fix: INPUT toggle no longer disrupts CLK timing in waveform

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>
This commit is contained in:
Jose Luis
2026-03-20 03:19:28 +01:00
parent f0f3516efa
commit d78b45841c

View File

@@ -1,247 +1,258 @@
// 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}`;
}
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;
// Manual toggles advance by simSpeed too for consistency
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();
}
export function forceRecordSample() {
// Advance time by the current simSpeed (in ms) to reflect real time
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([]);
}
}
// 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([]);
}
}