feat: CLK toggles on Step, add pan/zoom (arrows, +/-, wheel, 0=reset)
This commit is contained in:
81
js/events.js
81
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user