From a5ca5fdaa2e31bae1a8c2715bd4a25cff699c342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es?= Date: Thu, 19 Mar 2026 19:35:05 +0100 Subject: [PATCH] Add GTKWave-style waveform viewer - Signal recording panel with per-gate waveforms - Record/pause, step, zoom, clear controls - Auto-scroll to latest signals - Resizable panel with drag handle - Color-coded signals matching gate types Co-Authored-By: Claude Opus 4.6 --- index.html | 564 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 465 insertions(+), 99 deletions(-) diff --git a/index.html b/index.html index ba70d97..57495ab 100644 --- a/index.html +++ b/index.html @@ -20,29 +20,29 @@ top: 0; left: 0; right: 0; - height: 56px; + height: 48px; background: #12121a; border-bottom: 1px solid #2a2a3a; display: flex; align-items: center; - padding: 0 16px; - gap: 8px; + padding: 0 12px; + gap: 6px; z-index: 100; } #toolbar .logo { font-weight: 700; - font-size: 16px; + font-size: 15px; color: #00e599; - margin-right: 16px; + margin-right: 12px; } .gate-btn { - padding: 6px 14px; + padding: 5px 12px; background: #1a1a2e; border: 1px solid #2a2a3a; border-radius: 6px; color: #ccc; cursor: pointer; - font-size: 13px; + font-size: 12px; font-weight: 600; transition: all 0.15s; user-select: none; @@ -52,31 +52,121 @@ .gate-btn.input-btn:hover { border-color: #55aaff; } .gate-btn.output-btn { border-color: #ff8833; } .gate-btn.output-btn:hover { border-color: #ffaa55; } - .separator { width: 1px; height: 28px; background: #2a2a3a; margin: 0 8px; } - .toolbar-right { margin-left: auto; display: flex; gap: 8px; align-items: center; } + .separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 6px; } + .toolbar-right { margin-left: auto; display: flex; gap: 6px; align-items: center; } .action-btn { - padding: 6px 12px; + padding: 5px 10px; background: transparent; border: 1px solid #333; border-radius: 6px; color: #888; cursor: pointer; - font-size: 12px; + font-size: 11px; transition: all 0.15s; } .action-btn:hover { border-color: #ff4444; color: #ff4444; } .action-btn.help-btn:hover { border-color: #00e599; color: #00e599; } + .action-btn.sim-btn { border-color: #ff44aa; color: #ff44aa; } + .action-btn.sim-btn:hover { background: #ff44aa22; } + .action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; } /* Canvas */ #canvas { position: fixed; - top: 56px; + top: 48px; left: 0; right: 0; bottom: 0; cursor: default; } + /* Waveform panel */ + #waveform-panel { + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 220px; + background: #0c0c14; + border-top: 2px solid #00e599; + z-index: 90; + flex-direction: column; + } + #waveform-panel.visible { display: flex; } + + #wave-toolbar { + display: flex; + align-items: center; + padding: 4px 12px; + gap: 8px; + background: #10101a; + border-bottom: 1px solid #1a1a2a; + height: 32px; + flex-shrink: 0; + } + #wave-toolbar span.wave-title { + font-size: 12px; + font-weight: 700; + color: #00e599; + margin-right: 8px; + } + .wave-btn { + padding: 3px 10px; + background: #1a1a2e; + border: 1px solid #2a2a3a; + border-radius: 4px; + color: #aaa; + cursor: pointer; + font-size: 11px; + font-weight: 600; + transition: all 0.15s; + } + .wave-btn:hover { border-color: #00e599; color: #fff; } + .wave-btn.active { background: #00e59933; border-color: #00e599; color: #00e599; } + .wave-btn.record { border-color: #ff4444; } + .wave-btn.record.active { background: #ff444433; border-color: #ff4444; color: #ff4444; } + .wave-info { + margin-left: auto; + font-size: 11px; + color: #555; + } + + #wave-container { + display: flex; + flex: 1; + overflow: hidden; + } + + #wave-labels { + width: 100px; + flex-shrink: 0; + overflow-y: auto; + background: #0e0e18; + border-right: 1px solid #1a1a2a; + } + .wave-label { + height: 30px; + display: flex; + align-items: center; + padding: 0 8px; + font-size: 11px; + font-weight: 600; + border-bottom: 1px solid #111; + color: #888; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .wave-label.input-label { color: #3388ff; } + .wave-label.output-label { color: #ff8833; } + .wave-label.gate-label { color: #00e599; } + + #wave-canvas { + flex: 1; + cursor: crosshair; + } + /* Help modal */ #help-modal { display: none; @@ -92,28 +182,29 @@ background: #12121a; border: 1px solid #2a2a3a; border-radius: 12px; - padding: 32px; + padding: 24px; max-width: 520px; width: 90%; max-height: 80vh; overflow-y: auto; } - #help-content h2 { color: #00e599; margin-bottom: 16px; } + #help-content h2 { color: #00e599; margin-bottom: 12px; font-size: 18px; } + #help-content h3 { color: #ff44aa; margin: 12px 0 8px; font-size: 14px; } #help-content p, #help-content li { - color: #aaa; font-size: 14px; line-height: 1.6; margin-bottom: 8px; + color: #aaa; font-size: 13px; line-height: 1.5; margin-bottom: 6px; } #help-content ul { padding-left: 20px; } #help-content kbd { background: #1a1a2e; border: 1px solid #333; border-radius: 4px; - padding: 2px 6px; - font-size: 12px; + padding: 1px 5px; + font-size: 11px; color: #ddd; } #help-close { - margin-top: 16px; - padding: 8px 20px; + margin-top: 12px; + padding: 6px 18px; background: #00e599; border: none; border-radius: 6px; @@ -121,6 +212,17 @@ font-weight: 600; cursor: pointer; } + + /* Resize handle */ + #wave-resize { + position: absolute; + top: -4px; + left: 0; + right: 0; + height: 8px; + cursor: ns-resize; + z-index: 91; + } @@ -135,6 +237,8 @@ +
+
@@ -143,19 +247,45 @@ +
+
+
+ 📊 Waveform Viewer + + + + + + T=0 | 0 samples +
+
+
+ +
+
+

Logic Gate Simulator

    -
  • Click a gate button in the toolbar, then click on the canvas to place it
  • +
  • Click a gate button, then click on the canvas to place it
  • Drag gates to move them around
  • -
  • Click on an output port (right side), then click an input port (left side) to connect them
  • -
  • Click on an INPUT gate to toggle its value (0/1)
  • -
  • Press Delete or Backspace while hovering a gate to delete it
  • -
  • Right-click a connection to delete it
  • -
  • The circuit evaluates in real-time
  • +
  • Click on an output port (right), then an input port (left) to connect
  • +
  • Click an INPUT gate to toggle its value (0/1)
  • +
  • Delete while hovering a gate to remove it
  • +
  • Right-click a port to delete its connections
  • +
  • Escape to cancel placing/connecting
-

Built with ❤️ at MontLab

+

Waveform Viewer (GTKWave-style)

+
    +
  • Click 📊 Waveform to toggle the signal viewer
  • +
  • ⏺ Record captures signal changes automatically
  • +
  • Step ▶ manually advances one time step
  • +
  • Toggle inputs to see signals change in real-time
  • +
  • Drag the top border to resize the panel
  • +
  • All INPUT, OUTPUT, and gate signals are tracked
  • +
+

Built with ❤️ at MontLab

@@ -170,7 +300,7 @@ let placingGate = null; let dragging = null; let dragOffset = { x: 0, y: 0 }; - let connecting = null; // { gate, portIndex, portType } + let connecting = null; let hoveredGate = null; let hoveredPort = null; let mouseX = 0, mouseY = 0; @@ -185,6 +315,22 @@ INPUT: '#3388ff', OUTPUT: '#ff8833' }; + const SIGNAL_COLORS = [ + '#00e599', '#3388ff', '#ff6644', '#e5cc00', + '#cc44ff', '#ff44aa', '#ff8833', '#44ddff', + '#88ff44', '#ff4488', '#44ffaa', '#ffaa44' + ]; + + // Waveform state + let waveformVisible = false; + let waveformHeight = 220; + let recording = true; + let waveData = {}; // { gateId: [{ t, value }] } + let timeStep = 0; + let waveZoom = 20; // pixels per time step + let waveScroll = 0; + let resizingWave = false; + function gateInputCount(type) { if (type === 'NOT' || type === 'INPUT' || type === 'OUTPUT') return type === 'INPUT' ? 0 : 1; return 2; @@ -196,10 +342,8 @@ function evaluate(gate, visited = new Set()) { if (visited.has(gate.id)) return gate.value || 0; visited.add(gate.id); - if (gate.type === 'INPUT') return gate.value; - // Get input values const inputCount = gateInputCount(gate.type); const inputs = []; for (let i = 0; i < inputCount; i++) { @@ -229,8 +373,212 @@ function evaluateAll() { gates.forEach(g => { if (g.type !== 'INPUT') g.value = 0; }); gates.forEach(g => evaluate(g)); + if (recording && waveformVisible) recordSample(); } + // ==================== WAVEFORM ==================== + function recordSample() { + 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 && timeStep > 0) return; + + timeStep++; + gates.forEach(g => { + if (!waveData[g.id]) waveData[g.id] = []; + const arr = waveData[g.id]; + // Only record if value changed or first sample + if (arr.length === 0 || arr[arr.length - 1].value !== g.value) { + arr.push({ t: timeStep, value: g.value }); + } + }); + updateWaveInfo(); + } + + function manualStep() { + timeStep++; + gates.forEach(g => { + if (!waveData[g.id]) waveData[g.id] = []; + waveData[g.id].push({ t: timeStep, value: g.value }); + }); + updateWaveInfo(); + } + + function updateWaveInfo() { + const totalSamples = Object.values(waveData).reduce((sum, arr) => sum + arr.length, 0); + document.getElementById('wave-info').textContent = `T=${timeStep} | ${totalSamples} samples`; + } + + function getTrackedGates() { + // Order: inputs first, then gates, then outputs + 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'); + return [...inputs, ...logic, ...outputs]; + } + + function getGateLabel(gate) { + const sameType = gates.filter(g => g.type === gate.type); + const idx = sameType.indexOf(gate); + if (gate.type === 'INPUT') return `IN_${idx}`; + if (gate.type === 'OUTPUT') return `OUT_${idx}`; + return `${gate.type}_${idx}`; + } + + 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); + }); + } + + 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 (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; + } + + // Auto-scroll to show latest + const maxVisible = Math.floor(wc.width / waveZoom); + if (timeStep > maxVisible) { + waveScroll = timeStep - maxVisible; + } + + // Draw time grid + wctx.strokeStyle = '#151520'; + wctx.lineWidth = 1; + for (let t = Math.ceil(waveScroll); t <= timeStep; t++) { + const x = (t - waveScroll) * waveZoom; + if (x < 0 || x > wc.width) continue; + wctx.beginPath(); + wctx.moveTo(x, 0); + wctx.lineTo(x, wc.height); + wctx.stroke(); + + // Time labels + if (t % 5 === 0 || waveZoom > 30) { + wctx.fillStyle = '#333'; + wctx.font = '9px monospace'; + wctx.textAlign = 'center'; + wctx.fillText(`${t}`, 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 + tracked.forEach((gate, i) => { + const data = 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 lastVal = 0; + let lastX = 0; + let started = false; + + // Build complete signal timeline + const timeline = []; + for (let t = 1; t <= timeStep; t++) { + const sample = data.filter(s => s.t <= t).pop(); + timeline.push(sample ? sample.value : 0); + } + + for (let t = 0; t < timeline.length; t++) { + const x = (t + 1 - waveScroll) * waveZoom; + const val = timeline[t]; + const y = val ? yHigh : yLow; + + if (!started) { + wctx.moveTo(x, y); + started = true; + } else { + // Vertical transition + if (val !== lastVal) { + wctx.lineTo(x, lastVal ? yHigh : yLow); + wctx.lineTo(x, y); + } + wctx.lineTo(x + waveZoom, y); + } + lastVal = val; + lastX = x + waveZoom; + } + + wctx.stroke(); + + // Fill area under signal + wctx.globalAlpha = 0.08; + wctx.fillStyle = color; + // Simple fill + for (let t = 0; t < timeline.length; t++) { + const x = (t + 1 - waveScroll) * waveZoom; + if (timeline[t]) { + wctx.fillRect(x, yHigh, waveZoom, sigH); + } + } + wctx.globalAlpha = 1; + }); + + // Cursor line at current time + const cursorX = (timeStep - waveScroll) * waveZoom; + 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([]); + } + } + + // ==================== DRAWING ==================== function getInputPorts(gate) { const count = gateInputCount(gate.type); const ports = []; @@ -252,7 +600,8 @@ function resize() { canvas.width = window.innerWidth; - canvas.height = window.innerHeight - 56; + const waveH = waveformVisible ? waveformHeight : 0; + canvas.height = window.innerHeight - 48 - waveH; } window.addEventListener('resize', resize); resize(); @@ -262,20 +611,17 @@ const isHovered = hoveredGate === gate; const isActive = gate.value === 1; - // Shadow for active gates if (isActive) { ctx.shadowColor = color; ctx.shadowBlur = 20; } - // Body ctx.fillStyle = isActive ? color + '22' : '#14141e'; ctx.strokeStyle = isHovered ? '#fff' : color; ctx.lineWidth = isHovered ? 2.5 : 1.5; - const r = 8; ctx.beginPath(); - ctx.roundRect(gate.x, gate.y, GATE_W, GATE_H, r); + ctx.roundRect(gate.x, gate.y, GATE_W, GATE_H, 8); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; @@ -285,21 +631,27 @@ ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + + const label = getGateLabel(gate); ctx.fillText(gate.type, gate.x + GATE_W / 2, gate.y + GATE_H / 2 - (gate.type === 'INPUT' || gate.type === 'OUTPUT' ? 8 : 0)); + // Small ID label + ctx.font = '9px monospace'; + ctx.fillStyle = '#444'; + ctx.fillText(label, gate.x + GATE_W / 2, gate.y + GATE_H - 6); + // Value for INPUT/OUTPUT if (gate.type === 'INPUT' || gate.type === 'OUTPUT') { - ctx.font = 'bold 18px monospace'; + ctx.font = 'bold 16px monospace'; ctx.fillStyle = gate.value ? '#00ff88' : '#ff4444'; - ctx.fillText(gate.value ? '1' : '0', gate.x + GATE_W / 2, gate.y + GATE_H / 2 + 12); + ctx.fillText(gate.value ? '1' : '0', gate.x + GATE_W / 2, gate.y + GATE_H / 2 + 10); } - // Input ports + // Ports getInputPorts(gate).forEach(p => { const isPortHovered = hoveredPort && hoveredPort.gate === gate && hoveredPort.index === p.index && hoveredPort.type === 'input'; const conn = connections.find(c => c.to === gate.id && c.toPort === p.index); const portActive = conn ? gates.find(g => g.id === conn.from)?.value : 0; - ctx.beginPath(); ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2); ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e'); @@ -309,10 +661,8 @@ ctx.stroke(); }); - // Output ports getOutputPorts(gate).forEach(p => { const isPortHovered = hoveredPort && hoveredPort.gate === gate && hoveredPort.index === p.index && hoveredPort.type === 'output'; - ctx.beginPath(); ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2); ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e'); @@ -342,7 +692,6 @@ ctx.lineWidth = active ? 2.5 : 1.5; ctx.stroke(); - // Glow effect for active wires if (active) { ctx.strokeStyle = '#00ff8844'; ctx.lineWidth = 6; @@ -393,6 +742,12 @@ ctx.globalAlpha = 1; } + // Draw waveform + if (waveformVisible) { + drawWaveLabels(); + drawWaveforms(); + } + requestAnimationFrame(draw); } @@ -403,19 +758,16 @@ function findPortAt(x, y) { for (const gate of gates) { for (const p of getInputPorts(gate)) { - if (Math.hypot(x - p.x, y - p.y) < PORT_R + 4) { - return { gate, index: p.index, type: 'input' }; - } + if (Math.hypot(x - p.x, y - p.y) < PORT_R + 4) return { gate, index: p.index, type: 'input' }; } for (const p of getOutputPorts(gate)) { - if (Math.hypot(x - p.x, y - p.y) < PORT_R + 4) { - return { gate, index: p.index, type: 'output' }; - } + if (Math.hypot(x - p.x, y - p.y) < PORT_R + 4) return { gate, index: p.index, type: 'output' }; } } return null; } + // ==================== EVENTS ==================== canvas.addEventListener('mousemove', e => { mouseX = e.offsetX; mouseY = e.offsetY; @@ -438,43 +790,23 @@ if (e.button !== 0) return; const x = e.offsetX, y = e.offsetY; - // Placing a gate if (placingGate) { - gates.push({ - id: nextId++, - type: placingGate, - x: x - GATE_W / 2, - y: y - GATE_H / 2, - value: 0 - }); + gates.push({ id: nextId++, type: placingGate, x: x - GATE_W / 2, y: y - GATE_H / 2, value: 0 }); evaluateAll(); placingGate = null; return; } - // Check port click const port = findPortAt(x, y); if (port) { if (connecting) { - // Complete connection if (connecting.portType === 'output' && port.type === 'input') { - // Remove existing connection to this input connections = connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); - connections.push({ - from: connecting.gate.id, - fromPort: connecting.portIndex, - to: port.gate.id, - toPort: port.index - }); + connections.push({ from: connecting.gate.id, fromPort: connecting.portIndex, to: port.gate.id, toPort: port.index }); evaluateAll(); } else if (connecting.portType === 'input' && port.type === 'output') { connections = connections.filter(c => !(c.to === connecting.gate.id && c.toPort === connecting.portIndex)); - connections.push({ - from: port.gate.id, - fromPort: port.index, - to: connecting.gate.id, - toPort: connecting.portIndex - }); + connections.push({ from: port.gate.id, fromPort: port.index, to: connecting.gate.id, toPort: connecting.portIndex }); evaluateAll(); } connecting = null; @@ -484,13 +816,8 @@ return; } - // Cancel connecting - if (connecting) { - connecting = null; - return; - } + if (connecting) { connecting = null; return; } - // Toggle input const gate = findGateAt(x, y); if (gate && gate.type === 'INPUT') { gate.value = gate.value ? 0 : 1; @@ -498,7 +825,6 @@ return; } - // Start drag if (gate) { dragging = gate; dragOffset = { x: x - gate.x, y: y - gate.y }; @@ -506,50 +832,39 @@ } }); - canvas.addEventListener('mouseup', () => { - dragging = null; - }); + canvas.addEventListener('mouseup', () => { dragging = null; }); - // Right-click to delete connections canvas.addEventListener('contextmenu', e => { e.preventDefault(); const x = e.offsetX, y = e.offsetY; - - // Check if clicking near a connection const port = findPortAt(x, y); if (port && port.type === 'input') { connections = connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); evaluateAll(); - return; - } - if (port && port.type === 'output') { + } else if (port && port.type === 'output') { connections = connections.filter(c => !(c.from === port.gate.id && c.fromPort === port.index)); evaluateAll(); } }); - // Delete key document.addEventListener('keydown', e => { if (e.key === 'Delete' || e.key === 'Backspace') { if (hoveredGate && document.activeElement === document.body) { e.preventDefault(); - connections = connections.filter(c => c.from !== hoveredGate.id && c.to !== hoveredGate.id); - gates = gates.filter(g => g !== hoveredGate); + const gateId = hoveredGate.id; + connections = connections.filter(c => c.from !== gateId && c.to !== gateId); + gates = gates.filter(g => g.id !== gateId); + delete waveData[gateId]; hoveredGate = null; evaluateAll(); } } - if (e.key === 'Escape') { - placingGate = null; - connecting = null; - } + if (e.key === 'Escape') { placingGate = null; connecting = null; } }); - // Toolbar buttons + // Toolbar document.querySelectorAll('.gate-btn').forEach(btn => { - btn.addEventListener('click', () => { - placingGate = btn.dataset.gate; - }); + btn.addEventListener('click', () => { placingGate = btn.dataset.gate; }); }); document.getElementById('clear-btn').addEventListener('click', () => { @@ -558,9 +873,13 @@ connections = []; connecting = null; placingGate = null; + waveData = {}; + timeStep = 0; + updateWaveInfo(); } }); + // Help document.getElementById('help-btn').addEventListener('click', () => { document.getElementById('help-modal').classList.add('visible'); }); @@ -568,10 +887,57 @@ document.getElementById('help-modal').classList.remove('visible'); }); document.getElementById('help-modal').addEventListener('click', e => { - if (e.target === e.currentTarget) { - document.getElementById('help-modal').classList.remove('visible'); + if (e.target === e.currentTarget) document.getElementById('help-modal').classList.remove('visible'); + }); + + // Waveform toggle + document.getElementById('sim-btn').addEventListener('click', () => { + waveformVisible = !waveformVisible; + document.getElementById('waveform-panel').classList.toggle('visible', waveformVisible); + document.getElementById('sim-btn').classList.toggle('active', waveformVisible); + resize(); + }); + + // Waveform controls + document.getElementById('wave-record').addEventListener('click', function() { + recording = !recording; + this.classList.toggle('active', recording); + this.textContent = recording ? '⏺ Record' : '⏸ Paused'; + }); + + document.getElementById('wave-clear').addEventListener('click', () => { + waveData = {}; + timeStep = 0; + waveScroll = 0; + updateWaveInfo(); + }); + + document.getElementById('wave-step').addEventListener('click', () => { + manualStep(); + }); + + document.getElementById('wave-zoom-in').addEventListener('click', () => { + waveZoom = Math.min(60, waveZoom + 5); + }); + + document.getElementById('wave-zoom-out').addEventListener('click', () => { + waveZoom = Math.max(5, waveZoom - 5); + }); + + // Resize waveform panel + const resizeHandle = document.getElementById('wave-resize'); + resizeHandle.addEventListener('mousedown', e => { + resizingWave = true; + e.preventDefault(); + }); + document.addEventListener('mousemove', e => { + if (resizingWave) { + waveformHeight = Math.max(100, Math.min(500, window.innerHeight - e.clientY)); + document.getElementById('waveform-panel').style.height = waveformHeight + 'px'; + resize(); } }); + document.addEventListener('mouseup', () => { resizingWave = false; }); // Start draw();