← Node Graph Editor results

Node Graph Editor

Deepseek v4.1 Flash · full

Initial view of Node Graph Editor

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"} -->