diff --git a/js/waveform.js b/js/waveform.js index 6280194..1c0a152 100644 --- a/js/waveform.js +++ b/js/waveform.js @@ -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([]); + } +}