Node Graph Editor
Deepseek v4.1 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 50,143 bytes · SHA-256 ce43fa4e7326
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LOGICLAB · Node Graph Editor</title>
<style>
/* ============================================================
STYLES — dark instrument-panel aesthetic
============================================================ */
:root{
--bg:#0a0d13;
--panel:#0f131b;
--panel2:#151b26;
--panel3:#1b2230;
--line:#242e3e;
--txt:#c9d5e8;
--dim:#6c7d95;
--cool:#3fa9f5;
--warm:#ffa53d;
--live:#ffc14d;
--ok:#4dd6c1;
--bad:#ff5b6e;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;overflow:hidden}
body{
background:var(--bg);color:var(--txt);
font-family:var(--mono);font-size:12px;
-webkit-font-smoothing:antialiased;user-select:none;
}
#app{display:flex;height:100%;width:100%}
/* ---------- sidebar ---------- */
#side{
width:196px;flex:0 0 196px;height:100%;
background:linear-gradient(180deg,#101620,#0b0f16);
border-right:1px solid var(--line);
display:flex;flex-direction:column;
padding:14px 12px 12px;gap:6px;overflow-y:auto;
box-shadow:2px 0 18px rgba(0,0,0,.5);z-index:5;
}
#side::-webkit-scrollbar{width:8px}
#side::-webkit-scrollbar-thumb{background:#222c3c;border-radius:4px}
.brand{
font-size:15px;letter-spacing:.22em;font-weight:700;color:#e8eefc;
margin-bottom:2px;
}
.brand span{color:var(--warm)}
.tagline{font-size:9.5px;letter-spacing:.14em;color:var(--dim);margin-bottom:10px}
.sec{
font-size:9px;letter-spacing:.2em;color:var(--dim);
margin:12px 0 5px;padding-bottom:4px;border-bottom:1px solid #1b2331;
}
.sec:first-of-type{margin-top:2px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:6px}
button.pal{
display:flex;align-items:center;gap:7px;
background:linear-gradient(180deg,#1a2231,#141a25);
border:1px solid #28323f;border-radius:8px;
color:#c2cfe3;font-family:inherit;font-size:10.5px;letter-spacing:.08em;
padding:8px 7px;cursor:pointer;transition:.13s;text-align:left;
box-shadow:0 1px 0 rgba(255,255,255,.03) inset;
}
button.pal:hover{
background:linear-gradient(180deg,#233046,#1a2432);
border-color:#3d577a;color:#fff;transform:translateY(-1px);
box-shadow:0 4px 12px rgba(0,0,0,.45);
}
button.pal:active{transform:translateY(0)}
.dot{width:9px;height:9px;border-radius:50%;flex:0 0 9px;box-shadow:0 0 7px currentColor}
.d-cool{background:var(--cool);color:var(--cool)}
.d-warm{background:var(--warm);color:var(--warm)}
.d-grn{background:var(--ok);color:var(--ok)}
.d-red{background:var(--bad);color:var(--bad)}
.wide{width:100%;justify-content:center}
.hint{
margin-top:auto;padding-top:12px;font-size:9.5px;line-height:1.7;color:#596a80;
border-top:1px solid #1b2331;
}
.hint b{color:#8697af;font-weight:600}
/* ---------- stage ---------- */
#stage{position:relative;flex:1;min-width:0;height:100%;overflow:hidden}
canvas{display:block;width:100%;height:100%;cursor:crosshair}
/* vignette / depth veil — a CSS layer costs the canvas nothing per frame */
#veil{
position:absolute;inset:0;pointer-events:none;
background:
radial-gradient(ellipse 70% 70% at 50% 42%, rgba(34,48,70,.42), rgba(0,0,0,0) 72%),
radial-gradient(ellipse 120% 100% at 50% 50%, rgba(0,0,0,0) 55%, rgba(0,0,0,.45) 100%);
}
#hud{
position:absolute;left:12px;bottom:12px;display:flex;gap:8px;
align-items:center;pointer-events:none;
}
.pill{
background:rgba(13,18,26,.86);border:1px solid #26303f;border-radius:7px;
padding:5px 9px;font-size:10.5px;color:#8fa2bd;letter-spacing:.08em;
backdrop-filter:blur(6px);
}
.pill b{color:#dfe8f6;font-weight:600}
#toast{
position:absolute;left:50%;top:18px;transform:translate(-50%,-14px);
background:rgba(24,10,14,.94);border:1px solid rgba(255,91,110,.55);
color:#ffc0c8;padding:8px 16px;border-radius:9px;font-size:11px;
letter-spacing:.09em;opacity:0;transition:.22s;pointer-events:none;
box-shadow:0 8px 26px rgba(0,0,0,.6),0 0 20px rgba(255,91,110,.16);
}
#toast.show{opacity:1;transform:translate(-50%,0)}
#toast.info{border-color:rgba(77,214,193,.5);color:#bff3e9;background:rgba(9,24,22,.94)}
</style>
</head>
<body>
<div id="app">
<aside id="side">
<div class="brand">LOGIC<span>LAB</span></div>
<div class="tagline">NODE GRAPH / COMBINATIONAL</div>
<div class="sec">SOURCES</div>
<div class="grid2" id="pal-src"></div>
<div class="sec">GATES</div>
<div class="grid2" id="pal-gate"></div>
<div class="sec">OUTPUTS</div>
<div class="grid2" id="pal-out"></div>
<div class="sec">VIEW</div>
<div class="grid2">
<button class="pal wide" id="btn-fit">FIT</button>
<button class="pal wide" id="btn-clear">CLEAR</button>
</div>
<div class="hint">
<b>DRAG</b> empty space to pan<br>
<b>WHEEL</b> to zoom @ cursor<br>
<b>DRAG PORT</b> to wire up<br>
<b>DRAG A WIRE END</b> to re-route<br>
<b>CLICK</b> toggle / clock rate<br>
<b>DEL</b> removes selection
</div>
</aside>
<main id="stage">
<canvas id="cv"></canvas>
<div id="veil"></div>
<div id="hud">
<div class="pill">ZOOM <b id="zoomTxt">100%</b></div>
<div class="pill">NODES <b id="nodeTxt">0</b></div>
<div class="pill">WIRES <b id="wireTxt">0</b></div>
</div>
<div id="toast"></div>
</main>
</div>
<script>
"use strict";
/* ============================================================================
1. TUNABLE CONSTANTS
============================================================================ */
const CFG = {
/* geometry (world units) */
NODE_W: 124,
HEAD_H: 28,
PORT_ROW: 24,
PAD_BOTTOM: 10,
PORT_R: 6,
MIN_NODE_H: 62,
/* hit tolerances (screen px — converted to world by /zoom) */
PORT_HIT_PX: 15,
WIRE_HIT_PX: 9,
DRAG_SLOP: 4,
/* viewport */
ZOOM_MIN: 0.5,
ZOOM_MAX: 2.0,
ZOOM_STEP: 1.0016,
/* grid */
GRID: 40,
GRID_MIN_PX: 19,
/* clock */
CLOCK_RATES: [1, 2, 5, 10],
/* palette cycle options */
STORAGE_KEY: "logiclab.scene.v1",
SAVE_DEBOUNCE: 250,
colors: {
bg: "#0a0d13",
grid: "rgba(120,155,200,0.16)",
gridFar: "rgba(120,155,200,0.07)",
nodeTop: "#1d2534",
nodeBot: "#131a26",
nodeTopOn: "#2a2c2a",
nodeBotOn: "#1a1c1a",
nodeStroke: "#2b3648",
nodeStrokeOn: "#5a6b48",
nodeStrokeHi: "#4a6183",
sel: "#4dd6c1",
head: "#0e131c",
title: "#cbd8ea",
sub: "#7c8ea7",
cool: "#3fa9f5",
coolDim: "#1d4f76",
warm: "#ffa53d",
warmDim: "#7a4b17",
wireOff: "#2f3c4e",
wireOn: "#ffc14d",
wireGhost: "#9fb4cf",
ok: "#4dd6c1",
bad: "#ff5b6e",
lampOff: "#2a3444",
lampOn: "#ffe08a",
segOff: "#212a38",
segOn: "#ff7a5c"
}
};
/* Node type definitions: port counts + palette meta */
const DEF = {
toggle: { in:0, out:1, label:"TOGGLE", group:"src", port:["", ], hint:"click to flip" },
clock: { in:0, out:1, label:"CLOCK", group:"src", hint:"pulse" },
and: { in:2, out:1, label:"AND", group:"gate" },
or: { in:2, out:1, label:"OR", group:"gate" },
not: { in:1, out:1, label:"NOT", group:"gate" },
xor: { in:2, out:1, label:"XOR", group:"gate" },
nand: { in:2, out:1, label:"NAND", group:"gate" },
nor: { in:2, out:1, label:"NOR", group:"gate" },
lamp: { in:1, out:0, label:"LAMP", group:"out" },
hex7: { in:4, out:0, label:"HEX", group:"out", port:["1","2","4","8"] }
};
/* Combinational functions. Unconnected inputs read as 0. */
const LOGIC = {
and: v => (v[0] && v[1]) ? 1 : 0,
or: v => (v[0] || v[1]) ? 1 : 0,
not: v => v[0] ? 0 : 1,
xor: v => (v[0] ^ v[1]) ? 1 : 0,
nand: v => (v[0] && v[1]) ? 0 : 1,
nor: v => (v[0] || v[1]) ? 0 : 1
};
/* 7-segment decode for hex digits 0..15 */
const SEG7 = [
"abcdef","bc","abdeg","abcdg","bcfg","acdfg","acdefg","abc",
"abcdefg","abcdfg","abcefg","cdefg","adef","bcdeg","adefg","aefg"
];
/* ============================================================================
2. STORAGE (localStorage with graceful in-memory fallback)
============================================================================ */
const Store = (function(){
let ok = true, mem = {};
try { localStorage.setItem("__ll_t","1"); localStorage.removeItem("__ll_t"); }
catch(e){ ok = false; }
return {
get(k){ try { return ok ? localStorage.getItem(k) : (k in mem ? mem[k] : null); } catch(e){ return null; } },
set(k,v){ try { if(ok) localStorage.setItem(k,v); else mem[k]=v; } catch(e){ mem[k]=v; } },
available(){ return ok; }
};
})();
/* ============================================================================
3. SCENE DATA MODEL (the single source of truth)
scene = { nodes:[{id,type,x,y,state,...}], wires:[{id,from,fromPort,to,toPort}], nextId }
============================================================================ */
const scene = { nodes: [], wires: [], nextId: 1, cam: { x: 0, y: 0, zoom: 1 } };
let idCounter = 1;
function uid(prefix){ return prefix + (idCounter++) + "_" + Math.random().toString(36).slice(2,6); }
const nodeById = new Map();
function getNode(id){ return nodeById.get(id); }
function indexNodes(){
nodeById.clear();
for (const n of scene.nodes) nodeById.set(n.id, n);
}
function nodeHeight(type){
const d = DEF[type];
const rows = Math.max(d.in, d.out, 1);
return Math.max(CFG.MIN_NODE_H, CFG.HEAD_H + rows * CFG.PORT_ROW + CFG.PAD_BOTTOM);
}
function nodeWidth(){ return CFG.NODE_W; }
function makeNode(type, x, y){
const n = {
id: uid("n"),
type,
x: Math.round(x),
y: Math.round(y),
state: 0
};
if (type === "clock"){ n.rate = 2; n.acc = 0; }
if (type === "toggle") n.state = 0;
return n;
}
function addNode(type, x, y){
const n = makeNode(type, x, y);
scene.nodes.push(n);
indexNodes();
markStructDirty();
if (type === "clock") evaluateAll();
saveSoon();
return n;
}
function removeNode(id){
const i = scene.nodes.findIndex(n => n.id === id);
if (i < 0) return false;
scene.nodes.splice(i,1);
scene.wires = scene.wires.filter(w => w.from !== id && w.to !== id);
indexNodes();
markStructDirty();
evaluateAll();
saveSoon();
return true;
}
function wiresInto(id, port){
return scene.wires.filter(w => w.to === id && w.toPort === port);
}
/* ---------- connection legality ---------------------------------------- */
/** Is there a directed path from `a` to `b` following wires? */
function reaches(a, b){
if (a === b) return true;
const seen = new Set([a]);
const stack = [a];
while (stack.length){
const cur = stack.pop();
for (const w of scene.wires){
if (w.from !== cur) continue;
if (w.to === b) return true;
if (!seen.has(w.to)){ seen.add(w.to); stack.push(w.to); }
}
}
return false;
}
/**
* Validate a prospective wire.
* @returns {{ok:boolean, reason?:string, cycle?:boolean}}
*/
function canConnect(fromId, fromPort, toId, toPort){
const a = getNode(fromId), b = getNode(toId);
if (!a || !b) return { ok:false, reason:"missing node" };
if (fromId === toId) return { ok:false, reason:"A node cannot wire into itself", cycle:true };
if (fromPort < 0 || fromPort >= DEF[a.type].out) return { ok:false, reason:"bad output port" };
if (toPort < 0 || toPort >= DEF[b.type].in) return { ok:false, reason:"bad input port" };
if (reaches(toId, fromId))
return { ok:false, reason:"Feedback loop rejected — combinational only", cycle:true };
return { ok:true };
}
/** Add wire (replacing whatever occupied the target input). */
function addWire(fromId, fromPort, toId, toPort){
const v = canConnect(fromId, fromPort, toId, toPort);
if (!v.ok) return v;
scene.wires = scene.wires.filter(w => !(w.to === toId && w.toPort === toPort));
const w = { id: uid("w"), from: fromId, fromPort, to: toId, toPort };
scene.wires.push(w);
markStructDirty();
evaluateAll();
saveSoon();
return { ok:true, wire:w };
}
function removeWire(id){
const before = scene.wires.length;
scene.wires = scene.wires.filter(w => w.id !== id);
if (scene.wires.length === before) return false;
markStructDirty();
evaluateAll();
saveSoon();
return true;
}
/* ============================================================================
4. EVALUATION ENGINE (topological, single pass per change)
============================================================================ */
let topoOrder = []; // node ids in topological order
let topoDirty = true;
let outAdj = new Map(); // nodeId -> [child node ids]
let portSrc = new Map(); // "nodeId|port" -> incoming wire (O(1) input lookup)
function markStructDirty(){ topoDirty = true; }
function buildTopo(){
const indeg = new Map();
outAdj = new Map();
for (const n of scene.nodes){ indeg.set(n.id, 0); outAdj.set(n.id, []); }
portSrc.clear();
for (const w of scene.wires){
if (!indeg.has(w.from) || !indeg.has(w.to)) continue;
outAdj.get(w.from).push(w.to);
indeg.set(w.to, indeg.get(w.to) + 1);
portSrc.set(w.to + "|" + w.toPort, w);
}
const q = [];
for (const [id,d] of indeg) if (d === 0) q.push(id);
const order = [];
while (q.length){
const id = q.shift();
order.push(id);
for (const c of outAdj.get(id) || []){
const d = indeg.get(c) - 1;
indeg.set(c, d);
if (d === 0) q.push(c);
}
}
// Any nodes left over would indicate a cycle; append them so nothing is skipped.
if (order.length < scene.nodes.length){
for (const n of scene.nodes) if (!order.includes(n.id)) order.push(n.id);
}
topoOrder = order;
topoDirty = false;
}
function ensureTopo(){ if (topoDirty) buildTopo(); }
/** Resolve the value present at an input port (O(1) via the port index). */
function inputValue(node, port){
const w = portSrc.get(node.id + "|" + port);
if (!w) return 0;
const src = nodeById.get(w.from);
return src && src.state ? 1 : 0;
}
/** Compute a node's output/state from its inputs. Sources are left alone. */
function evalNode(n){
const t = n.type;
if (t === "toggle" || t === "clock") return;
if (t === "lamp"){
n.state = inputValue(n, 0);
return;
}
if (t === "hex7"){
let v = 0;
for (let i = 0; i < 4; i++) if (inputValue(n, i)) v |= (1 << i);
n.state = v;
return;
}
const fn = LOGIC[t];
if (!fn) return;
const bits = [];
for (let i = 0; i < DEF[t].in; i++) bits.push(inputValue(n, i));
n.state = fn(bits);
}
function evaluateAll(){
ensureTopo();
for (const id of topoOrder){
const n = getNode(id);
if (n) evalNode(n);
}
}
/** Descendants of a node (inclusive), following wires downstream. */
function descendants(rootId){
ensureTopo();
const seen = new Set([rootId]);
const stack = [rootId];
while (stack.length){
const cur = stack.pop();
for (const c of outAdj.get(cur) || []){
if (!seen.has(c)){ seen.add(c); stack.push(c); }
}
}
return seen;
}
/**
* Reactive re-evaluation: only the changed node and its downstream cone,
* walked in topological order. Prunes branches whose value did not change.
*/
function evaluateFrom(id){
ensureTopo();
const affected = descendants(id);
const skip = new Set();
for (const nid of topoOrder){
if (!affected.has(nid) || skip.has(nid)) continue;
const n = getNode(nid);
if (!n) continue;
const before = n.state;
evalNode(n);
/* The root's state is set externally (a toggle flip / clock pulse), so it
must always propagate. Descendants only propagate on a real change. */
if (nid !== id && n.state === before){
for (const c of outAdj.get(nid) || []) skip.add(c);
}
}
}
/* ============================================================================
5. VIEW TRANSFORM
screen = (world - cam) * zoom
============================================================================ */
let W = 0, H = 0, DPR = 1;
const cam = scene.cam;
function worldToScreen(x, y){ return { x: (x - cam.x) * cam.zoom, y: (y - cam.y) * cam.zoom }; }
function screenToWorld(x, y){ return { x: x / cam.zoom + cam.x, y: y / cam.zoom + cam.y }; }
function zoomAt(sx, sy, factor){
const wpt = screenToWorld(sx, sy);
const z = Math.min(CFG.ZOOM_MAX, Math.max(CFG.ZOOM_MIN, cam.zoom * factor));
if (z === cam.zoom) return;
cam.zoom = z;
cam.x = wpt.x - sx / z;
cam.y = wpt.y - sy / z;
updateHud();
saveSoon();
}
function fitView(){
if (!scene.nodes.length){
cam.zoom = 1; cam.x = -40; cam.y = -40; updateHud(); return;
}
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of scene.nodes){
x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y);
x1 = Math.max(x1, n.x + CFG.NODE_W); y1 = Math.max(y1, n.y + nodeHeight(n.type));
}
const pad = 70;
const zw = W / (x1 - x0 + pad * 2);
const zh = H / (y1 - y0 + pad * 2);
cam.zoom = Math.min(CFG.ZOOM_MAX, Math.max(CFG.ZOOM_MIN, Math.min(zw, zh)));
cam.x = x0 - pad - (W / cam.zoom - (x1 - x0 + pad * 2)) / 2;
cam.y = y0 - pad - (H / cam.zoom - (y1 - y0 + pad * 2)) / 2;
updateHud();
saveSoon();
}
/* viewport center in world coords */
function viewCenter(){
return screenToWorld(W / 2, H / 2);
}
/* ============================================================================
6. PORT GEOMETRY
============================================================================ */
function portPos(n, kind, idx){
const d = DEF[n.type];
const rows = Math.max(d.in, d.out, 1);
const cnt = kind === "in" ? d.in : d.out;
const top = n.y + CFG.HEAD_H + ((rows - cnt) / 2) * CFG.PORT_ROW;
const y = top + CFG.PORT_ROW * (idx + 0.5);
const x = kind === "in" ? n.x : n.x + CFG.NODE_W;
return { x, y };
}
/** Distance from a world point to a node's body. */
function pointInNode(n, wx, wy){
return wx >= n.x && wx <= n.x + CFG.NODE_W &&
wy >= n.y && wy <= n.y + nodeHeight(n.type);
}
/* ============================================================================
7. RENDERING
============================================================================ */
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let hover = { node:null, port:null, wire:null };
let selected = null; // {kind:'node'|'wire', id}
let drag = null; // interaction state (see section 9)
let flashes = []; // rejected-connection visual cues
let time = 0;
function resize(){
const r = cv.getBoundingClientRect();
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = Math.max(1, Math.round(r.width));
H = Math.max(1, Math.round(r.height));
cv.width = Math.round(W * DPR);
cv.height = Math.round(H * DPR);
updateHud();
}
function roundRect(c, x, y, w, h, r){
r = Math.min(r, w / 2, h / 2);
c.beginPath();
c.moveTo(x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + h - r);
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
c.lineTo(x + r, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - r);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.closePath();
}
/* top-corners-only rounded rect (node header) */
function topRoundRect(c, x, y, w, h, r){
c.beginPath();
c.moveTo(x, y + h);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + h);
c.closePath();
}
/* node body gradients are cached by (type, on/off, rounded y) */
const gradCache = new Map();
function nodeGrad(n, on, h){
const y = Math.round(n.y * 2) / 2;
const key = n.type + "|" + (on ? 1 : 0) + "|" + y;
let g = gradCache.get(key);
if (g) return g;
g = ctx.createLinearGradient(0, y, 0, y + h);
if (on && (n.type === "lamp" || n.type === "hex7")){
g.addColorStop(0, "#2c2620"); g.addColorStop(1, "#1b1712");
} else if (on){
g.addColorStop(0, CFG.colors.nodeTopOn); g.addColorStop(1, CFG.colors.nodeBotOn);
} else {
g.addColorStop(0, CFG.colors.nodeTop); g.addColorStop(1, CFG.colors.nodeBot);
}
if (gradCache.size > 700) gradCache.clear();
gradCache.set(key, g);
return g;
}
/* ---------- background grid ---------- */
function gridDots(step, r){ /* batched into one path */
const x0 = Math.floor(cam.x / step) * step;
const y0 = Math.floor(cam.y / step) * step;
const x1 = cam.x + W / cam.zoom;
const y1 = cam.y + H / cam.zoom;
/* squares are indistinguishable from circles below ~2px but far cheaper */
const d = r * 2;
for (let x = x0; x <= x1; x += step){
for (let y = y0; y <= y1; y += step){
ctx.rect(x - r, y - r, d, d);
}
}
}
function drawGrid(){
let step = CFG.GRID;
while (step * cam.zoom < CFG.GRID_MIN_PX) step *= 2;
const rWorld = Math.max(1.25 / cam.zoom, 0.35);
ctx.beginPath();
gridDots(step, rWorld);
ctx.fillStyle = CFG.colors.grid;
ctx.fill();
/* brighter anchors every 5 steps */
const big = step * 5;
ctx.beginPath();
gridDots(big, rWorld * 1.8);
ctx.fill();
}
/* ---------- bezier helpers ---------- */
function wireCurve(p0, p3){
const dx = Math.max(46, Math.abs(p3.x - p0.x) * 0.55);
return { c1: { x: p0.x + dx, y: p0.y }, c2: { x: p3.x - dx, y: p3.y } };
}
function bezPoint(p0, c1, c2, p3, t){
const mt = 1 - t;
const a = mt*mt*mt, b = 3*mt*mt*t, c = 3*mt*t*t, d = t*t*t;
return {
x: a*p0.x + b*c1.x + c*c2.x + d*p3.x,
y: a*p0.y + b*c1.y + c*c2.y + d*p3.y
};
}
function strokeBezier(p0, p3, width, style, glow){
const { c1, c2 } = wireCurve(p0, p3);
ctx.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, p3.x, p3.y);
if (glow){
ctx.lineWidth = width * 3.4;
ctx.strokeStyle = glow;
ctx.stroke();
}
ctx.lineWidth = width;
ctx.strokeStyle = style;
ctx.stroke();
}
/* ---------- wires ---------- */
function wireEndpoints(w){
const a = getNode(w.from), b = getNode(w.to);
if (!a || !b) return null;
return { p0: portPos(a, "out", w.fromPort), p3: portPos(b, "in", w.toPort), src: a };
}
function drawWire(w){
/* a wire being re-routed is hidden — the live preview stands in for it */
if (drag && drag.movingId === w.id) return;
const e = wireEndpoints(w);
if (!e) return;
const live = e.src.state ? true : false;
const isSel = selected && selected.kind === "wire" && selected.id === w.id;
const isHov = hover.wire === w.id;
const width = (isSel || isHov ? 3.6 : 2.6) / cam.zoom;
if (live){
const pulse = 0.5 + 0.5 * Math.sin(time * 6 - (w.fromPort || 0));
strokeBezier(e.p0, e.p3, width, CFG.colors.wireOn, `rgba(255,193,77,${0.10 + 0.10 * pulse})`);
// travelling signal dot
const { c1, c2 } = wireCurve(e.p0, e.p3);
const t = (time * 0.55 + hashT(w.id)) % 1;
const pt = bezPoint(e.p0, c1, c2, e.p3, t);
ctx.beginPath();
ctx.arc(pt.x, pt.y, 3.1 / cam.zoom, 0, 6.2831);
ctx.fillStyle = "rgba(255,240,200,0.95)";
ctx.fill();
} else {
strokeBezier(e.p0, e.p3, width, isSel ? "#7d93b3" : CFG.colors.wireOff, null);
}
if (isSel){
strokeBezier(e.p0, e.p3, width * 0.45, CFG.colors.sel, `rgba(77,214,193,0.18)`);
}
}
function hashT(id){
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return (h % 1000) / 1000;
}
/* ---------- 7-segment widget ---------- */
function drawSeg7(bx, by, bw, bh, digit){
const t = 4.2;
const on = SEG7[digit & 15] || "";
const segs = {
a: [bx+t, by, bw-2*t, t],
g: [bx+t, by+bh/2-t/2, bw-2*t, t],
d: [bx+t, by+bh-t, bw-2*t, t],
f: [bx, by+t, t, bh/2-1.5*t],
b: [bx+bw-t, by+t, t, bh/2-1.5*t],
e: [bx, by+bh/2+t/2, t, bh/2-1.5*t],
c: [bx+bw-t, by+bh/2+t/2, t, bh/2-1.5*t]
};
for (const k in segs){
const s = segs[k];
const lit = on.indexOf(k) >= 0;
ctx.fillStyle = lit ? CFG.colors.segOn : CFG.colors.segOff;
if (lit){
ctx.shadowColor = "rgba(255,122,92,0.75)";
ctx.shadowBlur = 9 / cam.zoom;
}
roundRect(ctx, s[0], s[1], s[2], s[3], t/2);
ctx.fill();
ctx.shadowBlur = 0;
}
}
/* ---------- node ---------- */
function drawNode(n){
const on = n.state ? true : false;
const isHov = hover.node === n.id;
const isSel = selected && selected.kind === "node" && selected.id === n.id;
const h = nodeHeight(n.type);
const w = nodeWidth();
/* soft drop shadow — a plain offset fill is far cheaper than shadowBlur */
const sh = (isHov || isSel ? 7 : 4) / cam.zoom;
ctx.fillStyle = isSel ? "rgba(20,64,58,0.55)" : "rgba(0,0,0,0.42)";
roundRect(ctx, n.x + 1.5 / cam.zoom, n.y + sh, w, h, 11);
ctx.fill();
ctx.fillStyle = nodeGrad(n, on, h);
roundRect(ctx, n.x, n.y, w, h, 11);
ctx.fill();
/* border */
ctx.lineWidth = (isSel ? 2.2 : isHov ? 1.8 : 1.2) / cam.zoom;
ctx.strokeStyle = isSel ? CFG.colors.sel
: isHov ? CFG.colors.nodeStrokeHi
: (on ? CFG.colors.nodeStrokeOn : CFG.colors.nodeStroke);
roundRect(ctx, n.x, n.y, w, h, 11);
ctx.stroke();
/* header */
ctx.fillStyle = CFG.colors.head;
topRoundRect(ctx, n.x, n.y, w, CFG.HEAD_H, 11);
ctx.fill();
ctx.font = "600 11px ui-monospace, monospace";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = on ? "#e9f3ff" : CFG.colors.title;
ctx.fillText(DEF[n.type].label, n.x + 10, n.y + CFG.HEAD_H / 2 + 0.5);
/* state LED in header */
const ledX = n.x + w - 13, ledY = n.y + CFG.HEAD_H / 2;
ctx.beginPath();
ctx.arc(ledX, ledY, 3.6, 0, 6.2831);
if (on){
ctx.shadowColor = "rgba(255,193,77,0.95)";
ctx.shadowBlur = 10 / cam.zoom;
ctx.fillStyle = CFG.colors.wireOn;
} else {
ctx.fillStyle = "#2b3543";
}
ctx.fill();
ctx.shadowBlur = 0;
/* ---- type-specific body widget ---- */
const bodyTop = n.y + CFG.HEAD_H;
const bodyH = h - CFG.HEAD_H - 2;
if (n.type === "toggle"){
const sw = 40, sh = 18;
const sx = n.x + w - sw - 10, sy = bodyTop + (bodyH - sh) / 2;
roundRect(ctx, sx, sy, sw, sh, sh/2);
ctx.fillStyle = on ? "rgba(255,165,61,0.22)" : "#0d121a";
ctx.fill();
ctx.lineWidth = 1.2 / cam.zoom;
ctx.strokeStyle = on ? CFG.colors.warm : "#33404f";
ctx.stroke();
ctx.beginPath();
ctx.arc(on ? sx + sw - sh/2 : sx + sh/2, sy + sh/2, sh/2 - 3, 0, 6.2831);
if (on){ ctx.shadowColor = "rgba(255,165,61,0.9)"; ctx.shadowBlur = 9/cam.zoom; }
ctx.fillStyle = on ? CFG.colors.warm : "#3c4a5c";
ctx.fill();
ctx.shadowBlur = 0;
ctx.font = "600 10px ui-monospace, monospace";
ctx.textAlign = "left";
ctx.fillStyle = on ? "#ffd9a8" : CFG.colors.sub;
ctx.fillText(on ? "ON" : "OFF", n.x + 11, sy + sh/2 + 0.5);
} else if (n.type === "clock"){
ctx.font = "600 10px ui-monospace, monospace";
ctx.textAlign = "left";
ctx.fillStyle = CFG.colors.sub;
ctx.fillText("RATE", n.x + 11, bodyTop + bodyH/2 + 0.5);
ctx.textAlign = "right";
ctx.fillStyle = on ? "#ffd9a8" : CFG.colors.title;
ctx.fillText((n.rate || 2) + " Hz", n.x + w - 11, bodyTop + bodyH/2 + 0.5);
} else if (n.type === "lamp"){
const cx = n.x + w - 26, cy = bodyTop + bodyH / 2;
if (on){
const rg = ctx.createRadialGradient(cx, cy, 1, cx, cy, 26);
rg.addColorStop(0, "rgba(255,224,138,0.85)");
rg.addColorStop(0.4, "rgba(255,200,90,0.28)");
rg.addColorStop(1, "rgba(255,200,90,0)");
ctx.fillStyle = rg;
ctx.beginPath(); ctx.arc(cx, cy, 26, 0, 6.2831); ctx.fill();
}
ctx.beginPath(); ctx.arc(cx, cy, 9.5, 0, 6.2831);
ctx.fillStyle = on ? CFG.colors.lampOn : CFG.colors.lampOff;
if (on){ ctx.shadowColor = "rgba(255,224,138,0.95)"; ctx.shadowBlur = 16/cam.zoom; }
ctx.fill();
ctx.shadowBlur = 0;
ctx.lineWidth = 1.2 / cam.zoom;
ctx.strokeStyle = on ? "#fff2c4" : "#3a4557";
ctx.stroke();
ctx.font = "600 10px ui-monospace, monospace";
ctx.textAlign = "left";
ctx.fillStyle = on ? "#ffd9a8" : CFG.colors.sub;
ctx.fillText(on ? "ON" : "—", n.x + 11, cy + 0.5);
} else if (n.type === "hex7"){
drawSeg7(n.x + 42, bodyTop + 7, 48, bodyH - 16, n.state);
} else {
/* gates: show the operator glyph faintly */
ctx.font = "600 13px ui-monospace, monospace";
ctx.textAlign = "center";
ctx.fillStyle = on ? "rgba(255,193,77,0.30)" : "rgba(120,145,180,0.16)";
ctx.fillText(n.type.toUpperCase(), n.x + w * 0.5, bodyTop + bodyH / 2 + 1);
}
/* ---- ports ---- */
const d = DEF[n.type];
for (let i = 0; i < d.in; i++) drawPort(n, "in", i);
for (let i = 0; i < d.out; i++) drawPort(n, "out", i);
}
function portEnergized(n, kind, idx){
if (kind === "out") return !!n.state;
ensureTopo();
const w = portSrc.get(n.id + "|" + idx);
if (!w) return false;
const s = nodeById.get(w.from);
return !!(s && s.state);
}
function drawPort(n, kind, idx){
const p = portPos(n, kind, idx);
const isHover = hover.port && hover.port.nodeId === n.id &&
hover.port.kind === kind && hover.port.idx === idx;
const hot = drag && drag.kind === "wire" && drag.validPort &&
drag.validPort.nodeId === n.id && drag.validPort.kind === kind &&
drag.validPort.idx === idx;
const bad = drag && drag.kind === "wire" && drag.badPort &&
drag.badPort.nodeId === n.id && drag.badPort.kind === kind &&
drag.badPort.idx === idx;
const live = portEnergized(n, kind, idx);
let r = CFG.PORT_R;
if (isHover || hot || bad) r = CFG.PORT_R * 1.55;
const base = kind === "in" ? CFG.colors.cool : CFG.colors.warm;
const dim = kind === "in" ? CFG.colors.coolDim : CFG.colors.warmDim;
/* halo */
if (live || isHover || hot || bad){
const col = bad ? "255,91,110" : hot ? "77,214,193" : kind === "in" ? "63,169,245" : "255,165,61";
const rg = ctx.createRadialGradient(p.x, p.y, r * 0.4, p.x, p.y, r * 3.4);
rg.addColorStop(0, `rgba(${col},0.42)`);
rg.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = rg;
ctx.beginPath(); ctx.arc(p.x, p.y, r * 3.4, 0, 6.2831); ctx.fill();
}
/* socket */
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, 6.2831);
ctx.fillStyle = live ? (kind === "in" ? "#8fd4ff" : "#ffd08a") : "#0c1118";
ctx.fill();
ctx.lineWidth = 2 / cam.zoom;
ctx.strokeStyle = bad ? CFG.colors.bad : hot ? CFG.colors.ok : (live ? base : dim);
ctx.stroke();
/* port index label for multi-port nodes */
const d = DEF[n.type];
if (kind === "in" && d.port && d.port[idx]){
ctx.font = "600 9px ui-monospace, monospace";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(140,165,195,0.75)";
ctx.fillText(d.port[idx], p.x - 12, p.y + 0.5);
}
}
/* ---------- live drag preview + rejection flashes ---------- */
function drawDragPreview(){
if (!drag || drag.kind !== "wire") return;
const mode = drag.mode; // 'out' | 'in'
const origin = getNode(drag.nodeId);
if (!origin) return;
const anchor = portPos(origin, mode === "out" ? "out" : "in", drag.portIdx);
let target = { x: drag.wx, y: drag.wy };
let col = CFG.colors.wireGhost;
let glow = null;
if (drag.validPort){
const tn = getNode(drag.validPort.nodeId);
if (tn){
target = portPos(tn, drag.validPort.kind, drag.validPort.idx);
col = CFG.colors.ok;
glow = "rgba(77,214,193,0.22)";
}
} else if (drag.badPort){
const tn = getNode(drag.badPort.nodeId);
if (tn){
target = portPos(tn, drag.badPort.kind, drag.badPort.idx);
col = CFG.colors.bad;
glow = "rgba(255,91,110,0.22)";
}
}
const p0 = mode === "out" ? anchor : target;
const p3 = mode === "out" ? target : anchor;
ctx.setLineDash([7 / cam.zoom, 5 / cam.zoom]);
ctx.lineDashOffset = -(time * 26) % 1000;
strokeBezier(p0, p3, 2.6 / cam.zoom, col, glow);
ctx.setLineDash([]);
ctx.lineDashOffset = 0;
/* cursor puck */
ctx.beginPath();
ctx.arc(drag.wx, drag.wy, 4.5 / cam.zoom, 0, 6.2831);
ctx.fillStyle = col;
ctx.fill();
}
function drawFlashes(){
const now = performance.now();
flashes = flashes.filter(f => now - f.t0 < 620);
for (const f of flashes){
const k = (now - f.t0) / 620;
const a = 1 - k;
ctx.globalAlpha = a;
strokeBezier(f.p0, f.p3, (3 + 2 * k) / cam.zoom,
`rgba(255,91,110,${0.95})`, `rgba(255,91,110,${0.28 * a})`);
ctx.globalAlpha = 1;
}
}
function flashReject(p0, p3){
flashes.push({ p0, p3, t0: performance.now() });
}
/* ---------- main frame ---------- */
function render(){
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.fillStyle = CFG.colors.bg;
ctx.fillRect(0, 0, W, H);
/* vignette + depth handled by the CSS #veil layer */
/* world space */
const z = cam.zoom * DPR;
ctx.setTransform(z, 0, 0, z, -cam.x * z, -cam.y * z);
drawGrid();
for (const w of scene.wires) drawWire(w);
drawDragPreview();
for (const n of scene.nodes) drawNode(n);
drawFlashes();
}
/* ============================================================================
8. HIT TESTING
============================================================================ */
function hitPort(wx, wy){
const tol = CFG.PORT_HIT_PX / cam.zoom;
let best = null, bestD = Infinity;
for (const n of scene.nodes){
const d = DEF[n.type];
for (let i = 0; i < d.in; i++){
const p = portPos(n, "in", i);
const dist = Math.hypot(p.x - wx, p.y - wy);
if (dist <= Math.max(CFG.PORT_R * 1.6, tol) && dist < bestD){
bestD = dist; best = { nodeId:n.id, kind:"in", idx:i };
}
}
for (let i = 0; i < d.out; i++){
const p = portPos(n, "out", i);
const dist = Math.hypot(p.x - wx, p.y - wy);
if (dist <= Math.max(CFG.PORT_R * 1.6, tol) && dist < bestD){
bestD = dist; best = { nodeId:n.id, kind:"out", idx:i };
}
}
}
return best;
}
function hitNode(wx, wy){
for (let i = scene.nodes.length - 1; i >= 0; i--){
const n = scene.nodes[i];
if (pointInNode(n, wx, wy)) return n;
}
return null;
}
function hitWire(wx, wy){
const tol = CFG.WIRE_HIT_PX / cam.zoom;
let best = null, bestD = Infinity;
for (const w of scene.wires){
const e = wireEndpoints(w);
if (!e) continue;
const { c1, c2 } = wireCurve(e.p0, e.p3);
const segs = 16;
let prev = e.p0;
for (let i = 1; i <= segs; i++){
const pt = bezPoint(e.p0, c1, c2, e.p3, i / segs);
const d = segDist(wx, wy, prev, pt);
if (d < bestD){ bestD = d; best = w; }
prev = pt;
}
}
return bestD <= tol ? best : null;
}
function segDist(px, py, a, b){
const vx = b.x - a.x, vy = b.y - a.y;
const len2 = vx * vx + vy * vy;
let t = len2 ? ((px - a.x) * vx + (py - a.y) * vy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const cx = a.x + t * vx, cy = a.y + t * vy;
return Math.hypot(px - cx, py - cy);
}
/* ============================================================================
9. PERSISTENCE
============================================================================ */
let booting = true;
let saveTimer = null;
function serialize(){
return JSON.stringify({
v: 1,
cam: { x: cam.x, y: cam.y, zoom: cam.zoom },
nodes: scene.nodes.map(n => {
const o = { id:n.id, type:n.type, x:n.x, y:n.y, state:n.state ? 1 : 0 };
if (n.type === "clock") o.rate = n.rate || 2;
return o;
}),
wires: scene.wires.map(w => ({
id:w.id, from:w.from, fromPort:w.fromPort, to:w.to, toPort:w.toPort
}))
});
}
function saveNow(){
if (booting) return;
try { Store.set(CFG.STORAGE_KEY, serialize()); } catch(e){ /* ignore */ }
}
function saveSoon(){
if (booting) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, CFG.SAVE_DEBOUNCE);
}
function deserialize(raw){
let data;
try { data = JSON.parse(raw); } catch(e){ return false; }
if (!data || !Array.isArray(data.nodes)) return false;
const nodes = [], wires = [];
const seen = new Set();
for (const n of data.nodes){
if (!n || typeof n.id !== "string" || !DEF[n.type] || seen.has(n.id)) continue;
seen.add(n.id);
const node = {
id: n.id, type: n.type,
x: Number(n.x) || 0, y: Number(n.y) || 0,
state: n.state ? 1 : 0
};
if (n.type === "clock"){
node.rate = CFG.CLOCK_RATES.indexOf(n.rate) >= 0 ? n.rate : 2;
node.acc = 0;
}
nodes.push(node);
}
for (const w of data.wires || []){
if (!w) continue;
const a = nodes.find(n => n.id === w.from), b = nodes.find(n => n.id === w.to);
if (!a || !b) continue;
if (w.fromPort < 0 || w.fromPort >= DEF[a.type].out) continue;
if (w.toPort < 0 || w.toPort >= DEF[b.type].in) continue;
if (w.from === w.to) continue;
wires.push({
id: typeof w.id === "string" ? w.id : uid("w"),
from: w.from, fromPort: w.fromPort | 0, to: w.to, toPort: w.toPort | 0
});
}
scene.nodes = nodes;
scene.wires = wires;
if (data.cam && isFinite(data.cam.x) && isFinite(data.cam.y) && isFinite(data.cam.zoom)){
cam.x = data.cam.x; cam.y = data.cam.y;
cam.zoom = Math.min(CFG.ZOOM_MAX, Math.max(CFG.ZOOM_MIN, data.cam.zoom));
} else {
return "nocam";
}
return true;
}
/* ============================================================================
10. UI (hud, toast, palette)
============================================================================ */
const zoomTxt = document.getElementById("zoomTxt");
const nodeTxt = document.getElementById("nodeTxt");
const wireTxt = document.getElementById("wireTxt");
const toastEl = document.getElementById("toast");
let toastTimer = null;
function updateHud(){
zoomTxt.textContent = Math.round(cam.zoom * 100) + "%";
nodeTxt.textContent = scene.nodes.length;
wireTxt.textContent = scene.wires.length;
}
function toast(msg, info){
toastEl.textContent = msg;
toastEl.className = "show" + (info ? " info" : "");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastEl.className = ""; }, 1700);
}
function buildPalette(){
const groups = { src: document.getElementById("pal-src"),
gate: document.getElementById("pal-gate"),
out: document.getElementById("pal-out") };
const swatch = { toggle:"d-warm", clock:"d-warm", and:"d-cool", or:"d-cool", not:"d-cool",
xor:"d-cool", nand:"d-cool", nor:"d-cool", lamp:"d-grn", hex7:"d-red" };
const order = { src:["toggle","clock"], gate:["and","or","not","xor","nand","nor"],
out:["lamp","hex7"] };
for (const g in groups){
for (const t of order[g]){
const b = document.createElement("button");
b.className = "pal";
b.dataset.type = t;
b.innerHTML = '<span class="dot ' + swatch[t] + '"></span>' + DEF[t].label;
b.addEventListener("click", () => spawnNode(t));
groups[g].appendChild(b);
}
}
}
function spawnNode(type){
const c = viewCenter();
const jitter = (scene.nodes.length % 6) * 18;
const n = addNode(type, c.x - CFG.NODE_W / 2 + jitter - 45,
c.y - nodeHeight(type) / 2 + jitter - 45);
selected = { kind:"node", id:n.id };
evaluateAll();
updateHud();
}
document.getElementById("btn-fit").addEventListener("click", fitView);
document.getElementById("btn-clear").addEventListener("click", () => {
scene.nodes = []; scene.wires = [];
selected = null; indexNodes(); markStructDirty(); evaluateAll();
saveNow(); updateHud();
toast("Canvas cleared", true);
});
/* ============================================================================
11. INTERACTION
============================================================================ */
function canvasPos(e){
const r = cv.getBoundingClientRect();
return { sx: e.clientX - r.left, sy: e.clientY - r.top };
}
/** Which kind of port does the current wire-drag need as a landing target? */
function wantedKind(){ return drag && drag.mode === "out" ? "in" : "out"; }
/** Given a hovered port, decide if it is a legal landing spot for the drag. */
function classifyTarget(port){
if (!drag || drag.kind !== "wire" || !port) return { valid:false, bad:false };
if (port.kind !== wantedKind()) return { valid:false, bad:false };
let fromId, fromPort, toId, toPort;
if (drag.mode === "out"){
fromId = drag.nodeId; fromPort = drag.portIdx; toId = port.nodeId; toPort = port.idx;
} else {
fromId = port.nodeId; fromPort = port.idx; toId = drag.nodeId; toPort = drag.portIdx;
}
if (drag.movingId){
const m = scene.wires.find(w => w.id === drag.movingId);
if (m && m.from === fromId && m.to === toId) return { valid:false, bad:false }; // no-op
}
const v = canConnect(fromId, fromPort, toId, toPort);
return { valid: v.ok, bad: !v.ok && !!v.cycle, reason: v.reason };
}
function startWireDrag(mode, nodeId, portIdx, movingId){
drag = { kind:"wire", mode, nodeId, portIdx, movingId: movingId || null,
wx:0, wy:0, validPort:null, badPort:null };
}
function onPointerDown(e){
if (e.button === 2) { /* handled in onPointerMove for pan */ }
try { cv.setPointerCapture(e.pointerId); } catch(err){ /* synthetic / lost pointer */ }
const { sx, sy } = canvasPos(e);
const w = screenToWorld(sx, sy);
if (e.button === 1 || e.button === 2){
drag = { kind:"pan", sx, sy, camx:cam.x, camy:cam.y , moved:false};
cv.style.cursor = "grabbing";
return;
}
/* 1 — ports win */
const port = hitPort(w.x, w.y);
if (port){
if (port.kind === "out"){
startWireDrag("out", port.nodeId, port.idx, null);
} else {
/* Grabbing an input port always drags the wire's *free end*, looking for
an output to land on. For an occupied port that means picking the
existing wire up (it is hidden while dragging) and re-sourcing it. */
const existing = wiresInto(port.nodeId, port.idx);
startWireDrag("in", port.nodeId, port.idx, existing.length ? existing[0].id : null);
}
selected = { kind:"node", id: port.nodeId };
return;
}
/* 2 — node bodies */
const n = hitNode(w.x, w.y);
if (n){
selected = { kind:"node", id:n.id };
drag = { kind:"node", id:n.id, ox:w.x - n.x, oy:w.y - n.y, sx, sy, moved:false };
cv.style.cursor = "grabbing";
return;
}
/* 3 — wires */
const ww = hitWire(w.x, w.y);
if (ww){
selected = { kind:"wire", id:ww.id };
drag = { kind:"pan", sx, sy, camx:cam.x, camy:cam.y, moved:false };
return;
}
/* 4 — empty space: pan + deselect */
selected = null;
drag = { kind:"pan", sx, sy, camx:cam.x, camy:cam.y, moved:false };
cv.style.cursor = "grabbing";
}
function onPointerMove(e){
const { sx, sy } = canvasPos(e);
const w = screenToWorld(sx, sy);
if (drag){
if (drag.kind === "pan"){
if (Math.abs(sx - drag.sx) + Math.abs(sy - drag.sy) > 1) drag.moved = true;
cam.x = drag.camx - (sx - drag.sx) / cam.zoom;
cam.y = drag.camy - (sy - drag.sy) / cam.zoom;
saveSoon();
return;
}
if (drag.kind === "node"){
if (Math.abs(sx - drag.sx) + Math.abs(sy - drag.sy) > CFG.DRAG_SLOP) drag.moved = true;
const n = getNode(drag.id);
if (n){
n.x = Math.round(w.x - drag.ox);
n.y = Math.round(w.y - drag.oy);
}
saveSoon();
return;
}
if (drag.kind === "wire"){
drag.wx = w.x; drag.wy = w.y;
const p = hitPort(w.x, w.y);
const cls = classifyTarget(p);
drag.validPort = cls.valid ? p : null;
drag.badPort = cls.bad ? p : null;
return;
}
}
/* hover state */
const hp = hitPort(w.x, w.y);
hover.port = hp;
hover.node = hp ? hp.nodeId : (hitNode(w.x, w.y) ? hitNode(w.x, w.y).id : null);
const hw = hover.node ? null : hitWire(w.x, w.y);
hover.wire = hw ? hw.id : null;
cv.style.cursor = hp ? "pointer" : hover.node ? "grab" : hw ? "pointer" : "crosshair";
}
function finishWireDrag(w, e){
const p = hitPort(w.x, w.y);
const cls = classifyTarget(p);
const moving = drag.movingId;
if (cls.valid && p){
let fromId, fromPort, toId, toPort;
if (drag.mode === "out"){
fromId = drag.nodeId; fromPort = drag.portIdx; toId = p.nodeId; toPort = p.idx;
} else {
fromId = p.nodeId; fromPort = p.idx; toId = drag.nodeId; toPort = drag.portIdx;
}
if (moving) removeWire(moving);
const res = addWire(fromId, fromPort, toId, toPort);
if (!res.ok){
flashReject(portPos(getNode(fromId), "out", fromPort),
portPos(getNode(toId), "in", toPort));
toast(res.reason || "Connection refused");
}
return;
}
if (cls.bad && p){
/* cycle attempt — refuse loudly, restore any picked-up wire */
let a, b;
if (drag.mode === "out"){
a = portPos(getNode(drag.nodeId), "out", drag.portIdx);
b = portPos(getNode(p.nodeId), "in", p.idx);
} else {
a = portPos(getNode(p.nodeId), "out", p.idx);
b = portPos(getNode(drag.nodeId), "in", drag.portIdx);
}
flashReject(a, b);
toast(cls.reason || "Connection refused");
return;
}
if (moving) toast("Release over an output port to re-route — wire kept as-is", true);
}
function onPointerUp(e){
if (!drag) return;
const { sx, sy } = canvasPos(e);
const w = screenToWorld(sx, sy);
if (drag.kind === "wire"){
finishWireDrag(w, e);
} else if (drag.kind === "node" && !drag.moved){
const n = getNode(drag.id);
if (n && n.type === "toggle"){
n.state = n.state ? 0 : 1;
evaluateFrom(n.id);
saveSoon();
updateHud();
} else if (n && n.type === "clock"){
const i = CFG.CLOCK_RATES.indexOf(n.rate || 2);
n.rate = CFG.CLOCK_RATES[(i + 1) % CFG.CLOCK_RATES.length];
n.acc = 0;
saveSoon();
}
}
drag = null;
hover.port = null;
cv.style.cursor = "crosshair";
updateHud();
}
function onWheel(e){
e.preventDefault();
const { sx, sy } = canvasPos(e);
const factor = Math.pow(CFG.ZOOM_STEP, -e.deltaY);
zoomAt(sx, sy, factor);
}
function onKeyDown(e){
if (e.key === "Escape" && drag && drag.kind === "wire"){ drag = null; return; }
if (e.key === "Delete" || e.key === "Backspace"){
if (selected && selected.kind === "node"){ removeNode(selected.id); selected = null; e.preventDefault(); }
else if (selected && selected.kind === "wire"){ removeWire(selected.id); selected = null; e.preventDefault(); }
updateHud();
}
if (e.key === "f" || e.key === "F") fitView();
}
/* ============================================================================
12. CLOCK LOOP + MAIN FRAME
============================================================================ */
let last = performance.now();
function tick(now){
const dt = Math.min(0.1, (now - last) / 1000);
last = now;
time += dt;
/* clock nodes */
let stateChanged = false;
for (const n of scene.nodes){
if (n.type !== "clock") continue;
const period = 1 / (n.rate || 2);
n.acc += dt;
if (n.acc >= period){
n.acc -= period;
n.state = n.state ? 0 : 1;
evaluateFrom(n.id);
stateChanged = true;
}
}
if (stateChanged) saveSoon();
render();
requestAnimationFrame(tick);
}
/* ============================================================================
13. DEMO SCENE + INIT
============================================================================ */
function buildDemo(){
const t1 = addNode("toggle", 90, 90);
const t2 = addNode("toggle", 90, 250);
const an = addNode("and", 340, 140);
const lp = addNode("lamp", 600, 150);
const ck = addNode("clock", 90, 420);
const nt = addNode("not", 340, 410);
const l2 = addNode("lamp", 600, 420);
t1.state = 1;
addWire(t1.id, 0, an.id, 0);
addWire(t2.id, 0, an.id, 1);
addWire(an.id, 0, lp.id, 0);
addWire(ck.id, 0, nt.id, 0);
addWire(nt.id, 0, l2.id, 0);
}
function init(){
resize();
buildPalette();
cv.addEventListener("pointerdown", onPointerDown);
cv.addEventListener("pointermove", onPointerMove);
cv.addEventListener("pointerup", onPointerUp);
cv.addEventListener("pointercancel", onPointerUp);
cv.addEventListener("wheel", onWheel, { passive:false });
cv.addEventListener("contextmenu", e => e.preventDefault());
window.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", resize);
if (window.ResizeObserver){
new ResizeObserver(() => resize()).observe(document.getElementById("stage"));
}
const raw = Store.get(CFG.STORAGE_KEY);
let loaded = false;
if (raw) loaded = deserialize(raw) === true;
if (!loaded || !scene.nodes.length){
buildDemo();
cam.zoom = 1; cam.x = 0; cam.y = 0;
fitView();
}
indexNodes();
markStructDirty();
evaluateAll();
updateHud();
booting = false;
saveNow();
requestAnimationFrame(tick);
}
/* ---------- test / debug surface ---------- */
window.__lab = {
scene, CFG, DEF, cam,
addNode, removeNode, addWire, removeWire, canConnect, getNode,
evaluateAll, evaluateFrom, inputValue, portPos, nodeHeight,
serialize, deserialize, saveNow,
screenToWorld, worldToScreen, zoomAt, fitView, hitPort, hitNode, hitWire,
dragState(){ return drag; },
setView(z, x, y){ cam.zoom = z; cam.x = x; cam.y = y; },
getTopo(){ ensureTopo(); return topoOrder.slice(); },
toggle(id){ const n = getNode(id); if (n && n.type === "toggle"){ n.state = n.state ? 0 : 1; evaluateFrom(id); return n.state; } return null; },
stats(){ return { nodes: scene.nodes.length, wires: scene.wires.length, zoom: cam.zoom }; }
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"dccc3ece-d116-48ef-bd23-9019f56486b0","tokensIn":6232891,"tokensOut":89801,"tokensTotal":6322692,"cost":0.08666231399999998,"turns":62,"toolCalls":61,"failedToolCalls":0,"timestamp":"2026-09-10T22:28:08.814Z"} -->