recordSample() (triggered by user INPUT toggle) now only advances timeStep when the simulation is stopped. When sim is running, it records at the current timeStep without advancing it, so the clock's regular tick cadence is never stolen by manual interactions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
8.3 KiB
JavaScript
259 lines
8.3 KiB
JavaScript
// Waveform recording and rendering (GTKWave-style)
|
|
import { SIGNAL_COLORS } from './constants.js';
|
|
import { state } from './state.js';
|
|
|
|
export function getTrackedGates() {
|
|
const { gates } = state;
|
|
const clocks = gates.filter(g => g.type === 'CLOCK');
|
|
const inputs = gates.filter(g => g.type === 'INPUT');
|
|
const outputs = gates.filter(g => g.type === 'OUTPUT');
|
|
const logic = gates.filter(g => g.type !== 'INPUT' && g.type !== 'OUTPUT' && g.type !== 'CLOCK');
|
|
return [...clocks, ...inputs, ...logic, ...outputs];
|
|
}
|
|
|
|
export function getGateLabel(gate) {
|
|
const sameType = state.gates.filter(g => g.type === gate.type);
|
|
const idx = sameType.indexOf(gate);
|
|
if (gate.type === 'CLOCK') return `CLK_${idx}`;
|
|
if (gate.type === 'INPUT') return `IN_${idx}`;
|
|
if (gate.type === 'OUTPUT') return `OUT_${idx}`;
|
|
return `${gate.type}_${idx}`;
|
|
}
|
|
|
|
/**
|
|
* Record a sample triggered by user interaction (INPUT toggle).
|
|
* When sim is running, records at current timeStep WITHOUT advancing it.
|
|
* When sim is stopped, advances timeStep first.
|
|
*/
|
|
export function recordSample() {
|
|
const { gates, waveData } = state;
|
|
|
|
const changed = gates.some(g => {
|
|
const data = waveData[g.id];
|
|
if (!data || data.length === 0) return true;
|
|
return data[data.length - 1].value !== g.value;
|
|
});
|
|
|
|
if (!changed && state.timeStep > 0) return;
|
|
|
|
// Only advance time if sim is NOT running (manual mode)
|
|
if (!state.simRunning) {
|
|
state.timeStep += state.simSpeed;
|
|
}
|
|
|
|
gates.forEach(g => {
|
|
if (!waveData[g.id]) waveData[g.id] = [];
|
|
const arr = waveData[g.id];
|
|
if (arr.length === 0 || arr[arr.length - 1].value !== g.value) {
|
|
arr.push({ t: state.timeStep, value: g.value });
|
|
}
|
|
});
|
|
updateWaveInfo();
|
|
}
|
|
|
|
/**
|
|
* Record a sample from the simulation tick.
|
|
* Always advances timeStep — this is the ONLY source of time when sim is running.
|
|
*/
|
|
export function forceRecordSample() {
|
|
state.timeStep += state.simSpeed;
|
|
state.gates.forEach(g => {
|
|
if (!state.waveData[g.id]) state.waveData[g.id] = [];
|
|
state.waveData[g.id].push({ t: state.timeStep, value: g.value });
|
|
});
|
|
updateWaveInfo();
|
|
}
|
|
|
|
export function manualStep() {
|
|
// Toggle all CLOCK gates on each step (same as simTick)
|
|
state.gates.forEach(g => {
|
|
if (g.type === 'CLOCK') {
|
|
g.value = g.value ? 0 : 1;
|
|
}
|
|
});
|
|
// Use the lazy-loaded evaluateAll to avoid circular imports
|
|
if (_evaluateAll) _evaluateAll();
|
|
forceRecordSample();
|
|
}
|
|
|
|
// Lazy reference to evaluateAll (set by gates.js to break circular dep)
|
|
let _evaluateAll = null;
|
|
export function setEvaluateAll(fn) {
|
|
_evaluateAll = fn;
|
|
}
|
|
|
|
export function updateWaveInfo() {
|
|
const totalSamples = Object.values(state.waveData).reduce((sum, arr) => sum + arr.length, 0);
|
|
const timeLabel = state.timeStep >= 1000
|
|
? `${(state.timeStep/1000).toFixed(1)}s`
|
|
: `${state.timeStep}ms`;
|
|
document.getElementById('wave-info').textContent = `T=${timeLabel} | ${totalSamples} samples`;
|
|
}
|
|
|
|
export function clearWaveData() {
|
|
state.waveData = {};
|
|
state.timeStep = 0;
|
|
state.waveScroll = 0;
|
|
updateWaveInfo();
|
|
}
|
|
|
|
export function drawWaveLabels() {
|
|
const labelsEl = document.getElementById('wave-labels');
|
|
labelsEl.innerHTML = '';
|
|
const tracked = getTrackedGates();
|
|
tracked.forEach((gate, i) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'wave-label';
|
|
if (gate.type === 'INPUT') div.classList.add('input-label');
|
|
else if (gate.type === 'OUTPUT') div.classList.add('output-label');
|
|
else div.classList.add('gate-label');
|
|
div.textContent = getGateLabel(gate);
|
|
div.style.color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
|
labelsEl.appendChild(div);
|
|
});
|
|
}
|
|
|
|
export function drawWaveforms() {
|
|
const wc = document.getElementById('wave-canvas');
|
|
const wctx = wc.getContext('2d');
|
|
const container = document.getElementById('wave-container');
|
|
|
|
wc.width = container.clientWidth - 100;
|
|
wc.height = container.clientHeight;
|
|
|
|
wctx.fillStyle = '#0c0c14';
|
|
wctx.fillRect(0, 0, wc.width, wc.height);
|
|
|
|
const tracked = getTrackedGates();
|
|
const rowH = 30;
|
|
const sigH = 20;
|
|
const margin = (rowH - sigH) / 2;
|
|
|
|
if (state.timeStep === 0) {
|
|
wctx.fillStyle = '#333';
|
|
wctx.font = '12px "Segoe UI", system-ui';
|
|
wctx.textAlign = 'center';
|
|
wctx.fillText('Toggle inputs to record signals...', wc.width / 2, wc.height / 2);
|
|
return;
|
|
}
|
|
|
|
// pxPerMs: how many pixels per millisecond of simulation time
|
|
const pxPerMs = state.waveZoom / 100; // waveZoom=20 → 0.2 px/ms
|
|
|
|
// Total width in pixels for all recorded time
|
|
const totalPx = state.timeStep * pxPerMs;
|
|
|
|
// Visible width in ms
|
|
const visibleMs = wc.width / pxPerMs;
|
|
|
|
// Auto-scroll: always follow the latest data, keep cursor at right edge
|
|
state.waveScroll = Math.max(0, state.timeStep - visibleMs);
|
|
|
|
// Helper: convert simulation time (ms) to pixel X
|
|
const tToX = (t) => (t - state.waveScroll) * pxPerMs;
|
|
|
|
// Draw time grid (every gridMs milliseconds)
|
|
let gridMs = 500;
|
|
if (pxPerMs * gridMs < 30) gridMs = 1000;
|
|
if (pxPerMs * gridMs < 30) gridMs = 2000;
|
|
if (pxPerMs * gridMs > 200) gridMs = 200;
|
|
if (pxPerMs * gridMs > 200) gridMs = 100;
|
|
|
|
wctx.strokeStyle = '#151520';
|
|
wctx.lineWidth = 1;
|
|
const startT = Math.floor(state.waveScroll / gridMs) * gridMs;
|
|
for (let t = startT; t <= state.timeStep; t += gridMs) {
|
|
const x = tToX(t);
|
|
if (x < 0 || x > wc.width) continue;
|
|
wctx.beginPath();
|
|
wctx.moveTo(x, 0);
|
|
wctx.lineTo(x, wc.height);
|
|
wctx.stroke();
|
|
|
|
wctx.fillStyle = '#333';
|
|
wctx.font = '9px monospace';
|
|
wctx.textAlign = 'center';
|
|
const label = t >= 1000 ? `${(t/1000).toFixed(1)}s` : `${t}ms`;
|
|
wctx.fillText(label, x, 10);
|
|
}
|
|
|
|
// Row dividers
|
|
tracked.forEach((_, i) => {
|
|
const y = i * rowH + rowH;
|
|
wctx.strokeStyle = '#111118';
|
|
wctx.beginPath();
|
|
wctx.moveTo(0, y);
|
|
wctx.lineTo(wc.width, y);
|
|
wctx.stroke();
|
|
});
|
|
|
|
// Draw signals using actual timestamps
|
|
tracked.forEach((gate, i) => {
|
|
const data = state.waveData[gate.id] || [];
|
|
if (data.length === 0) return;
|
|
|
|
const color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
|
|
const y0 = i * rowH + margin;
|
|
const yHigh = y0 + 2;
|
|
const yLow = y0 + sigH;
|
|
|
|
wctx.strokeStyle = color;
|
|
wctx.lineWidth = 1.5;
|
|
wctx.beginPath();
|
|
|
|
let started = false;
|
|
|
|
for (let s = 0; s < data.length; s++) {
|
|
const sample = data[s];
|
|
const nextT = s < data.length - 1 ? data[s + 1].t : state.timeStep;
|
|
const x1 = tToX(sample.t);
|
|
const x2 = tToX(nextT);
|
|
const y = sample.value ? yHigh : yLow;
|
|
|
|
if (!started) {
|
|
// Draw from time 0 to first sample
|
|
const x0 = tToX(0);
|
|
const initY = yLow; // default low before first sample
|
|
if (x0 < wc.width) {
|
|
wctx.moveTo(Math.max(0, x0), initY);
|
|
if (sample.t > 0) wctx.lineTo(x1, initY);
|
|
wctx.lineTo(x1, y);
|
|
}
|
|
started = true;
|
|
} else {
|
|
// Vertical transition from previous value
|
|
const prevVal = data[s - 1].value;
|
|
if (sample.value !== prevVal) {
|
|
wctx.lineTo(x1, prevVal ? yHigh : yLow);
|
|
wctx.lineTo(x1, y);
|
|
}
|
|
}
|
|
// Horizontal line to next transition
|
|
wctx.lineTo(x2, y);
|
|
|
|
// Fill high regions
|
|
if (sample.value) {
|
|
wctx.save();
|
|
wctx.globalAlpha = 0.08;
|
|
wctx.fillStyle = color;
|
|
wctx.fillRect(x1, yHigh, x2 - x1, sigH);
|
|
wctx.restore();
|
|
}
|
|
}
|
|
wctx.stroke();
|
|
});
|
|
|
|
// Cursor line at current time
|
|
const cursorX = tToX(state.timeStep);
|
|
if (cursorX >= 0 && cursorX <= wc.width) {
|
|
wctx.strokeStyle = '#00e59966';
|
|
wctx.lineWidth = 1;
|
|
wctx.setLineDash([4, 3]);
|
|
wctx.beginPath();
|
|
wctx.moveTo(cursorX, 0);
|
|
wctx.lineTo(cursorX, wc.height);
|
|
wctx.stroke();
|
|
wctx.setLineDash([]);
|
|
}
|
|
}
|