Node Graph Editor
Claude haiku 5.5 · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · a69d273fc6
# Task: Build a Logic Circuit Node-Graph Editor
Build an interactive node-graph editor (vanilla JS, no external assets, no CDN, no libraries) where users wire logic gates together and watch signals flow live. The top priority is **correct graph interaction and evaluation** — dragging wires must feel precise, and the circuit must compute the right answer every time.
## Core Specification
**Canvas & Viewport**
- An infinite pannable canvas: drag empty space to pan, mouse wheel to zoom toward the cursor (50%–200%)
- Nodes and wires live in world coordinates; all hit-testing stays accurate at every zoom level
- Subtle dot grid that pans/zooms with the world
**Nodes**
- **Inputs:** Toggle switch (click to flip on/off), Clock (pulses at a selectable rate)
- **Gates:** AND, OR, NOT, XOR, NAND, NOR — each with the standard truth table
- **Outputs:** Lamp (glows on), plus a 7-segment-style digit display driven by 4 input bits (binary → hex digit) if you want a stretch goal
- Palette (sidebar or right-click menu) to add nodes; drag nodes by their body to move them; Delete key removes the selected node and all its wires
- Every node shows its name and a live on/off state tint; input/output **ports** are clearly visible circles on the left/right edges
**Wires**
- Drag from an output port to an input port to connect; dragging from an occupied input port picks up the existing wire to re-route it
- Live wire preview follows the cursor while dragging, snapping/highlighting when over a valid target port
- Wires render as smooth bezier curves; energized wires glow, de-energized wires are dim
- Type rules: one wire per input port (connecting to an occupied port replaces the old wire); outputs fan out freely
- **Cycle rejection:** a connection that would create a feedback loop must be refused with a clear visual cue (red flash on the wire) — combinational circuits only
**Evaluation Engine**
- Propagate signal changes reactively: toggling an input re-evaluates only downstream nodes, in topological order
- Evaluation must converge in a single pass per change (topological evaluation, not iterate-until-stable guessing)
- Clock nodes tick on a timer and re-trigger evaluation of their downstream graph each pulse
- No evaluation glitches when wiring or unwiring mid-simulation
**Persistence**
- The full graph (nodes, positions, wires, toggle states, clock rates) serializes to `localStorage` and restores exactly on reload
## Visual Style
- Dark, instrument-panel aesthetic: rounded node cards with subtle depth, color-coded ports (inputs cool, outputs warm), glowing wires and lamps
- Hover states on every interactive element (ports enlarge, nodes lift slightly)
- Clean and legible at both zoom extremes — port hit targets must stay comfortable
## Technical Requirements
- Vanilla JS with HTML5 Canvas or SVG for the graph surface; all code organized with clear section comments
- Clean data model: a serializable scene of `{ nodes: [{ id, type, x, y, state }], wires: [{ from, fromPort, to, toPort }] }` — rendering, hit-testing, evaluation, and persistence all derive from it
- Tunable constants block at the top (colors, zoom limits, port radius, hit tolerance, clock rates)
- Target: smooth panning/dragging with 50+ nodes and live evaluation
## Quality Bar (must hit all)
- Every gate computes its standard truth table; a 3-gate chain updates end-to-end on one input toggle
- Zoom-to-cursor: the world point under the cursor stays under the cursor while zooming
- Port snapping and wire hit-testing are accurate at 50% and 200% zoom
- A cycle attempt (e.g. NOR feeding its own input through an inverter chain) is rejected, not hung on
- Reload restores the exact circuit, including toggle states and wire routing
## Self-Check Before Finishing
Verify with the browser tools:
- Build: Toggle → AND(1) with Toggle → NOT → Lamp; flip each toggle and assert the lamp matches the truth table both ways
- Re-wire an occupied input port and confirm the old wire is gone and evaluation reflects the new source
- Attempt to wire an output back into an upstream input of its own chain — must be refused
- Zoom in fully, drag a wire between two distant ports — the drop must land on the port under the cursor
- Reload the page and confirm the circuit is restored and still evaluating
Inspect original source 35,149 bytes · SHA-256 3e6b3dc0ecde
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Logic Lab — Node Graph Editor</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#0b0f14;color:#cfd8e3;font:13px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden}
#app{display:flex;height:100vh;width:100vw}
#side{width:212px;flex:none;background:linear-gradient(#151c25,#10161e);border-right:1px solid #222c38;padding:14px 12px;display:flex;flex-direction:column;gap:6px;box-shadow:4px 0 16px rgba(0,0,0,.35);z-index:2;overflow-y:auto}
#side h1{font-size:13px;letter-spacing:.16em;text-transform:uppercase;margin:0 0 2px;color:#8fd6b5}
#side h2{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#5f6e7e;margin:12px 0 2px}
#side p{color:#6b7a8a;font-size:11.5px;margin:2px 0 4px;line-height:1.5}
#side button{display:block;width:100%;text-align:left;background:#1a232e;border:1px solid #2a3644;color:#cfd8e3;border-radius:8px;padding:7px 10px;font:inherit;cursor:pointer;transition:background .15s,border-color .15s,transform .15s,box-shadow .15s}
#side button:hover{background:#243245;border-color:#4a86c5;transform:translateX(2px);box-shadow:0 0 0 1px rgba(74,134,197,.3)}
#side button:active{transform:translateX(1px) scale(.98)}
#side .row{display:flex;gap:6px}
#side .row button{flex:1;text-align:center}
#wrap{position:relative;flex:1;overflow:hidden;min-width:0}
#cv{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none}
#hud{position:absolute;left:12px;bottom:10px;font-size:11px;color:#6b7a8a;pointer-events:none;font-variant-numeric:tabular-nums;background:rgba(11,15,20,.6);padding:3px 8px;border-radius:6px}
#toast{position:absolute;top:12px;left:50%;transform:translateX(-50%);background:rgba(60,18,24,.94);border:1px solid #ff4d5e;color:#ffd1d6;padding:7px 14px;border-radius:8px;font-size:12px;opacity:0;transition:opacity .25s;pointer-events:none;box-shadow:0 6px 20px rgba(0,0,0,.4);white-space:nowrap}
#toast.on{opacity:1}
</style>
</head>
<body>
<div id="app">
<aside id="side">
<h1>Logic Lab</h1>
<p>Drag an output port to a input port to wire. Drag a wired input to re-route. Drag bodies to move. Click toggles & clock rate. Del removes. Wheel zooms.</p>
<h2>Inputs</h2>
<button data-add="toggle">Toggle switch</button>
<button data-add="clock">Clock</button>
<h2>Gates</h2>
<button data-add="and">AND</button>
<button data-add="or">OR</button>
<button data-add="xor">XOR</button>
<button data-add="nand">NAND</button>
<button data-add="nor">NOR</button>
<button data-add="not">NOT</button>
<h2>Outputs</h2>
<button data-add="lamp">Lamp</button>
<button data-add="seg">Hex display</button>
<h2>Scene</h2>
<div class="row"><button id="btnDemo">Demo</button><button id="btnClear">Clear</button></div>
<button id="btnDel">Delete selected</button>
<button id="btnView">Reset view</button>
</aside>
<div id="wrap">
<canvas id="cv"></canvas>
<div id="hud"></div>
<div id="toast"></div>
</div>
</div>
<script>
'use strict';
/* ==========================================================================
1. TUNABLE CONSTANTS
========================================================================== */
const C = {
storageKey: 'logic-lab-v1',
zoomMin: 0.5, zoomMax: 2.0, wheelSensitivity: 0.0015,
gridSpacing: 32, gridDot: '#243241', bg: '#0b0f14',
nodeTop: '#2a3441', nodeBot: '#131921', nodeBorder: '#3a4757',
nodeOn: '#4fe3a6', select: '#5aa9ff', text: '#e6edf5',
headerH: 30, rowH: 24, minBodyH: 46,
shadowIdle: 8, shadowHover: 22,
portR: 5.5, portHoverR: 8.5, portHitPx: 12,
wireHitPx: 7, wireSamples: 40,
inPort: '#5ab4ff', outPort: '#ffb347',
wireOn: '#5cffb0', wireOff: '#3a4656', wireBad: '#ff4d5e', wirePreview: '#9fd8ff',
lampOn: '#ffd24a', lampOff: '#3b3121',
segOn: '#ff5a3c', segOff: '#2a1b19',
clockRates: [0.5, 1, 2, 4, 8], clockDefaultRate: 1,
flashMs: 700, toastMs: 2400, dragThresholdPx: 3,
};
/* Node type catalogue. kind: input | gate | output */
const TYPES = {
toggle: { name: 'Toggle', kind: 'input', ins: [], outs: ['Q'], w: 132, hdr: '#1d4658' },
clock: { name: 'Clock', kind: 'input', ins: [], outs: ['CLK'], w: 132, hdr: '#1d4658' },
and: { name: 'AND', kind: 'gate', ins: ['A','B'], outs: ['Y'], w: 124, hdr: '#2b3361', sym: '&', fn: a => a[0] && a[1] },
or: { name: 'OR', kind: 'gate', ins: ['A','B'], outs: ['Y'], w: 124, hdr: '#2b3361', sym: '≥1', fn: a => a[0] || a[1] },
xor: { name: 'XOR', kind: 'gate', ins: ['A','B'], outs: ['Y'], w: 124, hdr: '#2b3361', sym: '=1', fn: a => a[0] !== a[1] },
nand: { name: 'NAND', kind: 'gate', ins: ['A','B'], outs: ['Y'], w: 124, hdr: '#2b3361', sym: '&', fn: a => !(a[0] && a[1]), bubble: true },
nor: { name: 'NOR', kind: 'gate', ins: ['A','B'], outs: ['Y'], w: 124, hdr: '#2b3361', sym: '≥1', fn: a => !(a[0] || a[1]), bubble: true },
not: { name: 'NOT', kind: 'gate', ins: ['A'], outs: ['Y'], w: 112, hdr: '#2b3361', sym: '1', fn: a => !a[0], bubble: true },
lamp: { name: 'Lamp', kind: 'output', ins: ['IN'], outs: [], w: 112, hdr: '#4a3424' },
seg: { name: 'Hex Display', kind: 'output', ins: ['b0','b1','b2','b3'], outs: [], w: 176, hdr: '#4a3424' },
};
/* 7-segment encodings for 0..F: bit0=a … bit6=g */
const SEG_MAP = [0x3f,0x06,0x5b,0x4f,0x66,0x6d,0x7d,0x07,0x7f,0x6f,0x77,0x7c,0x39,0x5e,0x79,0x71];
/* ==========================================================================
2. SCENE MODEL — { nodes:[{id,type,x,y,state,rate?}], wires:[{from,fromPort,to,toPort}] }
========================================================================== */
const scene = { nodes: [], wires: [] };
let nextId = 1;
const cam = { x: 0, y: 0, z: 1 }; // screen = world * z + (x,y)
let sel = { nodeId: null, wire: null }; // current selection
let drag = null; // active pointer interaction
const hover = { node: null, port: null, wire: null };
const flashes = []; // red cycle-rejection flashes (world curves)
const clockAcc = new Map(); // runtime phase accumulators (not persisted)
const nodeById = id => scene.nodes.find(n => n.id === id);
const dist2 = (a, b) => (a.x - b.x) ** 2 + (a.y - b.y) ** 2;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
/* ==========================================================================
3. GEOMETRY (all local coords are relative to the node's top-left)
========================================================================== */
function bodyH(t) { return Math.max(C.minBodyH, Math.max(t.ins.length, 1) * C.rowH + 10); }
function nodeH(t) { return C.headerH + bodyH(t); }
function localIn(t, i) {
const cy = C.headerH + bodyH(t) / 2;
return { x: 0, y: cy + (i - (t.ins.length - 1) / 2) * C.rowH };
}
function localOut(t) { return { x: t.w, y: C.headerH + bodyH(t) / 2 }; }
function portWorld(n, kind, idx) {
const t = TYPES[n.type];
const p = kind === 'in' ? localIn(t, idx) : localOut(t);
return { x: n.x + p.x, y: n.y + p.y };
}
function wireCurve(a, b) {
const dx = Math.max(60, Math.abs(b.x - a.x) * 0.5);
return [a, { x: a.x + dx, y: a.y }, { x: b.x - dx, y: b.y }, b];
}
function bezAt(c, t) {
const u = 1 - t, a = u * u * u, b = 3 * u * u * t, cc = 3 * u * t * t, d = t * t * t;
return { x: a * c[0].x + b * c[1].x + cc * c[2].x + d * c[3].x,
y: a * c[0].y + b * c[1].y + cc * c[2].y + d * c[3].y };
}
function wireEnds(w) {
const a = nodeById(w.from), b = nodeById(w.to);
if (!a || !b) return null;
return { a: portWorld(a, 'out', w.fromPort), b: portWorld(b, 'in', w.toPort) };
}
/* ==========================================================================
4. CONNECTIVITY, CYCLE DETECTION, EVALUATION ENGINE
========================================================================== */
function inboundWire(nodeId, idx) { return scene.wires.find(w => w.to === nodeId && w.toPort === idx); }
function removeWire(w) { const i = scene.wires.indexOf(w); if (i >= 0) scene.wires.splice(i, 1); }
/* Would wiring from->to:toPort create a feedback loop? A loop exists iff `from`
is already reachable downstream of `to`. The wire currently occupying to:toPort
is ignored because it is about to be replaced. */
function wouldCycle(fromId, toId, toPort) {
if (fromId === toId) return true;
const wires = scene.wires.filter(w => !(w.to === toId && w.toPort === toPort));
const seen = new Set([toId]), q = [toId];
while (q.length) {
const id = q.shift();
if (id === fromId) return true;
for (const w of wires) if (w.from === id && !seen.has(w.to)) { seen.add(w.to); q.push(w.to); }
}
return false;
}
/* Re-evaluate the downstream closure of `seeds` in ONE topological pass (Kahn).
Each node is computed exactly once, after all of its upstream inputs are final,
so no glitches are possible. Input nodes (toggle/clock) keep their own state. */
function evaluate(seeds) {
if (!seeds || !seeds.length) return;
const byId = new Map(scene.nodes.map(n => [n.id, n]));
const inMap = new Map(), outAdj = new Map();
for (const w of scene.wires) {
inMap.set(w.to + ':' + w.toPort, w);
if (!outAdj.has(w.from)) outAdj.set(w.from, []);
outAdj.get(w.from).push(w);
}
// 1) downstream closure
const set = new Set(), stack = [];
for (const s of seeds) if (byId.has(s) && !set.has(s)) { set.add(s); stack.push(s); }
while (stack.length) {
const id = stack.pop();
for (const w of outAdj.get(id) || []) if (!set.has(w.to)) { set.add(w.to); stack.push(w.to); }
}
// 2) in-degree restricted to the closure
const indeg = new Map();
for (const id of set) indeg.set(id, 0);
for (const w of scene.wires) if (set.has(w.from) && set.has(w.to)) indeg.set(w.to, indeg.get(w.to) + 1);
// 3) Kahn topological pass
const q = [];
for (const id of set) if (indeg.get(id) === 0) q.push(id);
while (q.length) {
const id = q.shift();
const n = byId.get(id), t = TYPES[n.type];
if (t.kind !== 'input') {
const ins = t.ins.map((_, i) => {
const w = inMap.get(id + ':' + i), src = w && byId.get(w.from);
return !!(src && src.state);
});
if (t.fn) n.state = !!t.fn(ins);
else if (n.type === 'lamp') n.state = ins[0];
else if (n.type === 'seg') { n.val = ins.reduce((acc, b, i) => acc | (b ? 1 << i : 0), 0); n.state = n.val !== 0; }
}
for (const w of outAdj.get(id) || []) {
if (!set.has(w.to)) continue;
const d = indeg.get(w.to) - 1;
indeg.set(w.to, d);
if (d === 0) q.push(w.to);
}
}
}
/* Connect with validation. Replaces any occupant of the target input. */
function tryConnect(fromId, fromPort, toId, toPort) {
const a = nodeById(fromId), b = nodeById(toId);
if (!a || !b) return { ok: false, reason: 'missing' };
if (fromPort >= TYPES[a.type].outs.length || toPort >= TYPES[b.type].ins.length) return { ok: false, reason: 'port' };
if (wouldCycle(fromId, toId, toPort)) return { ok: false, reason: 'cycle' };
const old = inboundWire(toId, toPort);
if (old) removeWire(old);
scene.wires.push({ from: fromId, fromPort, to: toId, toPort });
evaluate([toId]);
return { ok: true };
}
function addNode(type, x, y) {
const n = { id: nextId++, type, x, y, state: false };
if (type === 'clock') n.rate = C.clockDefaultRate;
scene.nodes.push(n);
evaluate([n.id]);
save();
return n;
}
function removeNode(id) {
const targets = [];
scene.wires = scene.wires.filter(w => {
if (w.from === id) targets.push(w.to);
return w.from !== id && w.to !== id;
});
scene.nodes = scene.nodes.filter(n => n.id !== id);
clockAcc.delete(id);
evaluate(targets);
}
function onNodeClick(n) {
if (n.type === 'toggle') { n.state = !n.state; evaluate([n.id]); }
else if (n.type === 'clock') {
const i = C.clockRates.indexOf(n.rate);
n.rate = C.clockRates[(i + 1) % C.clockRates.length];
clockAcc.set(n.id, 0);
}
}
function deleteSelected() {
if (sel.nodeId != null) { removeNode(sel.nodeId); sel.nodeId = null; save(); }
else if (sel.wire && scene.wires.includes(sel.wire)) {
const w = sel.wire; removeWire(w); evaluate([w.to]); sel.wire = null; save();
}
}
/* ==========================================================================
5. PERSISTENCE (localStorage)
========================================================================== */
function serialize() {
return {
v: 1, nextId, camera: { x: cam.x, y: cam.y, z: cam.z },
nodes: scene.nodes.map(n => {
const o = { id: n.id, type: n.type, x: n.x, y: n.y, state: !!n.state };
if (n.type === 'clock') o.rate = n.rate;
return o;
}),
wires: scene.wires.map(w => ({ from: w.from, fromPort: w.fromPort, to: w.to, toPort: w.toPort })),
};
}
function save() { try { localStorage.setItem(C.storageKey, JSON.stringify(serialize())); } catch (e) {} }
function load() {
let data = null;
try { data = JSON.parse(localStorage.getItem(C.storageKey) || 'null'); } catch (e) { data = null; }
if (!data || !Array.isArray(data.nodes)) return false;
const ids = new Set();
scene.nodes = [];
for (const o of data.nodes) {
if (!TYPES[o.type] || !Number.isInteger(o.id) || ids.has(o.id)) continue;
if (typeof o.x !== 'number' || typeof o.y !== 'number') continue;
ids.add(o.id);
const n = { id: o.id, type: o.type, x: o.x, y: o.y, state: !!o.state };
if (o.type === 'clock') n.rate = C.clockRates.includes(o.rate) ? o.rate : C.clockDefaultRate;
scene.nodes.push(n);
}
scene.wires = [];
const used = new Set();
for (const o of Array.isArray(data.wires) ? data.wires : []) {
const a = nodeById(o.from), b = nodeById(o.to);
if (!a || !b) continue;
if (o.fromPort >= TYPES[a.type].outs.length || o.toPort >= TYPES[b.type].ins.length) continue;
const key = o.to + ':' + o.toPort;
if (used.has(key)) continue;
used.add(key);
scene.wires.push({ from: o.from, fromPort: o.fromPort, to: o.to, toPort: o.toPort });
}
nextId = Math.max(data.nextId || 1, ...scene.nodes.map(n => n.id + 1), 1);
if (data.camera && [data.camera.x, data.camera.y, data.camera.z].every(Number.isFinite)) {
cam.x = data.camera.x; cam.y = data.camera.y; cam.z = clamp(data.camera.z, C.zoomMin, C.zoomMax);
}
evaluate(scene.nodes.map(n => n.id)); // restore consistent derived state
return true;
}
let saveTimer = 0;
function saveSoon() { clearTimeout(saveTimer); saveTimer = setTimeout(save, 250); }
function buildDemo() {
scene.nodes = []; scene.wires = []; nextId = 1; clockAcc.clear();
const A = addNode('toggle', -330, -60), B = addNode('toggle', -330, 90);
const K = addNode('clock', -330, 230);
const AND = addNode('and', -150, -20), NOT = addNode('not', 40, -20);
const L1 = addNode('lamp', 220, -20), L2 = addNode('lamp', 220, 150);
const NOR = addNode('nor', -150, 160);
const connections = [[A,0,AND,0],[B,0,AND,1],[AND,0,NOT,0],[NOT,0,L1,0],[K,0,NOR,0],[NOR,0,L2,0]];
for (const [f,fp,t,tp] of connections) tryConnect(f.id, fp, t.id, tp);
save();
}
/* ==========================================================================
6. RENDERING (canvas 2D — world transform applied for graph layers)
========================================================================== */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let W = 0, H = 0, DPR = 1;
function resize() {
const r = cv.getBoundingClientRect();
DPR = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
}
window.addEventListener('resize', resize);
function s2w(p) { return { x: (p.x - cam.x) / cam.z, y: (p.y - cam.y) / cam.z }; }
function w2s(p) { return { x: p.x * cam.z + cam.x, y: p.y * cam.z + cam.y }; }
function strokeCurve(c) {
ctx.beginPath(); ctx.moveTo(c[0].x, c[0].y);
ctx.bezierCurveTo(c[1].x, c[1].y, c[2].x, c[2].y, c[3].x, c[3].y); ctx.stroke();
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();
}
function drawGrid() {
const s = C.gridSpacing * cam.z;
if (s < 6) return;
ctx.fillStyle = C.gridDot;
const ox = ((cam.x % s) + s) % s, oy = ((cam.y % s) + s) % s;
for (let x = ox; x < W; x += s) for (let y = oy; y < H; y += s) ctx.fillRect(x - 1, y - 1, 2, 2);
}
function drawWires() {
for (const w of scene.wires) {
const e = wireEnds(w); if (!e) continue;
const c = wireCurve(e.a, e.b);
const on = !!nodeById(w.from).state;
const selc = sel.wire === w;
ctx.save(); ctx.lineCap = 'round';
if (on) {
ctx.strokeStyle = 'rgba(92,255,176,.28)'; ctx.lineWidth = 7;
ctx.shadowColor = C.wireOn; ctx.shadowBlur = 16; strokeCurve(c);
ctx.shadowBlur = 0; ctx.strokeStyle = C.wireOn; ctx.lineWidth = 2.2; strokeCurve(c);
} else {
ctx.strokeStyle = C.wireOff; ctx.lineWidth = 2; strokeCurve(c);
}
if (selc) { ctx.strokeStyle = C.select; ctx.lineWidth = 1; ctx.setLineDash([5, 4]); strokeCurve(c); }
ctx.restore();
}
}
function drawPreview() {
if (!drag || drag.kind !== 'wire') return;
const src = nodeById(drag.fromId); if (!src) return;
const a = portWorld(src, 'out', drag.fromPort);
const b = drag.target ? portWorld(drag.target.node, 'in', drag.target.idx) : drag.pos;
const c = wireCurve(a, b);
ctx.save(); ctx.lineCap = 'round';
const col = drag.bad ? C.wireBad : C.wirePreview;
ctx.strokeStyle = col; ctx.lineWidth = 2.4; ctx.setLineDash([9, 6]);
ctx.shadowColor = col; ctx.shadowBlur = drag.bad ? 14 : 6;
strokeCurve(c);
ctx.restore();
}
function drawFlashes(now) {
for (let i = flashes.length - 1; i >= 0; i--) {
const f = flashes[i];
const k = 1 - (now - f.t0) / C.flashMs;
if (k <= 0) { flashes.splice(i, 1); continue; }
ctx.save();
ctx.globalAlpha = k; ctx.strokeStyle = C.wireBad; ctx.lineWidth = 3.5;
ctx.shadowColor = C.wireBad; ctx.shadowBlur = 14; ctx.lineCap = 'round';
strokeCurve(f.c);
ctx.restore();
}
}
function drawSevenSeg(x, y, dw, dh, val, on) {
const t = dh * 0.1, hw = dw - 2 * t, vh = dh / 2 - t / 2 - t;
const segs = [
[x + t, y, hw, t], // a
[x + dw - t, y + t, t, vh], // b
[x + dw - t, y + dh / 2 + t / 2, t, vh], // c
[x + t, y + dh - t, hw, t], // d
[x, y + dh / 2 + t / 2, t, vh], // e
[x, y + t, t, vh], // f
[x + t, y + dh / 2 - t / 2, hw, t], // g
];
const mask = SEG_MAP[val & 15];
segs.forEach((r, i) => {
const lit = (mask >> i) & 1;
ctx.fillStyle = lit ? (on ? C.segOn : '#5a2a22') : C.segOff;
if (lit) { ctx.shadowColor = C.segOn; ctx.shadowBlur = 8; }
ctx.beginPath(); ctx.roundRect ? ctx.roundRect(r[0], r[1], r[2], r[3], 1.5) : ctx.rect(r[0], r[1], r[2], r[3]);
ctx.fill();
ctx.shadowBlur = 0;
});
}
function drawPort(x, y, kind, lit, hot, target, bad) {
const r = (hot ? C.portHoverR : C.portR) + (target ? 1.5 : 0);
ctx.save();
const base = kind === 'in' ? C.inPort : C.outPort;
if (hot || target) { ctx.shadowColor = bad ? C.wireBad : base; ctx.shadowBlur = 10; }
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = '#0b0f14'; ctx.fill();
ctx.lineWidth = target ? 2.5 : 2;
ctx.strokeStyle = target ? (bad ? C.wireBad : '#ffffff') : base;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.beginPath(); ctx.arc(x, y, r * 0.45, 0, Math.PI * 2);
ctx.fillStyle = lit ? '#ffffff' : base; ctx.globalAlpha = lit ? 1 : 0.55; ctx.fill();
ctx.restore();
}
function headerColor(t) { return t.hdr; }
function drawNode(n) {
const t = TYPES[n.type];
const w = t.w, h = nodeH(t);
const hov = hover.node === n;
const selc = sel.nodeId === n.id;
const lift = hov ? -2 : 0;
const cy0 = C.headerH, bh = bodyH(t), cy = cy0 + bh / 2;
ctx.save();
ctx.translate(n.x, n.y + lift);
// body with depth shadow
ctx.shadowColor = 'rgba(0,0,0,.6)';
ctx.shadowBlur = hov ? C.shadowHover : C.shadowIdle;
ctx.shadowOffsetY = hov ? 8 : 4;
const g = ctx.createLinearGradient(0, 0, 0, h);
if (n.state) { g.addColorStop(0, '#22433c'); g.addColorStop(1, '#10231e'); }
else { g.addColorStop(0, C.nodeTop); g.addColorStop(1, C.nodeBot); }
roundRect(0, 0, w, h, 10); ctx.fillStyle = g; ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
// header band
ctx.save(); roundRect(0, 0, w, h, 10); ctx.clip();
ctx.fillStyle = headerColor(t); ctx.fillRect(0, 0, w, cy0);
ctx.fillStyle = 'rgba(255,255,255,.04)'; ctx.fillRect(0, cy0 - 1, w, 1);
ctx.restore();
// state LED in header
ctx.beginPath(); ctx.arc(w - 13, cy0 / 2, 4, 0, Math.PI * 2);
ctx.fillStyle = n.state ? C.nodeOn : '#2b3543';
if (n.state) { ctx.shadowColor = C.nodeOn; ctx.shadowBlur = 8; }
ctx.fill(); ctx.shadowBlur = 0;
// border (selection / state tint)
roundRect(0.5, 0.5, w - 1, h - 1, 10);
ctx.lineWidth = selc ? 2 : 1;
ctx.strokeStyle = selc ? C.select : (n.state ? C.nodeOn : C.nodeBorder);
if (n.state && !selc) { ctx.shadowColor = C.nodeOn; ctx.shadowBlur = 10; }
ctx.stroke(); ctx.shadowBlur = 0;
// title
ctx.fillStyle = C.text; ctx.font = '600 11px system-ui,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(t.name.toUpperCase(), 12, cy0 / 2 + 0.5);
// body content
if (n.type === 'toggle') {
const px = 16, py = cy - 11;
roundRect(px, py, 58, 22, 11);
ctx.fillStyle = n.state ? '#1d7656' : '#262f3b'; ctx.fill();
ctx.beginPath(); ctx.arc(n.state ? px + 47 : px + 11, cy, 8.5, 0, Math.PI * 2);
ctx.fillStyle = '#eef6ff'; ctx.fill();
ctx.fillStyle = n.state ? C.nodeOn : C.dim || '#6b7a8a';
ctx.font = '700 11px system-ui,sans-serif'; ctx.textAlign = 'left';
ctx.fillText(n.state ? 'ON' : 'OFF', px + 68, cy + 0.5);
} else if (n.type === 'clock') {
ctx.beginPath(); ctx.arc(20, cy, 6, 0, Math.PI * 2);
ctx.fillStyle = n.state ? C.nodeOn : '#2b3543';
if (n.state) { ctx.shadowColor = C.nodeOn; ctx.shadowBlur = 10; }
ctx.fill(); ctx.shadowBlur = 0;
ctx.fillStyle = C.text; ctx.font = '600 13px ui-monospace,Menlo,monospace'; ctx.textAlign = 'left';
ctx.fillText(n.rate + ' Hz', 34, cy + 0.5);
ctx.fillStyle = '#5f6e7e'; ctx.font = '10px system-ui,sans-serif';
ctx.fillText('click: rate', 34, cy + 16);
} else if (t.kind === 'gate') {
ctx.fillStyle = '#dfe8f2'; ctx.font = '700 17px system-ui,sans-serif';
ctx.textAlign = 'center'; ctx.fillText(t.sym, w / 2 - 6, cy + 0.5);
t.ins.forEach((lbl, i) => {
const p = localIn(t, i);
ctx.fillStyle = '#6b7a8a'; ctx.font = '600 9px system-ui,sans-serif';
ctx.textAlign = 'left'; ctx.fillText(lbl, 12, p.y + 0.5);
});
if (t.bubble) {
ctx.beginPath(); ctx.arc(w - 11, cy, 3.2, 0, Math.PI * 2);
ctx.strokeStyle = '#dfe8f2'; ctx.lineWidth = 1.4; ctx.stroke();
}
} else if (n.type === 'lamp') {
const r = 17;
if (n.state) {
const rg = ctx.createRadialGradient(w / 2, cy, 2, w / 2, cy, r * 2.4);
rg.addColorStop(0, 'rgba(255,210,74,.75)'); rg.addColorStop(1, 'rgba(255,210,74,0)');
ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(w / 2, cy, r * 2.4, 0, Math.PI * 2); ctx.fill();
}
ctx.beginPath(); ctx.arc(w / 2, cy, r, 0, Math.PI * 2);
ctx.fillStyle = n.state ? C.lampOn : C.lampOff; ctx.fill();
ctx.lineWidth = 1.5; ctx.strokeStyle = n.state ? '#fff3c4' : '#4a3d26'; ctx.stroke();
t.ins.forEach((lbl, i) => {
const p = localIn(t, i);
ctx.fillStyle = '#6b7a8a'; ctx.font = '600 9px system-ui,sans-serif';
ctx.textAlign = 'left'; ctx.fillText(lbl, 12, p.y + 0.5);
});
} else if (n.type === 'seg') {
const dx = 46, dy = cy0 + 8, dw = w - dx - 12, dh = bh - 16;
roundRect(dx - 6, dy - 4, dw + 12, dh + 8, 6); ctx.fillStyle = '#07090c'; ctx.fill();
drawSevenSeg(dx, dy, dw, dh, n.val || 0, !!n.state);
t.ins.forEach((lbl, i) => {
const p = localIn(t, i);
ctx.fillStyle = '#6b7a8a'; ctx.font = '600 9px system-ui,sans-serif';
ctx.textAlign = 'left'; ctx.fillText(lbl, 12, p.y + 0.5);
});
}
// ports
t.ins.forEach((_, i) => {
const p = localIn(t, i);
const src = inboundWire(n.id, i);
const lit = !!(src && nodeById(src.from) && nodeById(src.from).state);
const hot = hover.port && hover.port.node === n && hover.port.kind === 'in' && hover.port.idx === i;
const tgt = drag && drag.kind === 'wire' && drag.target && drag.target.node === n && drag.target.idx === i;
drawPort(p.x, p.y, 'in', lit, hot, tgt, drag && drag.bad);
});
t.outs.forEach((_, j) => {
const p = localOut(t);
const hot = hover.port && hover.port.node === n && hover.port.kind === 'out' && hover.port.idx === j;
drawPort(p.x, p.y, 'out', !!n.state, hot, false, false);
});
ctx.restore();
}
function render(now) {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.fillStyle = C.bg; ctx.fillRect(0, 0, W, H);
drawGrid();
ctx.save();
ctx.translate(cam.x, cam.y); ctx.scale(cam.z, cam.z);
drawWires();
drawPreview();
for (const n of scene.nodes) drawNode(n);
drawFlashes(now);
ctx.restore();
}
/* ==========================================================================
7. HIT TESTING (screen tolerances converted to world units via zoom)
========================================================================== */
function hitPort(w, kindFilter) {
const tol2 = (C.portHitPx / cam.z) ** 2;
let best = null, bd = tol2;
for (const n of scene.nodes) {
const t = TYPES[n.type];
if (!kindFilter || kindFilter === 'in') {
for (let i = 0; i < t.ins.length; i++) {
const p = portWorld(n, 'in', i), d = dist2(p, w);
if (d <= bd) { bd = d; best = { node: n, kind: 'in', idx: i, x: p.x, y: p.y }; }
}
}
if (!kindFilter || kindFilter === 'out') {
for (let j = 0; j < t.outs.length; j++) {
const p = portWorld(n, 'out', j), d = dist2(p, w);
if (d <= bd) { bd = d; best = { node: n, kind: 'out', idx: j, x: p.x, y: p.y }; }
}
}
}
return best;
}
function hitNode(w) {
for (let i = scene.nodes.length - 1; i >= 0; i--) {
const n = scene.nodes[i], t = TYPES[n.type];
if (w.x >= n.x && w.x <= n.x + t.w && w.y >= n.y && w.y <= n.y + nodeH(t)) return n;
}
return null;
}
function hitWire(p) {
let best = null, bd = C.wireHitPx;
for (const w of scene.wires) {
const e = wireEnds(w); if (!e) continue;
const c = wireCurve(e.a, e.b);
for (let k = 0; k <= C.wireSamples; k++) {
const s = w2s(bezAt(c, k / C.wireSamples));
const d = Math.hypot(s.x - p.x, s.y - p.y);
if (d < bd) { bd = d; best = w; }
}
}
return best;
}
/* ==========================================================================
8. POINTER / KEYBOARD INTERACTION
========================================================================== */
const pos = e => { const r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('on');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => el.classList.remove('on'), C.toastMs);
}
function flashCurve(fromId, fromPort, toNode, toIdx) {
const src = nodeById(fromId);
if (!src) return;
flashes.push({ c: wireCurve(portWorld(src, 'out', fromPort), portWorld(toNode, 'in', toIdx)), t0: performance.now() });
}
function restoreDragged() {
if (drag && drag.restore) { scene.wires.push(drag.restore); evaluate([drag.restore.to]); }
}
function onDown(e) {
if (e.button !== 0) return;
cv.setPointerCapture(e.pointerId);
const p = pos(e), w = s2w(p);
const port = hitPort(w);
if (port) {
if (port.kind === 'out') {
drag = { kind: 'wire', fromId: port.node.id, fromPort: port.idx, restore: null, pos: w, target: null, bad: false };
} else {
const wire = inboundWire(port.node.id, port.idx);
if (!wire) return; // empty input: nothing to pick up
removeWire(wire); // pick up existing wire and re-route it
evaluate([wire.to]);
drag = { kind: 'wire', fromId: wire.from, fromPort: wire.fromPort, restore: wire, pos: w, target: null, bad: false };
}
sel = { nodeId: null, wire: null };
return;
}
const node = hitNode(w);
if (node) {
sel = { nodeId: node.id, wire: null };
scene.nodes.splice(scene.nodes.indexOf(node), 1); scene.nodes.push(node); // bring to front
drag = { kind: 'node', node, ox: w.x - node.x, oy: w.y - node.y, sx: p.x, sy: p.y, moved: false };
return;
}
const wire = hitWire(p);
if (wire) { sel = { nodeId: null, wire }; drag = null; return; }
sel = { nodeId: null, wire: null };
drag = { kind: 'pan', sx: p.x, sy: p.y, cx: cam.x, cy: cam.y };
}
function onMove(e) {
const p = pos(e), w = s2w(p);
if (!drag) {
hover.port = hitPort(w);
hover.node = hover.port ? hover.port.node : hitNode(w);
hover.wire = (!hover.port && !hover.node) ? hitWire(p) : null;
} else if (drag.kind === 'pan') {
cam.x = drag.cx + (p.x - drag.sx); cam.y = drag.cy + (p.y - drag.sy);
saveSoon();
} else if (drag.kind === 'node') {
if (!drag.moved && Math.hypot(p.x - drag.sx, p.y - drag.sy) > C.dragThresholdPx) drag.moved = true;
if (drag.moved) { drag.node.x = w.x - drag.ox; drag.node.y = w.y - drag.oy; }
} else if (drag.kind === 'wire') {
drag.pos = w;
drag.target = hitPort(w, 'in');
drag.bad = !!drag.target && wouldCycle(drag.fromId, drag.target.node.id, drag.target.idx);
}
}
function onUp(e) {
if (!drag) return;
const p = pos(e), w = s2w(p);
const d = drag; drag = null;
if (d.kind === 'node') {
if (!d.moved) onNodeClick(d.node);
save();
} else if (d.kind === 'wire') {
const t = hitPort(w, 'in');
if (t) {
const r = tryConnect(d.fromId, d.fromPort, t.node.id, t.idx);
if (!r.ok) {
if (r.reason === 'cycle') {
flashCurve(d.fromId, d.fromPort, t.node, t.idx);
showToast('Connection refused: would create a feedback loop');
}
restoreDragged();
}
} else {
restoreDragged();
}
save();
}
}
function cancelDrag() { if (drag && drag.kind === 'wire') restoreDragged(); drag = null; }
cv.addEventListener('pointerdown', onDown);
cv.addEventListener('pointermove', onMove);
cv.addEventListener('pointerup', onUp);
cv.addEventListener('pointercancel', cancelDrag);
cv.addEventListener('wheel', e => {
e.preventDefault();
const p = pos(e), before = s2w(p);
const f = clamp(Math.exp(-e.deltaY * C.wheelSensitivity), 0.8, 1.25);
cam.z = clamp(cam.z * f, C.zoomMin, C.zoomMax);
cam.x = p.x - before.x * cam.z; // keep world point under cursor fixed
cam.y = p.y - before.y * cam.z;
saveSoon();
}, { passive: false });
cv.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('keydown', e => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
else if (e.key === 'Escape') cancelDrag();
});
/* Palette */
function addAtViewCenter(type) {
const c = s2w({ x: W / 2, y: H / 2 });
const t = TYPES[type];
const jitter = (scene.nodes.length % 6) * 18;
addNode(type, Math.round(c.x - t.w / 2 + jitter), Math.round(c.y - nodeH(t) / 2 + jitter));
}
document.querySelectorAll('[data-add]').forEach(b => b.addEventListener('click', () => addAtViewCenter(b.dataset.add)));
document.getElementById('btnDemo').onclick = () => { buildDemo(); showToast('Demo circuit loaded'); };
document.getElementById('btnClear').onclick = () => { scene.nodes = []; scene.wires = []; clockAcc.clear(); sel = { nodeId: null, wire: null }; save(); };
document.getElementById('btnDel').onclick = () => deleteSelected();
document.getElementById('btnView').onclick = () => { cam.x = W / 2; cam.y = H / 2; cam.z = 1; saveSoon(); };
/* ==========================================================================
9. MAIN LOOP (clock ticks → downstream re-evaluation; render; HUD)
========================================================================== */
let lastT = performance.now(), hudT = 0;
function frame(now) {
const dt = Math.min(0.1, (now - lastT) / 1000); lastT = now;
for (const n of scene.nodes) {
if (n.type !== 'clock') continue;
const half = 0.5 / n.rate; // one half-period per toggle
let acc = (clockAcc.get(n.id) || 0) + dt, pulsed = false;
while (acc >= half) { acc -= half; n.state = !n.state; pulsed = true; }
clockAcc.set(n.id, acc);
if (pulsed) evaluate([n.id]); // re-trigger downstream on each edge
}
if (drag && drag.kind === 'pan') cv.style.cursor = 'grabbing';
else if (drag && drag.kind === 'node') cv.style.cursor = 'grabbing';
else if (drag && drag.kind === 'wire') cv.style.cursor = 'crosshair';
else if (hover.port) cv.style.cursor = 'crosshair';
else if (hover.node) cv.style.cursor = 'grab';
else cv.style.cursor = 'default';
render(now);
if (now - hudT > 250) {
hudT = now;
const clk = scene.nodes.filter(n => n.type === 'clock').length;
document.getElementById('hud').textContent =
`${scene.nodes.length} nodes · ${scene.wires.length} wires · ${clk} clock · zoom ${Math.round(cam.z * 100)}%`;
}
requestAnimationFrame(frame);
}
/* ==========================================================================
10. INIT + TEST HOOKS
========================================================================== */
resize();
cam.x = W / 2; cam.y = H / 2;
if (!load()) buildDemo();
window.addEventListener('beforeunload', save);
requestAnimationFrame(frame);
window.LogicLab = {
scene, cam, TYPES, C,
addNode, tryConnect, evaluate, wouldCycle, removeNode, save, load, serialize,
s2w, w2s, nodeById, hitPort, hitWire,
portScreen(id, kind, idx) { const n = nodeById(id); return w2s(portWorld(n, kind, idx)); },
nodeScreen(id) { const n = nodeById(id); return w2s({ x: n.x, y: n.y }); },
toggleNode(id) { const n = nodeById(id); onNodeClick(n); evaluate([id]); save(); },
get drag() { return drag; },
get sel() { return sel; },
};
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"406de584-b85f-4b05-adaf-680fef343882","tokensIn":96606,"tokensOut":117282,"tokensTotal":213888,"cost":0.062988515,"turns":5,"toolCalls":5,"failedToolCalls":1,"timestamp":"2026-10-08T03:52:24.537Z"} -->