diff --git a/js/events.js b/js/events.js index 761c4f0..128b767 100644 --- a/js/events.js +++ b/js/events.js @@ -2,9 +2,11 @@ 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 { manualStep, clearWaveData } from './waveform.js'; import { startSim, stopSim, adjustSpeed } from './simulation.js'; -import { resize } from './renderer.js'; +import { resize, screenToWorld } from './renderer.js'; + +const PAN_SPEED = 40; export function initEvents() { const canvas = document.getElementById('canvas'); @@ -13,12 +15,15 @@ export function initEvents() { 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); + + // Convert to world coords for gate/port detection + const world = screenToWorld(e.offsetX, e.offsetY); + state.hoveredPort = findPortAt(world.x, world.y); + state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y); if (state.dragging) { - state.dragging.x = state.mouseX - state.dragOffset.x; - state.dragging.y = state.mouseY - state.dragOffset.y; + state.dragging.x = world.x - state.dragOffset.x; + state.dragging.y = world.y - state.dragOffset.y; evaluateAll(); } @@ -30,15 +35,15 @@ export function initEvents() { canvas.addEventListener('mousedown', e => { if (e.button !== 0) return; - const x = e.offsetX, y = e.offsetY; + const world = screenToWorld(e.offsetX, 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, + x: world.x - GATE_W / 2, + y: world.y - GATE_H / 2, value: 0 }); evaluateAll(); @@ -47,7 +52,7 @@ export function initEvents() { } // Port click — connecting - const port = findPortAt(x, y); + const port = findPortAt(world.x, world.y); if (port) { if (state.connecting) { if (state.connecting.portType === 'output' && port.type === 'input') { @@ -69,7 +74,7 @@ export function initEvents() { if (state.connecting) { state.connecting = null; return; } // Toggle INPUT/CLOCK - const gate = findGateAt(x, y); + const gate = findGateAt(world.x, world.y); if (gate && (gate.type === 'INPUT' || gate.type === 'CLOCK')) { gate.value = gate.value ? 0 : 1; evaluateAll(); @@ -79,7 +84,7 @@ export function initEvents() { // Drag gate if (gate) { state.dragging = gate; - state.dragOffset = { x: x - gate.x, y: y - gate.y }; + state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y }; canvas.style.cursor = 'grabbing'; } }); @@ -88,8 +93,8 @@ export function initEvents() { canvas.addEventListener('contextmenu', e => { e.preventDefault(); - const x = e.offsetX, y = e.offsetY; - const port = findPortAt(x, y); + const world = screenToWorld(e.offsetX, e.offsetY); + const port = findPortAt(world.x, world.y); if (port && port.type === 'input') { state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); evaluateAll(); @@ -99,8 +104,27 @@ export function initEvents() { } }); + // ==================== MOUSE WHEEL ZOOM ==================== + canvas.addEventListener('wheel', e => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + const newZoom = Math.max(0.2, Math.min(3, state.zoom + delta)); + + // Zoom towards mouse position + const worldBefore = screenToWorld(e.offsetX, e.offsetY); + state.zoom = newZoom; + const worldAfter = screenToWorld(e.offsetX, e.offsetY); + + state.camX += (worldAfter.x - worldBefore.x) * state.zoom; + state.camY += (worldAfter.y - worldBefore.y) * state.zoom; + }, { passive: false }); + // ==================== KEYBOARD ==================== + const keysDown = new Set(); + document.addEventListener('keydown', e => { + keysDown.add(e.key); + if (e.key === 'Delete' || e.key === 'Backspace') { if (state.hoveredGate && document.activeElement === document.body) { e.preventDefault(); @@ -116,6 +140,33 @@ export function initEvents() { state.placingGate = null; state.connecting = null; } + + // Pan with arrow keys + if (e.key === 'ArrowLeft') { state.camX += PAN_SPEED; e.preventDefault(); } + if (e.key === 'ArrowRight') { state.camX -= PAN_SPEED; e.preventDefault(); } + if (e.key === 'ArrowUp') { state.camY += PAN_SPEED; e.preventDefault(); } + if (e.key === 'ArrowDown') { state.camY -= PAN_SPEED; e.preventDefault(); } + + // Zoom with +/- keys + if ((e.key === '+' || e.key === '=') && document.activeElement === document.body) { + state.zoom = Math.min(3, state.zoom + 0.1); + e.preventDefault(); + } + if ((e.key === '-' || e.key === '_') && document.activeElement === document.body) { + state.zoom = Math.max(0.2, state.zoom - 0.1); + e.preventDefault(); + } + + // Reset view with 0 + if (e.key === '0' && document.activeElement === document.body) { + state.camX = 0; + state.camY = 0; + state.zoom = 1; + } + }); + + document.addEventListener('keyup', e => { + keysDown.delete(e.key); }); // ==================== TOOLBAR ==================== @@ -156,7 +207,7 @@ export function initEvents() { document.getElementById('wave-record').addEventListener('click', function() { state.recording = !state.recording; this.classList.toggle('active', state.recording); - this.textContent = state.recording ? '⏺ Record' : '⏸ Paused'; + this.textContent = state.recording ? 'Record' : 'Paused'; }); document.getElementById('wave-clear').addEventListener('click', clearWaveData); diff --git a/js/gates.js b/js/gates.js index 8cfbe72..a973587 100644 --- a/js/gates.js +++ b/js/gates.js @@ -1,7 +1,7 @@ // 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'; +import { recordSample, setEvaluateAll } from './waveform.js'; export function getInputPorts(gate) { const count = gateInputCount(gate.type); @@ -61,6 +61,9 @@ export function evaluateAll() { if (state.recording && state.waveformVisible) recordSample(); } +// Register evaluateAll in waveform to break circular dependency +setEvaluateAll(evaluateAll); + 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); } diff --git a/js/renderer.js b/js/renderer.js index 7232965..2a778ce 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -20,6 +20,14 @@ export function resize() { canvas.height = window.innerHeight - 48 - waveH; } +// Convert screen coords to world coords (accounting for pan/zoom) +export function screenToWorld(sx, sy) { + return { + x: (sx - state.camX) / state.zoom, + y: (sy - state.camY) / state.zoom + }; +} + function drawGate(gate) { const color = GATE_COLORS[gate.type]; const isHovered = state.hoveredGate === gate; @@ -27,12 +35,12 @@ function drawGate(gate) { if (isActive) { ctx.shadowColor = color; - ctx.shadowBlur = 20; + ctx.shadowBlur = 20 * state.zoom; } ctx.fillStyle = isActive ? color + '22' : '#14141e'; ctx.strokeStyle = isHovered ? '#fff' : color; - ctx.lineWidth = isHovered ? 2.5 : 1.5; + ctx.lineWidth = (isHovered ? 2.5 : 1.5); ctx.beginPath(); ctx.roundRect(gate.x, gate.y, GATE_W, GATE_H, 8); @@ -42,7 +50,7 @@ function drawGate(gate) { // Label ctx.fillStyle = isActive ? '#fff' : color; - ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif'; + ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; @@ -127,13 +135,22 @@ function drawConnection(conn) { } function drawGrid() { + const gridSize = 40; 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(); + + // Calculate visible grid range in world coords + const topLeft = screenToWorld(0, 0); + const bottomRight = screenToWorld(canvas.width, canvas.height); + + const startX = Math.floor(topLeft.x / gridSize) * gridSize; + const startY = Math.floor(topLeft.y / gridSize) * gridSize; + + for (let x = startX; x < bottomRight.x; x += gridSize) { + ctx.beginPath(); ctx.moveTo(x, topLeft.y); ctx.lineTo(x, bottomRight.y); ctx.stroke(); } - for (let y = 0; y < canvas.height; y += 40) { - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); + for (let y = startY; y < bottomRight.y; y += gridSize) { + ctx.beginPath(); ctx.moveTo(topLeft.x, y); ctx.lineTo(bottomRight.x, y); ctx.stroke(); } } @@ -145,10 +162,12 @@ function drawConnectingWire() { : getInputPorts(gate)[state.connecting.portIndex]; if (!port) return; - const midX = (port.x + state.mouseX) / 2; + // Convert mouse to world coords for the wire endpoint + const world = screenToWorld(state.mouseX, state.mouseY); + const midX = (port.x + world.x) / 2; ctx.beginPath(); ctx.moveTo(port.x, port.y); - ctx.bezierCurveTo(midX, port.y, midX, state.mouseY, state.mouseX, state.mouseY); + ctx.bezierCurveTo(midX, port.y, midX, world.y, world.x, world.y); ctx.strokeStyle = '#00e59988'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); @@ -159,9 +178,10 @@ function drawConnectingWire() { function drawPlacingGhost() { if (!state.placingGate) return; ctx.globalAlpha = 0.5; + const world = screenToWorld(state.mouseX, state.mouseY); const ghost = { - x: state.mouseX - GATE_W / 2, - y: state.mouseY - GATE_H / 2, + x: world.x - GATE_W / 2, + y: world.y - GATE_H / 2, type: state.placingGate, value: 0, id: -1 @@ -170,15 +190,36 @@ function drawPlacingGhost() { ctx.globalAlpha = 1; } +function drawZoomIndicator() { + const pct = Math.round(state.zoom * 100); + ctx.save(); + ctx.resetTransform(); + ctx.fillStyle = '#333'; + ctx.font = '11px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(`${pct}%`, canvas.width - 10, canvas.height - 10); + ctx.restore(); +} + function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); + // Apply camera transform + ctx.save(); + ctx.translate(state.camX, state.camY); + ctx.scale(state.zoom, state.zoom); + drawGrid(); state.connections.forEach(drawConnection); state.gates.forEach(drawGate); drawConnectingWire(); drawPlacingGhost(); + ctx.restore(); + + // HUD (drawn without transform) + drawZoomIndicator(); + if (state.waveformVisible) { drawWaveLabels(); drawWaveforms(); diff --git a/js/state.js b/js/state.js index 94e57fc..ba5ff71 100644 --- a/js/state.js +++ b/js/state.js @@ -15,6 +15,11 @@ export const state = { mouseX: 0, mouseY: 0, + // Camera (pan/zoom) + camX: 0, + camY: 0, + zoom: 1, + // Waveform waveformVisible: false, waveformHeight: 220, diff --git a/js/waveform.js b/js/waveform.js index cb6a26b..7c10274 100644 --- a/js/waveform.js +++ b/js/waveform.js @@ -52,9 +52,23 @@ export function forceRecordSample() { } 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); document.getElementById('wave-info').textContent = `T=${state.timeStep} | ${totalSamples} samples`;