diff --git a/Dockerfile b/Dockerfile index 5ebe438..5b93eb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ FROM nginx:alpine -COPY index.html /usr/share/nginx/html/index.html +COPY index.html /usr/share/nginx/html/ +COPY css/ /usr/share/nginx/html/css/ +COPY js/ /usr/share/nginx/html/js/ EXPOSE 80 diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..b625c22 --- /dev/null +++ b/css/style.css @@ -0,0 +1,208 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Segoe UI', system-ui, sans-serif; + background: #0a0a0f; + color: #e0e0e0; + overflow: hidden; + height: 100vh; +} + +/* ==================== Toolbar ==================== */ +#toolbar { + position: fixed; + top: 0; left: 0; right: 0; + height: 48px; + background: #12121a; + border-bottom: 1px solid #2a2a3a; + display: flex; + align-items: center; + padding: 0 12px; + gap: 6px; + z-index: 100; +} + +#toolbar .logo { + font-weight: 700; + font-size: 15px; + color: #00e599; + margin-right: 12px; +} + +.gate-btn { + padding: 5px 12px; + background: #1a1a2e; + border: 1px solid #2a2a3a; + border-radius: 6px; + color: #ccc; + cursor: pointer; + font-size: 12px; + font-weight: 600; + transition: all 0.15s; + user-select: none; +} +.gate-btn:hover { background: #252540; border-color: #00e599; color: #fff; } +.gate-btn.input-btn { border-color: #3388ff; } +.gate-btn.input-btn:hover { border-color: #55aaff; } +.gate-btn.clock-btn { border-color: #ff44aa; } +.gate-btn.clock-btn:hover { border-color: #ff66cc; } +.gate-btn.output-btn { border-color: #ff8833; } +.gate-btn.output-btn:hover { border-color: #ffaa55; } + +.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: 5px 10px; + background: transparent; + border: 1px solid #333; + border-radius: 6px; + color: #888; + cursor: pointer; + 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: 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; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.7); + z-index: 200; + justify-content: center; + align-items: center; +} +#help-modal.visible { display: flex; } + +#help-content { + background: #12121a; + border: 1px solid #2a2a3a; + border-radius: 12px; + padding: 24px; + max-width: 520px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} +#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: 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: 1px 5px; + font-size: 11px; + color: #ddd; +} +#help-close { + margin-top: 12px; + padding: 6px 18px; + background: #00e599; + border: none; + border-radius: 6px; + color: #000; + 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; +} diff --git a/index.html b/index.html index bac0dd3..d08337b 100644 --- a/index.html +++ b/index.html @@ -4,228 +4,7 @@ Logic Gates — MontLab - +
@@ -241,7 +20,7 @@
- +
@@ -253,15 +32,15 @@
- 📊 Waveform Viewer - + Waveform Viewer + - +
- - + + 500ms T=0 | 0 samples @@ -287,755 +66,24 @@

Clock & Simulation

  • Place a CLOCK gate — it auto-toggles 0/1 during simulation
  • -
  • Click ▶ Run in the waveform bar to start the clock
  • -
  • Use / + to adjust speed (50ms–2000ms per tick)
  • +
  • Click Run in the waveform bar to start the clock
  • +
  • Use - / + to adjust speed (50ms-2000ms per tick)
  • Connect CLOCK to gates to see automatic signal propagation

Waveform Viewer (GTKWave-style)

    -
  • Click 📊 Waveform to toggle the signal viewer
  • -
  • ⏺ Record captures signal changes automatically
  • -
  • Step ▶ manually advances one time step
  • +
  • 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 CLOCK, INPUT, OUTPUT, and gate signals are tracked
-

Built with ❤️ at MontLab

+

Built at MontLab

- + - diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..aaa50ed --- /dev/null +++ b/js/app.js @@ -0,0 +1,8 @@ +// Entry point — initializes all modules +import { initRenderer } from './renderer.js'; +import { initEvents } from './events.js'; + +document.addEventListener('DOMContentLoaded', () => { + initRenderer(); + initEvents(); +}); diff --git a/js/constants.js b/js/constants.js new file mode 100644 index 0000000..882c57b --- /dev/null +++ b/js/constants.js @@ -0,0 +1,26 @@ +// Gate dimensions and rendering constants +export const GATE_W = 100; +export const GATE_H = 60; +export const PORT_R = 7; + +export const GATE_COLORS = { + AND: '#00e599', OR: '#3388ff', NOT: '#ff6644', + NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa', + INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833' +}; + +export const SIGNAL_COLORS = [ + '#00e599', '#3388ff', '#ff6644', '#e5cc00', + '#cc44ff', '#ff44aa', '#ff8833', '#44ddff', + '#88ff44', '#ff4488', '#44ffaa', '#ffaa44' +]; + +export function gateInputCount(type) { + if (type === 'CLOCK' || type === 'INPUT') return 0; + if (type === 'NOT' || type === 'OUTPUT') return 1; + return 2; +} + +export function gateOutputCount(type) { + return type === 'OUTPUT' ? 0 : 1; +} diff --git a/js/events.js b/js/events.js new file mode 100644 index 0000000..761c4f0 --- /dev/null +++ b/js/events.js @@ -0,0 +1,193 @@ +// Event handlers — mouse, keyboard, toolbar, waveform controls +import { GATE_W, GATE_H } from './constants.js'; +import { state } from './state.js'; +import { evaluateAll, findGateAt, findPortAt } from './gates.js'; +import { manualStep, clearWaveData, updateWaveInfo } from './waveform.js'; +import { startSim, stopSim, adjustSpeed } from './simulation.js'; +import { resize } from './renderer.js'; + +export function initEvents() { + const canvas = document.getElementById('canvas'); + + // ==================== CANVAS MOUSE ==================== + canvas.addEventListener('mousemove', e => { + state.mouseX = e.offsetX; + state.mouseY = e.offsetY; + state.hoveredPort = findPortAt(state.mouseX, state.mouseY); + state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(state.mouseX, state.mouseY); + + if (state.dragging) { + state.dragging.x = state.mouseX - state.dragOffset.x; + state.dragging.y = state.mouseY - state.dragOffset.y; + evaluateAll(); + } + + canvas.style.cursor = state.placingGate ? 'crosshair' + : state.hoveredPort ? 'pointer' + : state.hoveredGate ? 'grab' + : 'default'; + }); + + canvas.addEventListener('mousedown', e => { + if (e.button !== 0) return; + const x = e.offsetX, y = e.offsetY; + + // Placing a new gate + if (state.placingGate) { + state.gates.push({ + id: state.nextId++, + type: state.placingGate, + x: x - GATE_W / 2, + y: y - GATE_H / 2, + value: 0 + }); + evaluateAll(); + state.placingGate = null; + return; + } + + // Port click — connecting + const port = findPortAt(x, y); + if (port) { + if (state.connecting) { + if (state.connecting.portType === 'output' && port.type === 'input') { + state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); + state.connections.push({ from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index }); + evaluateAll(); + } else if (state.connecting.portType === 'input' && port.type === 'output') { + state.connections = state.connections.filter(c => !(c.to === state.connecting.gate.id && c.toPort === state.connecting.portIndex)); + state.connections.push({ from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex }); + evaluateAll(); + } + state.connecting = null; + } else { + state.connecting = { gate: port.gate, portIndex: port.index, portType: port.type }; + } + return; + } + + if (state.connecting) { state.connecting = null; return; } + + // Toggle INPUT/CLOCK + const gate = findGateAt(x, y); + if (gate && (gate.type === 'INPUT' || gate.type === 'CLOCK')) { + gate.value = gate.value ? 0 : 1; + evaluateAll(); + return; + } + + // Drag gate + if (gate) { + state.dragging = gate; + state.dragOffset = { x: x - gate.x, y: y - gate.y }; + canvas.style.cursor = 'grabbing'; + } + }); + + canvas.addEventListener('mouseup', () => { state.dragging = null; }); + + canvas.addEventListener('contextmenu', e => { + e.preventDefault(); + const x = e.offsetX, y = e.offsetY; + const port = findPortAt(x, y); + if (port && port.type === 'input') { + state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); + evaluateAll(); + } else if (port && port.type === 'output') { + state.connections = state.connections.filter(c => !(c.from === port.gate.id && c.fromPort === port.index)); + evaluateAll(); + } + }); + + // ==================== KEYBOARD ==================== + document.addEventListener('keydown', e => { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (state.hoveredGate && document.activeElement === document.body) { + e.preventDefault(); + const gateId = state.hoveredGate.id; + state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId); + state.gates = state.gates.filter(g => g.id !== gateId); + delete state.waveData[gateId]; + state.hoveredGate = null; + evaluateAll(); + } + } + if (e.key === 'Escape') { + state.placingGate = null; + state.connecting = null; + } + }); + + // ==================== TOOLBAR ==================== + document.querySelectorAll('.gate-btn').forEach(btn => { + btn.addEventListener('click', () => { state.placingGate = btn.dataset.gate; }); + }); + + document.getElementById('clear-btn').addEventListener('click', () => { + if (state.gates.length === 0 || confirm('Clear all gates and connections?')) { + state.gates = []; + state.connections = []; + state.connecting = null; + state.placingGate = null; + clearWaveData(); + } + }); + + // Help modal + document.getElementById('help-btn').addEventListener('click', () => { + document.getElementById('help-modal').classList.add('visible'); + }); + document.getElementById('help-close').addEventListener('click', () => { + 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'); + }); + + // Waveform toggle + document.getElementById('sim-btn').addEventListener('click', () => { + state.waveformVisible = !state.waveformVisible; + document.getElementById('waveform-panel').classList.toggle('visible', state.waveformVisible); + document.getElementById('sim-btn').classList.toggle('active', state.waveformVisible); + resize(); + }); + + // ==================== WAVEFORM CONTROLS ==================== + document.getElementById('wave-record').addEventListener('click', function() { + state.recording = !state.recording; + this.classList.toggle('active', state.recording); + this.textContent = state.recording ? '⏺ Record' : '⏸ Paused'; + }); + + document.getElementById('wave-clear').addEventListener('click', clearWaveData); + document.getElementById('wave-step').addEventListener('click', manualStep); + + document.getElementById('wave-zoom-in').addEventListener('click', () => { + state.waveZoom = Math.min(60, state.waveZoom + 5); + }); + document.getElementById('wave-zoom-out').addEventListener('click', () => { + state.waveZoom = Math.max(5, state.waveZoom - 5); + }); + + // ==================== SIMULATION CONTROLS ==================== + document.getElementById('sim-run-btn').addEventListener('click', () => { + if (state.simRunning) stopSim(); else startSim(); + }); + document.getElementById('sim-faster').addEventListener('click', () => adjustSpeed(-100)); + document.getElementById('sim-slower').addEventListener('click', () => adjustSpeed(100)); + + // ==================== WAVEFORM PANEL RESIZE ==================== + const resizeHandle = document.getElementById('wave-resize'); + resizeHandle.addEventListener('mousedown', e => { + state.resizingWave = true; + e.preventDefault(); + }); + document.addEventListener('mousemove', e => { + if (state.resizingWave) { + state.waveformHeight = Math.max(100, Math.min(500, window.innerHeight - e.clientY)); + document.getElementById('waveform-panel').style.height = state.waveformHeight + 'px'; + resize(); + } + }); + document.addEventListener('mouseup', () => { state.resizingWave = false; }); +} diff --git a/js/gates.js b/js/gates.js new file mode 100644 index 0000000..8cfbe72 --- /dev/null +++ b/js/gates.js @@ -0,0 +1,78 @@ +// Gate evaluation and port geometry +import { GATE_W, GATE_H, PORT_R, gateInputCount, gateOutputCount } from './constants.js'; +import { state } from './state.js'; +import { recordSample } from './waveform.js'; + +export function getInputPorts(gate) { + const count = gateInputCount(gate.type); + const ports = []; + for (let i = 0; i < count; i++) { + const spacing = GATE_H / (count + 1); + ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' }); + } + return ports; +} + +export function getOutputPorts(gate) { + const count = gateOutputCount(gate.type); + const ports = []; + for (let i = 0; i < count; i++) { + ports.push({ x: gate.x + GATE_W, y: gate.y + GATE_H / 2, index: i, type: 'output' }); + } + return ports; +} + +export function evaluate(gate, visited = new Set()) { + if (visited.has(gate.id)) return gate.value || 0; + visited.add(gate.id); + if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value; + + const inputCount = gateInputCount(gate.type); + const inputs = []; + for (let i = 0; i < inputCount; i++) { + const conn = state.connections.find(c => c.to === gate.id && c.toPort === i); + if (conn) { + const srcGate = state.gates.find(g => g.id === conn.from); + inputs.push(srcGate ? evaluate(srcGate, visited) : 0); + } else { + inputs.push(0); + } + } + + let result = 0; + switch (gate.type) { + case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break; + case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break; + case 'NOT': result = inputs[0] ? 0 : 1; break; + case 'NAND': result = (inputs[0] && inputs[1]) ? 0 : 1; break; + case 'NOR': result = (inputs[0] || inputs[1]) ? 0 : 1; break; + case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break; + case 'OUTPUT': result = inputs[0] || 0; break; + } + gate.value = result; + return result; +} + +export function evaluateAll() { + state.gates.forEach(g => { + if (g.type !== 'INPUT' && g.type !== 'CLOCK') g.value = 0; + }); + state.gates.forEach(g => evaluate(g)); + if (state.recording && state.waveformVisible) recordSample(); +} + +export function findGateAt(x, y) { + return state.gates.find(g => x >= g.x && x <= g.x + GATE_W && y >= g.y && y <= g.y + GATE_H); +} + +export function findPortAt(x, y) { + for (const gate of state.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' }; + } + 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' }; + } + } + return null; +} diff --git a/js/renderer.js b/js/renderer.js new file mode 100644 index 0000000..7232965 --- /dev/null +++ b/js/renderer.js @@ -0,0 +1,188 @@ +// Canvas rendering — gates, connections, grid +import { GATE_W, GATE_H, PORT_R, GATE_COLORS } from './constants.js'; +import { state } from './state.js'; +import { getInputPorts, getOutputPorts } from './gates.js'; +import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js'; + +let canvas, ctx; + +export function initRenderer() { + canvas = document.getElementById('canvas'); + ctx = canvas.getContext('2d'); + resize(); + window.addEventListener('resize', resize); + requestAnimationFrame(draw); +} + +export function resize() { + canvas.width = window.innerWidth; + const waveH = state.waveformVisible ? state.waveformHeight : 0; + canvas.height = window.innerHeight - 48 - waveH; +} + +function drawGate(gate) { + const color = GATE_COLORS[gate.type]; + const isHovered = state.hoveredGate === gate; + const isActive = gate.value === 1; + + if (isActive) { + ctx.shadowColor = color; + ctx.shadowBlur = 20; + } + + ctx.fillStyle = isActive ? color + '22' : '#14141e'; + ctx.strokeStyle = isHovered ? '#fff' : color; + ctx.lineWidth = isHovered ? 2.5 : 1.5; + + ctx.beginPath(); + ctx.roundRect(gate.x, gate.y, GATE_W, GATE_H, 8); + ctx.fill(); + ctx.stroke(); + ctx.shadowBlur = 0; + + // Label + ctx.fillStyle = isActive ? '#fff' : color; + ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK'; + ctx.fillText( + gate.type === 'CLOCK' ? '⏱ CLK' : gate.type, + gate.x + GATE_W / 2, + gate.y + GATE_H / 2 - (isIOType ? 8 : 0) + ); + + // Small ID label + ctx.font = '9px monospace'; + ctx.fillStyle = '#444'; + ctx.fillText(getGateLabel(gate), gate.x + GATE_W / 2, gate.y + GATE_H - 6); + + // Value for INPUT/OUTPUT/CLOCK + if (isIOType) { + 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 + 10); + } + + // Input ports + getInputPorts(gate).forEach(p => { + const isPortHovered = state.hoveredPort && + state.hoveredPort.gate === gate && + state.hoveredPort.index === p.index && + state.hoveredPort.type === 'input'; + const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index); + const portActive = conn ? state.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'); + ctx.fill(); + ctx.strokeStyle = isPortHovered ? '#fff' : '#555'; + ctx.lineWidth = 1.5; + ctx.stroke(); + }); + + // Output ports + getOutputPorts(gate).forEach(p => { + const isPortHovered = state.hoveredPort && + state.hoveredPort.gate === gate && + state.hoveredPort.index === p.index && + state.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'); + ctx.fill(); + ctx.strokeStyle = isPortHovered ? '#fff' : '#555'; + ctx.lineWidth = 1.5; + ctx.stroke(); + }); +} + +function drawConnection(conn) { + const fromGate = state.gates.find(g => g.id === conn.from); + const toGate = state.gates.find(g => g.id === conn.to); + if (!fromGate || !toGate) return; + + const fromPort = getOutputPorts(fromGate)[conn.fromPort]; + const toPort = getInputPorts(toGate)[conn.toPort]; + if (!fromPort || !toPort) return; + + const active = fromGate.value === 1; + const midX = (fromPort.x + toPort.x) / 2; + + ctx.beginPath(); + ctx.moveTo(fromPort.x, fromPort.y); + ctx.bezierCurveTo(midX, fromPort.y, midX, toPort.y, toPort.x, toPort.y); + ctx.strokeStyle = active ? '#00ff88' : '#333'; + ctx.lineWidth = active ? 2.5 : 1.5; + ctx.stroke(); + + if (active) { + ctx.strokeStyle = '#00ff8844'; + ctx.lineWidth = 6; + ctx.stroke(); + } +} + +function drawGrid() { + ctx.strokeStyle = '#111118'; + ctx.lineWidth = 1; + for (let x = 0; x < canvas.width; x += 40) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); + } + for (let y = 0; y < canvas.height; y += 40) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); + } +} + +function drawConnectingWire() { + if (!state.connecting) return; + const gate = state.connecting.gate; + const port = state.connecting.portType === 'output' + ? getOutputPorts(gate)[state.connecting.portIndex] + : getInputPorts(gate)[state.connecting.portIndex]; + if (!port) return; + + const midX = (port.x + state.mouseX) / 2; + ctx.beginPath(); + ctx.moveTo(port.x, port.y); + ctx.bezierCurveTo(midX, port.y, midX, state.mouseY, state.mouseX, state.mouseY); + ctx.strokeStyle = '#00e59988'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.stroke(); + ctx.setLineDash([]); +} + +function drawPlacingGhost() { + if (!state.placingGate) return; + ctx.globalAlpha = 0.5; + const ghost = { + x: state.mouseX - GATE_W / 2, + y: state.mouseY - GATE_H / 2, + type: state.placingGate, + value: 0, + id: -1 + }; + drawGate(ghost); + ctx.globalAlpha = 1; +} + +function draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + drawGrid(); + state.connections.forEach(drawConnection); + state.gates.forEach(drawGate); + drawConnectingWire(); + drawPlacingGhost(); + + if (state.waveformVisible) { + drawWaveLabels(); + drawWaveforms(); + } + + requestAnimationFrame(draw); +} diff --git a/js/simulation.js b/js/simulation.js new file mode 100644 index 0000000..46fc7fb --- /dev/null +++ b/js/simulation.js @@ -0,0 +1,66 @@ +// Clock simulation engine +import { state } from './state.js'; +import { evaluateAll } from './gates.js'; +import { forceRecordSample } from './waveform.js'; + +export function simTick() { + // Toggle all CLOCK gates + state.gates.forEach(g => { + if (g.type === 'CLOCK') { + g.value = g.value ? 0 : 1; + } + }); + evaluateAll(); + // Force record even if evaluateAll didn't detect change + if (state.recording && state.waveformVisible) { + forceRecordSample(); + } +} + +export function startSim() { + if (state.simRunning) return; + const hasClocks = state.gates.some(g => g.type === 'CLOCK'); + if (!hasClocks) { alert('Place a CLOCK gate first!'); return; } + + state.simRunning = true; + + // Auto-open waveform panel + if (!state.waveformVisible) { + state.waveformVisible = true; + document.getElementById('waveform-panel').classList.add('visible'); + document.getElementById('sim-btn').classList.add('active'); + // Trigger resize via event so renderer picks it up + window.dispatchEvent(new Event('resize')); + } + + state.simInterval = setInterval(simTick, state.simSpeed); + updateSimUI(); +} + +export function stopSim() { + state.simRunning = false; + if (state.simInterval) clearInterval(state.simInterval); + state.simInterval = null; + updateSimUI(); +} + +export function updateSimUI() { + const btn = document.getElementById('sim-run-btn'); + if (state.simRunning) { + btn.textContent = '⏹ Stop'; + btn.classList.add('active'); + } else { + btn.textContent = '▶ Run'; + btn.classList.remove('active'); + } + document.getElementById('sim-speed-label').textContent = `${state.simSpeed}ms`; +} + +export function adjustSpeed(delta) { + state.simSpeed = Math.max(50, Math.min(2000, state.simSpeed + delta)); + if (state.simRunning) { + clearInterval(state.simInterval); + state.simInterval = setInterval(simTick, state.simSpeed); + } + updateSimUI(); +} diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..94e57fc --- /dev/null +++ b/js/state.js @@ -0,0 +1,32 @@ +// Shared application state — single source of truth +export const state = { + // Circuit + gates: [], + connections: [], + nextId: 1, + + // Interaction + placingGate: null, + dragging: null, + dragOffset: { x: 0, y: 0 }, + connecting: null, + hoveredGate: null, + hoveredPort: null, + mouseX: 0, + mouseY: 0, + + // Waveform + waveformVisible: false, + waveformHeight: 220, + recording: true, + waveData: {}, // { gateId: [{ t, value }] } + timeStep: 0, + waveZoom: 20, // pixels per time step + waveScroll: 0, + resizingWave: false, + + // Simulation + simRunning: false, + simInterval: null, + simSpeed: 500 // ms per tick +}; diff --git a/js/waveform.js b/js/waveform.js new file mode 100644 index 0000000..cb6a26b --- /dev/null +++ b/js/waveform.js @@ -0,0 +1,212 @@ +// 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; + + state.timeStep++; + 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() { + state.timeStep++; + 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() { + forceRecordSample(); +} + +export function updateWaveInfo() { + const totalSamples = Object.values(state.waveData).reduce((sum, arr) => sum + arr.length, 0); + document.getElementById('wave-info').textContent = `T=${state.timeStep} | ${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; + } + + // Auto-scroll to show latest + const maxVisible = Math.floor(wc.width / state.waveZoom); + if (state.timeStep > maxVisible) { + state.waveScroll = state.timeStep - maxVisible; + } + + // Draw time grid + wctx.strokeStyle = '#151520'; + wctx.lineWidth = 1; + for (let t = Math.ceil(state.waveScroll); t <= state.timeStep; t++) { + const x = (t - state.waveScroll) * state.waveZoom; + if (x < 0 || x > wc.width) continue; + wctx.beginPath(); + wctx.moveTo(x, 0); + wctx.lineTo(x, wc.height); + wctx.stroke(); + + if (t % 5 === 0 || state.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 = 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 lastVal = 0; + let started = false; + + // Build complete signal timeline + const timeline = []; + for (let t = 1; t <= state.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 - state.waveScroll) * state.waveZoom; + const val = timeline[t]; + const y = val ? yHigh : yLow; + + if (!started) { + wctx.moveTo(x, y); + started = true; + } else { + if (val !== lastVal) { + wctx.lineTo(x, lastVal ? yHigh : yLow); + wctx.lineTo(x, y); + } + wctx.lineTo(x + state.waveZoom, y); + } + lastVal = val; + } + wctx.stroke(); + + // Fill area under signal + wctx.globalAlpha = 0.08; + wctx.fillStyle = color; + for (let t = 0; t < timeline.length; t++) { + const x = (t + 1 - state.waveScroll) * state.waveZoom; + if (timeline[t]) { + wctx.fillRect(x, yHigh, state.waveZoom, sigH); + } + } + wctx.globalAlpha = 1; + }); + + // Cursor line at current time + const cursorX = (state.timeStep - state.waveScroll) * state.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([]); + } +}