← Node Graph Editor results

Node Graph Editor

Ornith 1.5 35B A3B · 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 36,340 bytes · SHA-256 1abe2f48ed9a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logic Circuit Node Graph</title>
<style>
  /* ===== Base / Reset ===== */
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%; overflow: hidden;
    font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
    background: #0b0e14; color: #cdd3e0;
    user-select: none; -webkit-user-select: none;
  }
  #app { position: fixed; inset: 0; }

  /* ===== Canvas ===== */
  #stage { position: absolute; inset: 0; display: block; cursor: grab; }
  #stage.grabbing { cursor: grabbing; }
  #stage.wiring { cursor: crosshair; }

  /* ===== Sidebar Palette ===== */
  #palette {
    position: absolute; top: 12px; left: 12px; width: 168px;
    background: rgba(18, 23, 34, 0.92); border: 1px solid #26304180;
    border-radius: 12px; padding: 10px; box-shadow: 0 8px 30px #00000060;
    backdrop-filter: blur(6px); z-index: 10;
  }
  #palette h2 {
    font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase;
    color: #6f7a92; margin-bottom: 8px; font-weight: 600;
  }
  .pal-group { margin-bottom: 8px; }
  .pal-label { font-size: 10px; color: #55607a; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
  .pal-btn {
    display: flex; align-items: center; gap: 8px; width: 100%;
    background: #171d29; border: 1px solid #2a3446; color: #cdd3e0;
    padding: 7px 9px; margin-bottom: 4px; border-radius: 8px; cursor: pointer;
    font-size: 13px; font-family: inherit; transition: background .12s, border-color .12s, transform .1s;
  }
  .pal-btn:hover { background: #222c3d; border-color: #3d4c66; }
  .pal-btn:active { transform: scale(0.98); }
  .pal-swatch { width: 14px; height: 14px; border-radius: 4px; flex: 0 0 auto; box-shadow: 0 0 6px #00000055; }

  /* ===== Toolbar ===== */
  #toolbar {
    position: absolute; top: 12px; right: 12px; display: flex; gap: 6px;
    background: rgba(18, 23, 34, 0.92); border: 1px solid #26304180;
    border-radius: 10px; padding: 6px; box-shadow: 0 8px 30px #00000060; z-index: 10;
  }
  #toolbar button {
    background: #171d29; border: 1px solid #2a3446; color: #cdd3e0;
    padding: 6px 10px; border-radius: 7px; cursor: pointer; font-size: 12px; font-family: inherit;
    transition: background .12s;
  }
  #toolbar button:hover { background: #222c3d; }
  #zoom-label { display:flex; align-items:center; font-size: 12px; color:#8f9ab2; min-width: 52px; text-align:center; }

  /* ===== Hint ===== */
  #hint {
    position: absolute; bottom: 12px; left: 12px; max-width: 380px;
    background: rgba(18, 23, 34, 0.85); border: 1px solid #26304180;
    border-radius: 10px; padding: 9px 12px; font-size: 11px; line-height: 1.6; color: #8f9ab2; z-index: 10;
  }
  #hint b { color: #cdd3e0; }
  #hint kbd {
    background: #222c3d; border: 1px solid #34405a; border-bottom-width: 2px;
    border-radius: 4px; padding: 0 5px; font-size: 10px; color: #cdd3e0; font-family: monospace;
  }

  /* ===== Context menu ===== */
  #context {
    position: absolute; display: none; min-width: 176px;
    background: rgba(20, 26, 38, 0.97); border: 1px solid #34405a;
    border-radius: 10px; padding: 5px; box-shadow: 0 12px 40px #00000080; z-index: 100;
  }
  .ctx-item {
    display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 7px;
    cursor: pointer; font-size: 13px;
  }
  .ctx-item:hover { background: #28344a; }
  .ctx-item .sw { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 auto; }
  .ctx-sep { height: 1px; background: #2a3446; margin: 5px 4px; }
</style>
</head>
<body>
<div id="app">
  <canvas id="stage"></canvas>

  <div id="palette">
    <h2>Nodes</h2>
    <div class="pal-group">
      <div class="pal-label">Inputs</div>
      <button class="pal-btn" data-add="input"><span class="pal-swatch" style="background:#46b8ff"></span>Switch</button>
      <button class="pal-btn" data-add="clock"><span class="pal-swatch" style="background:#ffd24a"></span>Clock</button>
    </div>
    <div class="pal-group">
      <div class="pal-label">Gates</div>
      <button class="pal-btn" data-add="and"><span class="pal-swatch" style="background:#a06bff"></span>AND</button>
      <button class="pal-btn" data-add="or"><span class="pal-swatch" style="background:#c05bff"></span>OR</button>
      <button class="pal-btn" data-add="not"><span class="pal-swatch" style="background:#e05bff"></span>NOT</button>
      <button class="pal-btn" data-add="xor"><span class="pal-swatch" style="background:#ff6bd0"></span>XOR</button>
      <button class="pal-btn" data-add="nand"><span class="pal-swatch" style="background:#ff8a4a"></span>NAND</button>
      <button class="pal-btn" data-add="nor"><span class="pal-swatch" style="background:#ffb14a"></span>NOR</button>
    </div>
    <div class="pal-group">
      <div class="pal-label">Outputs</div>
      <button class="pal-btn" data-add="lamp"><span class="pal-swatch" style="background:#4aff9e"></span>Lamp</button>
      <button class="pal-btn" data-add="display"><span class="pal-swatch" style="background:#3fe0c8"></span>7-Segment</button>
    </div>
  </div>

  <div id="toolbar">
    <button id="btn-fit">Fit</button>
    <button id="btn-zin">+</button>
    <div id="zoom-label">100%</div>
    <button id="btn-zout">-</button>
    <button id="btn-clear">Clear</button>
  </div>

  <div id="hint">
    <b>Logic Node Graph</b><br>
    <kbd>Drag</kbd> empty space to pan · <kbd>Scroll</kbd> zoom · <kbd>Del</kbd> delete<br>
    Drag from a <b>warm</b> output → <b>cool</b> input to wire · Right-click to add
  </div>

  <div id="context"></div>
</div>

<script>
"use strict";

/* ============================================================
   1. TUNABLE CONSTANTS
   ============================================================ */
const CFG = {
  bgTop:       '#0b0e14', bgBottom:    '#0d1119',
  gridDot:     '#1b2434',
  nodeBody:    '#161c28', nodeHead:    '#1e2736', nodeBorder: '#2c3850', nodeSel: '#46b8ff',
  portIn:      '#2f5f8c', portInHi:    '#46b8ff',
  portOut:     '#8c5a2f', portOutHi:   '#ffb14a',
  wireOff:     '#3a4a63', wireOn:      '#4aff9e', wirePreview: '#7f8db0', wireBad: '#ff4a6b',
  text:        '#cdd3e0', textDim:     '#7f8db0',
  lampOn:      '#7dffb0', lampOff:     '#1e4a33', segOn: '#3fe0c8', segOff: '#1c3330',

  nodeW:       148, nodeH:       64, headH: 26,
  portR:       7, portHitR:      15, portGap: 26, portMarginX: 16, bezierK: 0.55,

  zoomMin:     0.5, zoomMax:     2.0, zoomStep: 1.12,
  clockRates:  [0.5, 1, 2, 4],

  panThreshold: 3,
};

/* ============================================================
   2. TYPE DEFINITIONS + TRUTH TABLES
   ============================================================ */
const TYPES = {
  input: { label: 'Switch', inputs: 0, outputs: 1, head: '#20507a', toggle: true },
  clock: { label: 'Clock',  inputs: 0, outputs: 1, head: '#7a6620', toggle: true },
  and:   { label: 'AND',    inputs: 2, outputs: 1, head: '#4a2f8c' },
  or:    { label: 'OR',     inputs: 3, outputs: 1, head: '#5a2f8c' },
  not:   { label: 'NOT',    inputs: 1, outputs: 1, head: '#7a2f8c' },
  xor:   { label: 'XOR',    inputs: 2, outputs: 1, head: '#8c2f7a' },
  nand:  { label: 'NAND',   inputs: 2, outputs: 1, head: '#8c5a2f' },
  nor:   { label: 'NOR',    inputs: 2, outputs: 1, head: '#8c6a2f' },
  lamp:  { label: 'Lamp',   inputs: 1, outputs: 0, head: '#1f7a4f', output: true },
  display: { label: '7-Seg', inputs: 4, outputs: 0, head: '#1f7a73', output: true },
};

function gateOutput(type, inputs) {
  // Missing (unwired) inputs take their gate's identity element so that a
  // partially-wired gate evaluates correctly: AND/NAND default to 1,
  // OR/NOR/XOR default to 0.
  const isOrFamily = type === 'or' || type === 'nor';
  const pad = isOrFamily ? 0 : 1;
  const a = (inputs[0] != null ? inputs[0] : pad) ? 1 : 0;
  const b = (inputs[1] != null ? inputs[1] : pad) ? 1 : 0;
  const c = (inputs[2] != null ? inputs[2] : pad) ? 1 : 0;
  switch (type) {
    case 'and':   return (a & b & c) ? 1 : 0;
    case 'or':    return (a | b | c) ? 1 : 0;
    case 'not':   return inputs[0] ? 0 : 1;
    case 'xor':   return (a ^ b ^ c) ? 1 : 0;
    case 'nand':  return (a & b & c) ? 0 : 1;
    case 'nor':   return (a | b | c) ? 0 : 1;
    default:      return 0;
  }
}

/* ============================================================
   3. SCENE MODEL  (serializable source of truth)
   ============================================================ */
let scene = { nodes: [], wires: [] };
let uid = 1;
const nid = () => 'n' + (uid++);

function makeNode(type, x, y) {
  return { id: nid(), type, x, y, state: 0, rate: 1 };
}
function findNode(id) { return scene.nodes.find(n => n.id === id); }

/* ============================================================
   4. VIEWPORT  (world <-> screen)
   ============================================================ */
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
let DPR = window.devicePixelRatio || 1;
const view = { x: 0, y: 0, zoom: 1 };

function resize() {
  DPR = window.devicePixelRatio || 1;
  canvas.width = Math.floor(innerWidth * DPR);
  canvas.height = Math.floor(innerHeight * DPR);
}
window.addEventListener('resize', () => { resize(); render(); });
resize();

function worldToScreen(x, y) {
  return [ (x - view.x) * view.zoom + view.x + innerWidth / 2,
           (y - view.y) * view.zoom + view.y + innerHeight / 2 ];
}
function screenToWorld(sx, sy) {
  return [ (sx - innerWidth / 2 - view.x) / view.zoom + view.x,
           (sy - innerHeight / 2 - view.y) / view.zoom + view.y ];
}
function applyZoom(nz) {
  nz = Math.max(CFG.zoomMin, Math.min(CFG.zoomMax, nz));
  const s = mouse.x, t = mouse.y;
  const [wx, wy] = screenToWorld(s, t);
  if (Math.abs(nz - 1) < 1e-6) { view.zoom = nz; }
  else {
    view.x = (nz * wx - (s - innerWidth / 2)) / (nz - 1);
    view.y = (nz * wy - (t - innerHeight / 2)) / (nz - 1);
    view.zoom = nz;
  }
  updateZoomLabel();
}
function updateZoomLabel() { document.getElementById('zoom-label').textContent = Math.round(view.zoom * 100) + '%'; }

/* ============================================================
   5. PORT LOCATIONS
   ============================================================ */
function portLoc(node, kind, i) {
  const t = TYPES[node.type];
  const count = kind === 'in' ? t.inputs : t.outputs;
  const baseY = node.y - CFG.headH / 2 - (count - 1) * CFG.portGap / 2 + CFG.portGap / 2;
  const x = node.x + (kind === 'in' ? -CFG.nodeW / 2 : CFG.nodeW / 2);
  return [x, baseY + i * CFG.portGap];
}
function portsOf(node) {
  const t = TYPES[node.type], res = { in: [], out: [] };
  for (let i = 0; i < t.inputs; i++) res.in.push(portLoc(node, 'in', i));
  for (let i = 0; i < t.outputs; i++) res.out.push(portLoc(node, 'out', i));
  return res;
}

/* ============================================================
   6. WIRE HELPERS
   ============================================================ */
function wiresToInput(toId, port) { return scene.wires.find(w => w.to === toId && w.toPort === port); }
function wireFromOutput(outId, port) { return scene.wires.find(w => w.from === outId && w.fromPort === port); }
function removeWire(w) { scene.wires = scene.wires.filter(x => x !== w); }
function wireAt(toId, port) { return wiresToInput(toId, port); }
function pruneDanglingWires() {
  scene.wires = scene.wires.filter(w => findNode(w.from) && findNode(w.to));
}

/* ---- Cycle rejection: would connecting out->in close a feedback loop? ---- */
function wouldCreateCycle(outId, outPort, inId, inPort) {
  if (outId === inId) return true; // self-loop
  // Signal flows: output port drives its input port; input port drives node's output port(s).
  const adj = new Map(); // key "node#port" -> Set of driven keys
  const addEdge = (a, b) => { if (!adj.has(a)) adj.set(a, new Set()); adj.get(a).add(b); };
  // intrinsic edge: every input port of an output-capable node drives its output port(s)
  for (const n of scene.nodes) {
    if (n && TYPES[n.type] && TYPES[n.type].outputs > 0) {
      for (let i = 0; i < TYPES[n.type].inputs; i++) addEdge(n.id + '.' + i, n.id + '.0');
    }
  }
  for (const w of scene.wires) {
    if (!findNode(w.from) || !findNode(w.to)) continue;
    addEdge(w.from + '.' + w.fromPort, w.to + '.' + w.toPort);      // outPort drives inPort
    const node = findNode(w.to);
    if (node && TYPES[node.type].outputs > 0) addEdge(w.to + '.' + w.toPort, w.to + '.0'); // inPort drives node out
  }
  // proposed edge: outPort drives inPort
  addEdge(outId + '.' + outPort, inId + '.' + inPort);
  // DFS from inPort (head of edge): if we can reach outPort (tail), it's a cycle
  const seen = new Set(), stack = [inId + '.' + inPort];
  while (stack.length) {
    const cur = stack.pop();
    if (cur === outId + '.' + outPort) return true;
    if (seen.has(cur)) continue;
    seen.add(cur);
    const nb = adj.get(cur);
    if (nb) for (const nx of nb) stack.push(nx);
  }
  return false;
}

/* ============================================================
   7. EVALUATION ENGINE  (single topological pass)
   ============================================================ */
function topoOrder() {
  const order = [], vis = new Set(), tmp = new Set();
  const visit = (id) => {
    if (vis.has(id)) return true;
    if (tmp.has(id)) return false; // cycle guard (safety)
    tmp.add(id);
    const node = findNode(id);
    if (node) {
      const t = TYPES[node.type];
      for (let i = 0; i < t.inputs; i++) {
        const w = wiresToInput(id, i);
        if (w && !visit(w.from)) return false;
      }
    }
    tmp.delete(id); vis.add(id); order.push(node);
    return true;
  };
  for (const n of scene.nodes) if (!visit(n.id)) return null;
  return order;
}

function evaluate() {
  const order = topoOrder();
  if (!order) return; // cycle present (shouldn't happen) — don't hang
  for (const n of order) {
    const t = TYPES[n.type];
    if (n.type === 'input') continue;
    if (n.type === 'clock') {
      const w = wiresToInput(n.id, 0);
      n.state = w ? findNode(w.from).state : n.state; // gated clock mirrors input; else timer
      continue;
    }
    const ins = [];
    for (let i = 0; i < t.inputs; i++) {
      const w = wiresToInput(n.id, i);
      if (w) ins.push(findNode(w.from).state); // only wired inputs
    }
    if (n.type === 'lamp') { n.state = ins[0] ? 1 : 0; }
    else if (n.type === 'display') { n.state = ins[0] | (ins[1] << 1) | (ins[2] << 2) | (ins[3] << 3); }
    else { n.state = gateOutput(n.type, ins) ? 1 : 0; }
  }
}

/* ============================================================
   8. CLOCK TIMER
   ============================================================ */
let lastTick = performance.now();
function tickClocks(now) {
  const dt = (now - lastTick) / 1000;
  lastTick = now;
  let changed = false;
  for (const n of scene.nodes) {
    if (n.type !== 'clock') continue;
    const gated = wiresToInput(n.id, 0);
    if (gated) continue; // driven externally
    const period = 1 / n.rate;
    n._acc = (n._acc || 0) + dt;
    if (n._acc >= period) {
      n._acc -= period;
      n.state = n.state ? 0 : 1;
      changed = true;
    }
  }
  if (changed) evaluate();
}

/* ============================================================
   9. RENDERING
   ============================================================ */
function render() {
  const W = innerWidth, H = innerHeight;
  const g = ctx.createLinearGradient(0, 0, 0, H);
  g.addColorStop(0, CFG.bgTop); g.addColorStop(1, CFG.bgBottom);
  ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
  drawGrid(W, H);
  ctx.save();
  const [ox, oy] = worldToScreen(0, 0);
  ctx.translate(ox, oy); ctx.scale(view.zoom, view.zoom);
  drawWires();
  drawPreviewWire();
  drawNodes();
  ctx.restore();
}

function drawGrid(W, H) {
  const step = view.zoom < 0.8 ? 80 : (view.zoom < 1.4 ? 40 : 20);
  const dotR = 1.0;
  const [x0] = screenToWorld(0, 0);
  const [x1] = screenToWorld(W, 0);
  const [, y0] = screenToWorld(0, 0);
  const [, y1] = screenToWorld(0, H);
  ctx.fillStyle = CFG.gridDot;
  const sx0 = Math.floor(x0 / step) * step;
  const sy0 = Math.floor(y0 / step) * step;
  ctx.beginPath();
  for (let gx = sx0; gx <= x1; gx += step) {
    for (let gy = sy0; gy <= y1; gy += step) {
      const [px, py] = worldToScreen(gx, gy);
      ctx.fillRect(px - dotR, py - dotR, dotR * 2, dotR * 2);
    }
  }
}

function drawNodes() {
  for (const n of scene.nodes) {
    const t = TYPES[n.type];
    const w = CFG.nodeW, h = CFG.nodeH, hh = CFG.headH;
    const sel = selected === n.id;
    // card
    ctx.save();
    ctx.shadowColor = sel ? CFG.nodeSel : '#00000060';
    ctx.shadowBlur = sel ? 18 : 8; ctx.shadowOffsetY = sel ? 4 : 2;
    roundRect(n.x - w / 2, n.y - h / 2, w, h, 10);
    ctx.fillStyle = CFG.nodeBody; ctx.fill(); ctx.restore();
    roundRect(n.x - w / 2, n.y - h / 2, w, h, 10);
    ctx.lineWidth = sel ? 2 : 1; ctx.strokeStyle = sel ? CFG.nodeSel : CFG.nodeBorder; ctx.stroke();

    // head
    ctx.beginPath();
    ctx.moveTo(n.x - w / 2, n.y - h / 2 + hh);
    ctx.lineTo(n.x + w / 2, n.y - h / 2 + hh);
    ctx.arc(n.x + w / 2, n.y - h / 2 + hh / 2, hh / 2, -Math.PI / 2, Math.PI / 2, false);
    ctx.lineTo(n.x - w / 2, n.y - h / 2 + hh);
    ctx.arc(n.x - w / 2, n.y - h / 2 + hh / 2, hh / 2, Math.PI / 2, -Math.PI / 2, false);
    ctx.closePath(); ctx.fillStyle = t.head; ctx.fill();

    ctx.fillStyle = '#fff'; ctx.font = '600 13px Segoe UI, sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(t.label, n.x, n.y - h / 2 + hh / 2 + 1);

    // state readout
    ctx.font = '12px Segoe UI, sans-serif'; ctx.fillStyle = CFG.textDim;
    let txt = '';
    if (n.type === 'input') txt = n.state ? 'ON' : 'OFF';
    else if (n.type === 'clock') txt = (n.state ? 'HI ' : 'LO ') + n.rate.toFixed(1) + ' Hz';
    else if (n.type === 'lamp') txt = n.state ? 'ON' : 'off';
    else if (n.type === 'display') txt = n.state.toString(16).toUpperCase();
    else txt = n.state ? '1' : '0';
    ctx.fillText(txt, n.x, n.y + 7);

    // output symbol
    if (t.output) {
      const oc = n.state ? CFG.lampOn : CFG.portIn;
      ctx.beginPath(); ctx.arc(n.x, n.y - hh / 2 - 6, 6, 0, Math.PI * 2);
      if (n.state) { ctx.shadowColor = CFG.lampOn; ctx.shadowBlur = 16; }
      ctx.fillStyle = n.state ? CFG.lampOn : '#2a3a34'; ctx.fill();
      ctx.shadowBlur = 0; ctx.lineWidth = 1.5; ctx.strokeStyle = '#0b0e14'; ctx.stroke();
    }

    drawPorts(n);
  }
}

function drawPorts(n) {
  const ps = portsOf(n), hv = hoveredPort;
  for (let i = 0; i < ps.in.length; i++) {
    const [x, y] = ps.in[i];
    const isH = hv && hv.node === n.id && hv.kind === 'in' && hv.index === i;
    const wired = !!wiresToInput(n.id, i);
    drawPortCircle(x, y, CFG.portR, isH ? CFG.portInHi : CFG.portIn, isH, wired, false);
  }
  for (let i = 0; i < ps.out.length; i++) {
    const [x, y] = ps.out[i];
    const isH = hv && hv.node === n.id && hv.kind === 'out' && hv.index === i;
    const wired = !!wireFromOutput(n.id, i);
    drawPortCircle(x, y, CFG.portR, isH ? CFG.portOutHi : CFG.portOut, isH, wired, true);
  }
}

function drawPortCircle(x, y, r, color, hover, wired, isOut) {
  ctx.beginPath(); ctx.arc(x, y, hover ? r + 2 : r, 0, Math.PI * 2);
  ctx.fillStyle = color;
  ctx.shadowColor = color; ctx.shadowBlur = hover ? 14 : (wired ? 6 : 0);
  ctx.fill(); ctx.shadowBlur = 0;
  ctx.lineWidth = 2; ctx.strokeStyle = '#0b0e14'; ctx.stroke();
  if (wired) { ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fillStyle = '#0b0e14'; ctx.fill(); }
}

function drawWires() {
  for (const w of scene.wires) {
    if (findNode(w.from) && findNode(w.to)) drawBezier(w.from, w.fromPort, w.to, w.toPort, getNodeState(w.from));
  }
}
function getNodeState(id) { const n = findNode(id); return n ? n.state : 0; }

function drawBezier(fromId, fromPort, toId, toPort, on) {
  const fo = portLoc(findNode(fromId), 'out', fromPort);
  const to = portLoc(findNode(toId), 'in', toPort);
  const x1 = fo[0], y1 = fo[1], x2 = to[0], y2 = to[1];
  const kx = (x2 - x1) * CFG.bezierK;
  ctx.beginPath(); ctx.moveTo(x1, y1);
  ctx.bezierCurveTo(x1 + kx, y1, x2 - kx, y2, x2, y2);
  if (on) { ctx.strokeStyle = CFG.wireOn; ctx.lineWidth = 3; ctx.shadowColor = CFG.wireOn; ctx.shadowBlur = 12; }
  else { ctx.strokeStyle = CFG.wireOff; ctx.lineWidth = 2; ctx.shadowBlur = 0; }
  ctx.stroke(); ctx.shadowBlur = 0;
}

function drawPreviewWire() {
  if (!wireState) return;
  if (!findNode(wireState.fromId)) return;
  const fo = portLoc(findNode(wireState.fromId), 'out', wireState.fromPort);
  const x1 = fo[0], y1 = fo[1];
  let x2, y2, bad = false;
  if (snapTarget) {
    x2 = portLoc(findNode(snapTarget.node), 'in', snapTarget.index)[0];
    y2 = portLoc(findNode(snapTarget.node), 'in', snapTarget.index)[1];
    bad = snapTarget.bad;
  } else { x2 = mouse.x; y2 = mouse.y; }
  const kx = (x2 - x1) * CFG.bezierK;
  ctx.beginPath(); ctx.moveTo(x1, y1);
  ctx.bezierCurveTo(x1 + kx, y1, x2 - kx, y2, x2, y2);
  ctx.lineWidth = 2.5;
  if (bad) { ctx.strokeStyle = CFG.wireBad; ctx.shadowColor = CFG.wireBad; ctx.shadowBlur = 16; }
  else { ctx.strokeStyle = snapTarget ? CFG.wireOn : CFG.wirePreview; ctx.shadowColor = snapTarget ? CFG.wireOn : 'transparent'; ctx.shadowBlur = snapTarget ? 12 : 0; }
  ctx.setLineDash([6, 5]); ctx.stroke(); ctx.setLineDash([]); ctx.shadowBlur = 0;
  // highlight origin output
  const op = portLoc(findNode(wireState.fromId), 'out', wireState.fromPort);
  drawPortCircle(op[0], op[1], CFG.portR, CFG.portOutHi, false, true, true);
}

function roundRect(x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

/* ============================================================
   10. HIT TESTING
   ============================================================ */
function nearestPort(world, maxDist) {
  let best = null, bestD = maxDist * maxDist;
  for (const n of scene.nodes) {
    const ps = portsOf(n);
    for (let i = 0; i < ps.in.length; i++) {
      const [x, y] = ps.in[i];
      const d = (x - world[0]) ** 2 + (y - world[1]) ** 2;
      if (d < bestD) { bestD = d; best = { node: n.id, kind: 'in', index: i }; }
    }
    for (let i = 0; i < ps.out.length; i++) {
      const [x, y] = ps.out[i];
      const d = (x - world[0]) ** 2 + (y - world[1]) ** 2;
      if (d < bestD) { bestD = d; best = { node: n.id, kind: 'out', index: i }; }
    }
  }
  return best;
}
function hitNode(world) {
  for (let i = scene.nodes.length - 1; i >= 0; i--) {
    const n = scene.nodes[i];
    if (Math.abs(world[0] - n.x) < CFG.nodeW / 2 && Math.abs(world[1] - n.y) < CFG.nodeH / 2) return n;
  }
  return null;
}
function hitPort(world) {
  const p = nearestPort(world, CFG.portHitR);
  if (!p) return null;
  return { node: findNode(p.node), kind: p.kind, index: p.index, id: p.node };
}

/* ============================================================
   11. INTERACTION STATE
   ============================================================ */
let selected = null;
let hoveredPort = null;
const mouse = { x: 0, y: 0 };
let wireState = null;    // { fromId, fromPort, rewiring, removed }
let dragKind = null;     // 'pan' | 'node' | 'toggle'
let dragMoved = false;
let dragFrom = { x: 0, y: 0 };      // screen start
let nodeStart = { x: 0, y: 0 };     // world start for node drag
let viewStart = { x: 0, y: 0 };     // view start for pan
let toggleTarget = null;
let snapTarget = null;

/* ============================================================
   12. POINTER EVENTS
   ============================================================ */
canvas.addEventListener('pointerdown', (e) => {
  if (e.button !== 0) return;
  hideContextMenu();
  const r = canvas.getBoundingClientRect();
  mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
  const [wx, wy] = screenToWorld(mouse.x, mouse.y);

  dragMoved = false;
  dragFrom = { x: e.clientX, y: e.clientY };

  const port = hitPort([wx, wy]);
  if (port) {
    if (port.kind === 'out') {
      wireState = { fromId: port.id, fromPort: port.index, rewiring: false, removed: null };
      dragKind = 'wire';
      canvas.classList.add('wiring');
    } else {
      const existing = wiresToInput(port.id, port.index);
      if (existing) {
        wireState = { fromId: existing.from, fromPort: existing.fromPort, rewiring: true, removed: existing };
        removeWire(existing);
        dragKind = 'wire';
        canvas.classList.add('wiring');
      } else {
        // free input: just select the node (cannot be a wire source)
        selected = port.id; hoveredPort = null;
      }
    }
    render();
    return;
  }

  const node = hitNode([wx, wy]);
  if (node) {
    selected = node.id;
    if (TYPES[node.type].toggle) { toggleTarget = node; dragKind = 'toggle'; }
    else { dragKind = 'node'; nodeStart = { x: wx, y: wy }; }
    hoveredPort = null;
    render();
    return;
  }

  selected = null; dragKind = 'pan'; viewStart = { x: view.x, y: view.y }; hoveredPort = null;
  render();
});

window.addEventListener('pointermove', (e) => {
  const r = canvas.getBoundingClientRect();
  mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
  const [wx, wy] = screenToWorld(mouse.x, mouse.y);
  const dxs = e.clientX - dragFrom.x, dys = e.clientY - dragFrom.y;
  if (Math.abs(dxs) + Math.abs(dys) > CFG.panThreshold) dragMoved = true;

  if (dragKind === 'wire') { updateWireSnap(); render(); return; }
  if (dragKind === 'node') {
    const [sx, sy] = worldToScreen(nodeStart.x, nodeStart.y);
    const [nx, ny] = screenToWorld(mouse.x + dxs, mouse.y + dys);
    const node = findNode(selected);
    if (node) { node.x += wx - nodeStart.x; node.y += wy - nodeStart.y; nodeStart = { x: wx, y: wy }; render(); save(); }
    return;
  }
  if (dragKind === 'pan') {
    view.x = viewStart.x - dxs / view.zoom;
    view.y = viewStart.y - dys / view.zoom;
    canvas.classList.toggle('grabbing', dragMoved);
    render();
    return;
  }
  // hover (no active drag)
  hoveredPort = hitPort([wx, wy]);
  canvas.style.cursor = hoveredPort ? 'pointer' : 'grab';
  render();
});

window.addEventListener('pointerup', (e) => {
  if (dragKind === 'wire') finishWireDrag();
  else if (dragKind === 'toggle' && !dragMoved) doToggle(toggleTarget);
  if (dragKind === 'pan') canvas.classList.remove('grabbing');
  wireState = null; dragKind = null; snapTarget = null; toggleTarget = null;
  canvas.classList.remove('wiring');
  render(); save();
});

/* ---- Wire snapping ---- */
function updateWireSnap() {
  snapTarget = null;
  const [wx, wy] = screenToWorld(mouse.x, mouse.y);
  const p = nearestPort([wx, wy], CFG.portHitR);
  if (!p || p.kind !== 'in') return;
  const bad = wouldCreateCycle(wireState.fromId, wireState.fromPort, p.node, p.index);
  snapTarget = { node: p.node, index: p.index, bad };
}

function finishWireDrag() {
  if (!wireState) return;
  if (snapTarget && !snapTarget.bad) {
    const { fromId, fromPort } = wireState;
    const existing = wireAt(snapTarget.node, snapTarget.index);
    if (existing) removeWire(existing); // occupied input replaces old wire
    scene.wires.push({ from: fromId, fromPort, to: snapTarget.node, toPort: snapTarget.index });
    evaluate();
  } else if (wireState.rewiring && wireState.removed) {
    // invalid drop — restore the picked-up wire
    if (!scene.wires.some(w => w === wireState.removed)) scene.wires.push(wireState.removed);
  }
}

/* ---- Toggle ---- */
function doToggle(node) {
  if (!node || !TYPES[node.type].toggle) return;
  node.state = node.state ? 0 : 1;
  evaluate();
  save();
}

/* ============================================================
   13. DELETE / ADD
   ============================================================ */
function deleteSelected() {
  if (!selected) return;
  scene.wires = scene.wires.filter(w => w.from !== selected && w.to !== selected);
  scene.nodes = scene.nodes.filter(n => n.id !== selected);
  selected = null; evaluate(); save(); render();
}

function addNode(type, x, y) {
  const n = makeNode(type, x, y);
  scene.nodes.push(n); selected = n.id; evaluate(); save(); render(); return n;
}

/* ============================================================
   14. CONTEXT MENU
   ============================================================ */
const ctxMenu = document.getElementById('context');
const ctxDefs = [
  { type: 'input', label: 'Switch', sw: '#46b8ff' }, { type: 'clock', label: 'Clock', sw: '#ffd24a' },
  { sep: true },
  { type: 'and', label: 'AND', sw: '#a06bff' }, { type: 'or', label: 'OR', sw: '#c05bff' },
  { type: 'not', label: 'NOT', sw: '#e05bff' }, { type: 'xor', label: 'XOR', sw: '#ff6bd0' },
  { type: 'nand', label: 'NAND', sw: '#ff8a4a' }, { type: 'nor', label: 'NOR', sw: '#ffb14a' },
  { sep: true },
  { type: 'lamp', label: 'Lamp', sw: '#4aff9e' }, { type: 'display', label: '7-Segment', sw: '#3fe0c8' },
];
function showContextMenu(sx, sy) {
  ctxMenu.innerHTML = '';
  for (const d of ctxDefs) {
    if (d.sep) { const s = document.createElement('div'); s.className = 'ctx-sep'; ctxMenu.appendChild(s); }
    else {
      const it = document.createElement('div'); it.className = 'ctx-item';
      it.innerHTML = '<span class="sw" style="background:' + d.sw + '"></span>' + d.label;
      it.onclick = () => { const [wx, wy] = screenToWorld(sx, sy); addNode(d.type, wx, wy); hideContextMenu(); };
      ctxMenu.appendChild(it);
    }
  }
  ctxMenu.style.display = 'block';
  ctxMenu.style.left = Math.min(sx, innerWidth - 190) + 'px';
  ctxMenu.style.top = Math.min(sy, innerHeight - 300) + 'px';
}
function hideContextMenu() { ctxMenu.style.display = 'none'; }
document.addEventListener('click', (e) => { if (!ctxMenu.contains(e.target)) hideContextMenu(); });
canvas.addEventListener('contextmenu', (e) => {
  e.preventDefault();
  const r = canvas.getBoundingClientRect();
  showContextMenu(e.clientX - r.left, e.clientY - r.top);
});

/* ============================================================
   15. KEYBOARD + DBLCLICK
   ============================================================ */
window.addEventListener('keydown', (e) => {
  if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
});
canvas.addEventListener('dblclick', (e) => {
  const r = canvas.getBoundingClientRect();
  const [wx, wy] = screenToWorld(e.clientX - r.left, e.clientY - r.top);
  const node = hitNode([wx, wy]);
  if (node && node.type === 'clock') {
    const idx = CFG.clockRates.indexOf(node.rate);
    node.rate = CFG.clockRates[(idx + 1) % CFG.clockRates.length];
    evaluate(); save(); render();
  }
});

/* ============================================================
   16. WHEEL ZOOM (toward cursor)
   ============================================================ */
canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  const dir = e.deltaY < 0 ? 1 : -1;
  const factor = dir > 0 ? CFG.zoomStep : 1 / CFG.zoomStep;
  applyZoom(view.zoom * factor);
  render();
}, { passive: false });

/* ============================================================
   17. TOOLBAR + PALETTE
   ============================================================ */
document.getElementById('btn-fit').onclick = fitToView;
document.getElementById('btn-zin').onclick = () => { applyZoom(view.zoom * CFG.zoomStep); render(); };
document.getElementById('btn-zout').onclick = () => { applyZoom(view.zoom / CFG.zoomStep); render(); };
document.getElementById('btn-clear').onclick = () => {
  if (confirm('Clear the entire circuit?')) { scene = { nodes: [], wires: [] }; selected = null; evaluate(); save(); render(); }
};
document.querySelectorAll('[data-add]').forEach(btn => {
  btn.onclick = () => {
    const [wx, wy] = screenToWorld(innerWidth / 2, innerHeight / 2);
    addNode(btn.dataset.add, wx + (Math.random() - 0.5) * 40, wy + (Math.random() - 0.5) * 40);
  };
});

function fitToView() {
  if (!scene.nodes.length) { view.x = 0; view.y = 0; view.zoom = 1; updateZoomLabel(); render(); return; }
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const n of scene.nodes) {
    minX = Math.min(minX, n.x - CFG.nodeW / 2); maxX = Math.max(maxX, n.x + CFG.nodeW / 2);
    minY = Math.min(minY, n.y - CFG.nodeH / 2); maxY = Math.max(maxY, n.y + CFG.nodeH / 2);
  }
  const pad = 70, w = maxX - minX + pad * 2, h = maxY - minY + pad * 2;
  view.zoom = Math.min(CFG.zoomMax, Math.max(CFG.zoomMin, Math.min(innerWidth / w, innerHeight / h)));
  view.x = (minX + maxX) / 2; view.y = (minY + maxY) / 2;
  updateZoomLabel(); render();
}

/* ============================================================
   18. PERSISTENCE (localStorage)
   ============================================================ */
const STORE_KEY = 'logicNodeGraph.v2';
function save() {
  try {
    const data = {
      nodes: scene.nodes.map(n => ({ type: n.type, x: n.x, y: n.y, state: n.state, rate: n.rate })),
      wires: scene.wires.map(w => ({ from: w.from, fromPort: w.fromPort, to: w.to, toPort: w.toPort })),
      view: { x: view.x, y: view.y, zoom: view.zoom },
    };
    localStorage.setItem(STORE_KEY, JSON.stringify(data));
  } catch (e) {}
}
function load() {
  try {
    const raw = localStorage.getItem(STORE_KEY);
    if (!raw) return false;
    const data = JSON.parse(raw);
    if (!data || !data.nodes) return false;
    scene = {
      nodes: data.nodes.map(n => ({ type: n.type, x: n.x, y: n.y, state: n.state, rate: n.rate || 1 })),
      wires: (data.wires || []).map(w => ({ from: w.from, fromPort: w.fromPort, to: w.to, toPort: w.toPort })),
    };
    pruneDanglingWires();
    if (data.view) { view.x = data.view.x; view.y = data.view.y; view.zoom = data.view.zoom || 1; }
    return true;
  } catch (e) { return false; }
}

/* ============================================================
   19. DEMO + MAIN LOOP
   ============================================================ */
function buildDemo() {
  const a = makeNode('input', -240, -50); a.state = 1;
  const b = makeNode('input', -240, 70);  b.state = 0;
  const clk = makeNode('clock', -240, 190); clk.rate = 1;
  const and = makeNode('and', 0, 20);
  const lamp = makeNode('lamp', 250, 20);
  const disp = makeNode('display', 250, 160);
  scene.nodes.push(a, b, clk, and, lamp, disp);
  scene.wires.push({ from: a.id, fromPort: 0, to: and.id, toPort: 0 });
  scene.wires.push({ from: b.id, fromPort: 0, to: and.id, toPort: 1 });
  scene.wires.push({ from: and.id, fromPort: 0, to: lamp.id, toPort: 0 });
  scene.wires.push({ from: a.id, fromPort: 0, to: disp.id, toPort: 0 });
  scene.wires.push({ from: b.id, fromPort: 0, to: disp.id, toPort: 1 });
  scene.wires.push({ from: clk.id, fromPort: 0, to: disp.id, toPort: 2 });
  scene.wires.push({ from: clk.id, fromPort: 0, to: disp.id, toPort: 3 });
  evaluate();
}

function loop(now) {
  tickClocks(now);
  render();
  requestAnimationFrame(loop);
}

/* ============================================================
   20. INIT
   ============================================================ */
function init() {
  resize();
  if (!load()) buildDemo();
  updateZoomLabel();
  render();
  requestAnimationFrame(loop);
}
init();

/* ============================================================
   DEBUG / TEST HOOKS  (expose internals; harmless in prod)
   ============================================================ */
window.logic = {
  scene, view, CFG, TYPES, gateOutput, evaluate, addNode, doToggle,
  wouldCreateCycle, wiresToInput, wireFromOutput, removeWire, findNode,
  applyZoom, worldToScreen, screenToWorld, portLoc, topoOrder, save, load,
  deleteSelected, mouse,
  hitPort, nearestPort, hitNode, updateWireSnap, finishWireDrag,
  wireState, snapTarget, dragKind, selected, hoveredPort,
};
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"10aac674-508a-42a2-9a4a-e6472c8c13cb","tokensIn":6350818,"tokensOut":95906,"tokensTotal":6446724,"cost":0,"turns":82,"toolCalls":81,"failedToolCalls":5,"timestamp":"2026-08-24T00:26:50.384Z"} -->