← Node Graph Editor results

Node Graph Editor

muse spark 1.2 contributor · full

Initial view of Node Graph Editor

Browser check: Artifact runtime error. This is a technical check, not a quality review.

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 45,190 bytes · SHA-256 a8f9ac001c5d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logic Node Graph — Instrument Panel</title>
<style>
  :root{
    --bg:#080c14;
    --panel:#111827;
    --panel2:#1a2332;
    --border:#1e2e4a;
    --text:#cbd5e1;
    --muted:#64748b;
    --accent:#38bdf8;
    --warm:#f59e0b;
    --cool:#38bdf8;
    --glow-on:#22d3ee;
    --glow-off:#334155;
    --danger:#ef4444;
    --success:#10b981;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{
    font-family: ui-sans-system, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
    background:var(--bg);
    color:var(--text);
    overflow:hidden;
    height:100vh;
    display:flex;
    flex-direction:column;
    user-select:none;
  }
  /* Header */
  header{
    height:48px;
    background:linear-gradient(180deg,#0f172a 0%,#0b1220 100%);
    border-bottom:1px solid var(--border);
    display:flex;align-items:center;justify-content:space-between;
    padding:0 16px;
    flex-shrink:0;
  }
  .brand{display:flex;align-items:center;gap:10px}
  .logo{
    width:32px;height:32px;border-radius:8px;
    background:radial-gradient(120% 120% at 30% 20%,#38bdf8 0%,#0ea5e9 40%,#1e3a5f 100%);
    display:grid;place-items:center;
    box-shadow:0 0 12px rgba(56,189,248,.4), inset 0 1px 0 rgba(255,255,255,.3);
    font-weight:800;color:#fff;font-size:14px;letter-spacing:-.5px;
  }
  .brand h1{font-size:14px;font-weight:700;letter-spacing:.04em}
  .brand p{font-size:11px;color:var(--muted);margin-top:1px}
  .hdr-actions{display:flex;gap:8px;align-items:center}
  .btn{
    background:var(--panel2);border:1px solid var(--border);color:var(--text);
    padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;
    transition:.15s;
  }
  .btn:hover{background:#1e2d4a;border-color:#2a3f66;transform:translateY(-1px)}
  .btn.primary{background:linear-gradient(180deg,#0ea5e9,#0284c7);border-color:#0ea5e9;color:#fff;box-shadow:0 2px 10px rgba(14,165,233,.3)}
  .btn.danger{background:#1a0f14;border-color:#3a1a1a;color:#f87171}
  .btn.small{padding:4px 8px;font-size:11px}
  .zoom-read{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:52px;text-align:center}
  /* Layout */
  .main{flex:1;display:flex;overflow:hidden}
  /* Sidebar */
  .sidebar{
    width:220px;background:linear-gradient(180deg,#0f172a 0%,#0c1426 100%);
    border-right:1px solid var(--border);
    display:flex;flex-direction:column;
    flex-shrink:0;overflow:hidden;
  }
  .side-section{padding:14px 12px;border-bottom:1px solid #0f1e33}
  .side-title{font-size:10px;letter-spacing:.12em;color:var(--muted);font-weight:700;margin-bottom:10px;text-transform:uppercase}
  .palette-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
  .pal-btn{
    background:linear-gradient(180deg,#16233a,#0f1d33);
    border:1px solid #1e3355;border-radius:10px;
    padding:8px 6px;cursor:pointer;text-align:center;
    transition:.15s;
  }
  .pal-btn:hover{border-color:#38bdf8;transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.4)}
  .pal-btn .ico{font-size:16px;line-height:1}
  .pal-btn .lbl{font-size:10px;font-weight:700;margin-top:4px;color:#cbd5e1}
  .pal-btn .sub{font-size:9px;color:var(--muted)}
  .pal-btn.toggle{border-color:#0ea5e933}
  .pal-btn.clock{border-color:#f59e0b33}
  .pal-btn.gate{border-color:#8b5cf633}
  .pal-btn.out{border-color:#10b98133}
  .help{padding:12px;font-size:11px;line-height:1.5;color:var(--muted)}
  .help kbd{background:#1e2d4a;border:1px solid #2a3f66;border-bottom-width:2px;padding:1px 5px;border-radius:4px;font-size:10px;color:#94a3b8}
  .status-bar{
    margin-top:auto;padding:10px 12px;background:#0a1220;border-top:1px solid var(--border);
    display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted)
  }
  .dot{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 6px var(--success)}
  .dot.off{background:var(--muted);box-shadow:none}
  /* Canvas area */
  .canvas-wrap{flex:1;position:relative;overflow:hidden;background:var(--bg)}
  canvas{display:block;width:100%;height:100%;cursor:grab}
  canvas.panning{cursor:grabbing}
  canvas.wiring{cursor:crosshair}
  .overlay{
    position:absolute;inset:0;pointer-events:none;
  }
  .toast{
    position:absolute;top:16px;left:50%;transform:translateX(-50%) translateY(-20px);
    background:#1a0f14;border:1px solid #ef4444;color:#fecaca;
    padding:8px 14px;border-radius:10px;font-size:12px;font-weight:600;
    opacity:0;transition:.3s;pointer-events:none;
    box-shadow:0 8px 24px rgba(239,68,68,.3);
  }
  .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
  .legend{
    position:absolute;bottom:12px;left:12px;
    background:rgba(15,23,42,.85);backdrop-filter:blur(8px);
    border:1px solid var(--border);border-radius:10px;padding:8px 10px;
    font-size:10px;color:var(--muted);line-height:1.6;
    pointer-events:none;
  }
  .legend b{color:var(--text)}
  .zoom-controls{
    position:absolute;bottom:12px;right:12px;display:flex;flex-direction:column;gap:6px;
  }
  .zbtn{
    width:36px;height:36px;border-radius:10px;background:rgba(15,23,42,.9);
    border:1px solid var(--border);color:var(--text);font-size:18px;cursor:pointer;
    display:grid;place-items:center;backdrop-filter:blur(8px);
  }
  .zbtn:hover{background:#1e2d4a}
  /* Node tooltip */
  .hint{
    position:absolute;top:12px;right:12px;
    background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:10px;
    padding:10px 12px;font-size:11px;color:var(--muted);max-width:220px;line-height:1.5;
    pointer-events:none;
  }
  .hint b{color:#e2e8f0}
  /* Responsive */
  @media(max-width:800px){
    .sidebar{width:160px}
    .palette-grid{grid-template-columns:1fr}
  }
</style>
</head>
<body>
<header>
  <div class="brand">
    <div class="logo">◈</div>
    <div>
      <h1>LOGIC FORGE</h1>
      <p>Combinational Node Graph — Live Evaluation</p>
    </div>
  </div>
  <div class="hdr-actions">
    <span class="zoom-read" id="zoomRead">100%</span>
    <button class="btn small" id="btnFit">◎ Fit</button>
    <button class="btn small" id="btnExample">Load Demo</button>
    <button class="btn small danger" id="btnClear">Clear</button>
    <button class="btn primary small" id="btnAddToggle">+ Toggle</button>
  </div>
</header>

<div class="main">
  <aside class="sidebar">
    <div class="side-section">
      <div class="side-title">Inputs</div>
      <div class="palette-grid">
        <div class="pal-btn toggle" data-type="TOGGLE"><div class="ico">◐</div><div class="lbl">TOGGLE</div><div class="sub">Click to flip</div></div>
        <div class="pal-btn clock" data-type="CLOCK"><div class="ico">◷</div><div class="lbl">CLOCK</div><div class="sub">Pulse generator</div></div>
      </div>
    </div>
    <div class="side-section">
      <div class="side-title">Gates</div>
      <div class="palette-grid">
        <div class="pal-btn gate" data-type="AND"><div class="ico">∧</div><div class="lbl">AND</div><div class="sub">A·B</div></div>
        <div class="pal-btn gate" data-type="OR"><div class="ico">∨</div><div class="lbl">OR</div><div class="sub">A+B</div></div>
        <div class="pal-btn gate" data-type="NOT"><div class="ico">¬</div><div class="lbl">NOT</div><div class="sub">¬A</div></div>
        <div class="pal-btn gate" data-type="XOR"><div class="ico">⊕</div><div class="lbl">XOR</div><div class="sub">A⊕B</div></div>
        <div class="pal-btn gate" data-type="NAND"><div class="ico">⊼</div><div class="lbl">NAND</div><div class="sub">¬(A·B)</div></div>
        <div class="pal-btn gate" data-type="NOR"><div class="ico">⊽</div><div class="lbl">NOR</div><div class="sub">¬(A+B)</div></div>
      </div>
    </div>
    <div class="side-section">
      <div class="side-title">Outputs</div>
      <div class="palette-grid">
        <div class="pal-btn out" data-type="LAMP"><div class="ico">●</div><div class="lbl">LAMP</div><div class="sub">Glows on 1</div></div>
        <div class="pal-btn out" data-type="HEX"><div class="ico">⬢</div><div class="lbl">HEX</div><div class="sub">4-bit → 0-F</div></div>
      </div>
    </div>
    <div class="help">
      <div style="font-weight:700;color:#94a3b8;margin-bottom:6px">Controls</div>
      <div><kbd>Drag</kbd> empty space to pan</div>
      <div><kbd>Wheel</kbd> zoom to cursor (50–200%)</div>
      <div><kbd>Drag</kbd> node body to move</div>
      <div><kbd>Drag</kbd> port → port to wire</div>
      <div><kbd>Click</kbd> Toggle / Lamp body</div>
      <div><kbd>Delete</kbd> remove selected</div>
      <div style="margin-top:8px;color:#475569">One wire per input — re-wire replaces. Cycles are blocked (red flash).</div>
    </div>
    <div class="status-bar">
      <div class="dot" id="liveDot"></div>
      <span id="statusText">Live — topological evaluation</span>
    </div>
  </aside>

  <div class="canvas-wrap" id="wrap">
    <canvas id="c"></canvas>
    <div class="toast" id="toast">⚠ Cycle rejected — combinational only</div>
    <div class="legend">
      <div><b style="color:#38bdf8">●</b> Input port &nbsp; <b style="color:#f59e0b">●</b> Output port &nbsp; <b style="color:#22d3ee">━━</b> Energized &nbsp; <b style="color:#334155">━━</b> Low</div>
      <div>Drag from output → input. Drag occupied input to re-route.</div>
    </div>
    <div class="zoom-controls">
      <button class="zbtn" id="zIn">+</button>
      <button class="zbtn" id="zOut">−</button>
    </div>
  </div>
</div>

<script>
// ============================================================
//  LOGIC FORGE — Single-file Node Graph Editor
//  Sections: 1 Constants  2 Data Model  3 Viewport  4 Geometry
//            5 Evaluation  6 Persistence  7 Rendering  8 Interaction  9 Clock & Init
// ============================================================

/* ===== 1. TUNABLE CONSTANTS ===== */
const CFG = {
  // viewport
  ZOOM_MIN: 0.5, ZOOM_MAX: 2.0, ZOOM_STEP: 0.1,
  // visuals
  GRID_SPACING: 24, GRID_DOT_RADIUS: 1.2,
  NODE_W: 110, NODE_H: 64, NODE_RADIUS: 12,
  PORT_R: 7, PORT_HIT_R: 14, PORT_GAP: 8,
  WIRE_WIDTH: 3, WIRE_HIT_TOL: 8,
  COLORS: {
    bg: '#080c14',
    grid: 'rgba(148,163,184,0.12)',
    nodeBg: '#111c2e',
    nodeBgHover: '#162845',
    nodeBorder: '#1e3a5f',
    nodeBorderSel: '#38bdf8',
    text: '#e2e8f0',
    muted: '#64748b',
    inputPort: '#38bdf8', inputPortGlow: 'rgba(56,189,248,0.5)',
    outputPort: '#f59e0b', outputPortGlow: 'rgba(245,158,11,0.5)',
    wireOff: '#334155', wireOn: '#22d3ee', wireOnGlow: 'rgba(34,211,238,0.6)',
    wireCycle: '#ef4444',
    lampOn: '#fde047', lampOff: '#1e293b',
    hexOn: '#22d3ee', hexOff: '#1e293b'
  },
  CLOCK_RATES: [0.5, 1, 2, 4], // Hz
  NODE_TYPES: {
    TOGGLE:{inputs:0,outputs:1,label:'TOGGLE', color:'#0ea5e9'},
    CLOCK:{inputs:0,outputs:1,label:'CLOCK', color:'#f59e0b'},
    AND:{inputs:2,outputs:1,label:'AND'},
    OR:{inputs:2,outputs:1,label:'OR'},
    NOT:{inputs:1,outputs:1,label:'NOT'},
    XOR:{inputs:2,outputs:1,label:'XOR'},
    NAND:{inputs:2,outputs:1,label:'NAND'},
    NOR:{inputs:2,outputs:1,label:'NOR'},
    LAMP:{inputs:1,outputs:0,label:'LAMP'},
    HEX:{inputs:4,outputs:0,label:'HEX'}
  }
};

/* ===== 2. DATA MODEL ===== */
let nodes = []; // {id,type,x,y,state,clockRate,clockOn}
let wires = []; // {id, from, fromPort, to, toPort}
let nextId = 1;
function uid(){ return 'n'+(nextId++); }
function wid(){ return 'w'+(nextId++); }

// node output cache after evaluation: Map id -> boolean or boolean[]
let nodeOut = new Map();
let nodeInVals = new Map(); // id -> array of input values (bool or null)

/* ===== 3. VIEWPORT ===== */
const viewport = { x: 0, y: 0, scale: 1 };
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('wrap');

function resizeCanvas(){
  const dpr = window.devicePixelRatio || 1;
  const rect = wrap.getBoundingClientRect();
  if(rect.width===0||rect.height===0) return;
  canvas.width = Math.round(rect.width * dpr);
  canvas.height = Math.round(rect.height * dpr);
  canvas.style.width = rect.width+'px';
  canvas.style.height = rect.height+'px';
  ctx.setTransform(dpr,0,0,dpr,0,0);
  render();
}
window.addEventListener('resize', resizeCanvas);

function screenToWorld(sx, sy){
  const rect = canvas.getBoundingClientRect();
  const x = sx - rect.left;
  const y = sy - rect.top;
  return { x: (x - viewport.x)/viewport.scale, y: (y - viewport.y)/viewport.scale };
}
function worldToScreen(wx, wy){
  return { x: wx*viewport.scale + viewport.x, y: wy*viewport.scale + viewport.y };
}
function setZoom(newScale, cx, cy){
  newScale = Math.max(CFG.ZOOM_MIN, Math.min(CFG.ZOOM_MAX, newScale));
  // zoom to cursor: keep world point under cursor fixed
  const rect = canvas.getBoundingClientRect();
  const sx = cx - rect.left, sy = cy - rect.top;
  const wx = (sx - viewport.x)/viewport.scale;
  const wy = (sy - viewport.y)/viewport.scale;
  viewport.scale = newScale;
  viewport.x = sx - wx*newScale;
  viewport.y = sy - wy*newScale;
  document.getElementById('zoomRead').textContent = Math.round(viewport.scale*100)+'%';
  render();
}

/* ===== 4. GEOMETRY & HIT TESTING ===== */
function getPortPos(node, isInput, idx){
  const totalIn = CFG.NODE_TYPES[node.type].inputs;
  const totalOut = CFG.NODE_TYPES[node.type].outputs;
  const w = CFG.NODE_W, h = CFG.NODE_H;
  if(isInput){
    const spacing = h / (totalIn + 1);
    return { x: node.x, y: node.y + spacing*(idx+1) };
  } else {
    const spacing = h / (totalOut + 1);
    return { x: node.x + w, y: node.y + spacing*(idx+1) };
  }
}
function hitPort(world){
  for(const n of nodes){
    const ti = CFG.NODE_TYPES[n.type];
    for(let i=0;i<ti.inputs;i++){
      const p = getPortPos(n,true,i);
      const dx = world.x - p.x, dy = world.y - p.y;
      if(Math.hypot(dx,dy) <= CFG.PORT_HIT_R / viewport.scale) return {node:n, isInput:true, idx:i, pos:p};
    }
    for(let i=0;i<ti.outputs;i++){
      const p = getPortPos(n,false,i);
      const dx = world.x - p.x, dy = world.y - p.y;
      if(Math.hypot(dx,dy) <= CFG.PORT_HIT_R / viewport.scale) return {node:n, isInput:false, idx:i, pos:p};
    }
  }
  return null;
}
function hitNode(world){
  // topmost first
  for(let i=nodes.length-1;i>=0;i--){
    const n = nodes[i];
    if(world.x>=n.x && world.x<=n.x+CFG.NODE_W && world.y>=n.y && world.y<=n.y+CFG.NODE_H) return n;
  }
  return null;
}
function hitWire(world){
  const tol = CFG.WIRE_HIT_TOL / viewport.scale;
  for(const w of wires){
    const a = nodes.find(n=>n.id===w.from), b = nodes.find(n=>n.id===w.to);
    if(!a||!b) continue;
    const p0 = getPortPos(a,false,w.fromPort);
    const p1 = getPortPos(b,true,w.toPort);
    // sample bezier
    let closest = Infinity;
    for(let t=0;t<=1;t+=0.05){
      const x = bezier(p0.x,p1.x,t), y = bezierY(p0.y,p1.y,t);
      const d = Math.hypot(world.x-x, world.y-y);
      if(d<closest) closest=d;
    }
    if(closest<=tol) return w;
  }
  return null;
}
function bezier(x0,x1,t){
  const dx = Math.abs(x1-x0)*0.5 + 40;
  const c0x = x0+dx, c1x = x1-dx;
  const mt=1-t;
  return mt*mt*mt*x0 + 3*mt*mt*t*c0x + 3*mt*t*t*c1x + t*t*t*x1;
}
function bezierY(y0,y1,t){
  // cubic with same x control but y constant
  const mt=1-t;
  return mt*mt*mt*y0 + 3*mt*mt*t*y0 + 3*mt*t*t*y1 + t*t*t*y1;
}

/* ===== 5. EVALUATION ENGINE ===== */
function evaluate(){
  // Build adjacency for topological sort
  // Map nodeId -> outputs
  nodeOut.clear(); nodeInVals.clear();
  // init input vals as null
  for(const n of nodes){
    const ti = CFG.NODE_TYPES[n.type];
    nodeInVals.set(n.id, Array(ti.inputs).fill(null));
  }
  // Build graph edges
  const adj = new Map(); // from -> [to]
  const indeg = new Map();
  for(const n of nodes){ adj.set(n.id,[]); indeg.set(n.id,0); }
  for(const w of wires){
    if(!adj.has(w.from) || !adj.has(w.to)) continue;
    adj.get(w.from).push(w.to);
    indeg.set(w.to, indeg.get(w.to)+1);
  }
  // Kahn topological order
  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 nb of adj.get(id)){
      indeg.set(nb, indeg.get(nb)-1);
      if(indeg.get(nb)===0) q.push(nb);
    }
  }
  // If cycle exists, order will be incomplete — but we prevent cycles on wire creation
  // Evaluate in order
  // First, collect wire-driven input values iteratively as we go? Need to propagate.
  // We'll maintain a map wireValue: for each wire, value = nodeOut[from][fromPort]
  // Instead, as we evaluate nodes in order, we fill downstream input slots.

  // Helper to get node output value(s)
  function computeNode(n, inputs){
    switch(n.type){
      case 'TOGGLE': return !!n.state;
      case 'CLOCK': return !!n.state;
      case 'AND': return !!(inputs[0] && inputs[1]);
      case 'OR': return !!(inputs[0] || inputs[1]);
      case 'NOT': return !inputs[0];
      case 'XOR': return !!(inputs[0] ^ inputs[1]);
      case 'NAND': return !(inputs[0] && inputs[1]);
      case 'NOR': return !(inputs[0] || inputs[1]);
      case 'LAMP': return !!inputs[0];
      case 'HEX': return inputs.slice(0,4); // array of 4 bools
      default: return false;
    }
  }

  // We need to propagate wires: after evaluating a node, push its output to target inputs
  // For simplicity, do a pass: for each node in order, gather its inputs from wires that feed it (using already computed sources)
  const outVals = new Map(); // id -> value (bool or array)
  for(const id of order){
    const n = nodes.find(x=>x.id===id);
    if(!n) continue;
    const ti = CFG.NODE_TYPES[n.type];
    let ins = [];
    if(ti.inputs>0){
      // collect from wires
      const curIns = nodeInVals.get(id).slice();
      // For each wire to this node, fill slot with source output
      for(const w of wires){
        if(w.to===id){
          const srcVal = outVals.get(w.from);
          // srcVal is boolean for most; for nodes with single output it's bool
          // handle array? only HEX has no output, so ignore
          let v = srcVal;
          if(Array.isArray(v)) v = v[0]; // shouldn't happen
          // treat null/undefined as false (unconnected = 0)
          if(v===undefined) v=false;
          curIns[w.toPort]=!!v;
        }
      }
      // unconnected inputs default to false
      for(let i=0;i<curIns.length;i++) if(curIns[i]===null) curIns[i]=false;
      nodeInVals.set(id, curIns);
      ins = curIns;
    }
    const out = computeNode(n, ins);
    outVals.set(id, out);
  }
  // For nodes not in order (if cycle somehow), keep old
  nodeOut = outVals;
  // Update lamp/hex visual state
  for(const n of nodes){
    if(n.type==='LAMP' || n.type==='HEX'){
      n._eval = outVals.get(n.id);
    } else if(CFG.NODE_TYPES[n.type].outputs>0){
      n._outVal = outVals.get(n.id);
    }
  }
}

function wouldCreateCycle(fromId, toId){
  if(fromId===toId) return true;
  // Build adjacency including proposed edge
  const adj = new Map();
  for(const n of nodes) adj.set(n.id, []);
  for(const w of wires) adj.get(w.from).push(w.to);
  adj.get(fromId).push(toId);
  // DFS from toId to see if can reach fromId
  const visited = new Set();
  const stack = [toId];
  while(stack.length){
    const cur = stack.pop();
    if(cur===fromId) return true;
    if(visited.has(cur)) continue;
    visited.add(cur);
    for(const nb of (adj.get(cur)||[])) if(!visited.has(nb)) stack.push(nb);
  }
  return false;
}

/* ===== 6. PERSISTENCE ===== */
const LS_KEY = 'logicForge_v7';
function serialize(){
  return {
    nodes: nodes.map(n=>({id:n.id,type:n.type,x:n.x,y:n.y,state:n.state,clockRate:n.clockRate})),
    wires: wires.map(w=>({id:w.id,from:w.from,fromPort:w.fromPort,to:w.to,toPort:w.toPort})),
    viewport:{...viewport},
    nextId
  };
}
function save(){
  localStorage.setItem(LS_KEY, JSON.stringify(serialize()));
}
function load(){
  try{
    const raw = localStorage.getItem(LS_KEY);
    if(!raw) return false;
    const data = JSON.parse(raw);
    nodes = data.nodes || [];
    wires = data.wires || [];
    nextId = data.nextId || (nodes.length+10);
    if(data.viewport){ viewport.x=data.viewport.x; viewport.y=data.viewport.y; viewport.scale=data.viewport.scale; }
    // ensure defaults
    for(const n of nodes){
      if(n.type==='CLOCK' && !n.clockRate) n.clockRate=1;
      if(n.state===undefined) n.state=false;
    }
    document.getElementById('zoomRead').textContent = Math.round(viewport.scale*100)+'%';
    return true;
  }catch(e){ return false; }
}

/* ===== 7. RENDERING ===== */
let hoverPort = null;
let selectedId = null;
let dragState = null; // {type:'pan'|'node'|'wire', ...}
let wirePreview = null; // {from, fromPort, x,y, targetPort}
let cycleFlash = null; // {wire, time}

function drawGrid(){
  const spacing = CFG.GRID_SPACING * viewport.scale;
  const dotR = CFG.GRID_DOT_RADIUS * Math.min(1.5, viewport.scale);
  const offX = ((viewport.x % spacing)+spacing)%spacing;
  const offY = ((viewport.y % spacing)+spacing)%spacing;
  ctx.fillStyle = CFG.COLORS.grid;
  const w = canvas.getBoundingClientRect().width;
  const h = canvas.getBoundingClientRect().height;
  for(let x=offX; x<w; x+=spacing){
    for(let y=offY; y<h; y+=spacing){
      ctx.beginPath();
      ctx.arc(x,y,dotR,0,Math.PI*2);
      ctx.fill();
    }
  }
}

function drawWireCurve(p0,p1, energized, isFlash){
  const dx = Math.abs(p1.x-p0.x)*0.5 + 40;
  const c0x = p0.x+dx, c1x = p1.x-dx;
  ctx.beginPath();
  ctx.moveTo(p0.x,p0.y);
  ctx.bezierCurveTo(c0x,p0.y,c1x,p1.y,p1.x,p1.y);
  if(isFlash){
    ctx.strokeStyle = CFG.COLORS.wireCycle;
    ctx.lineWidth = CFG.WIRE_WIDTH+1;
    ctx.shadowColor = 'rgba(239,68,68,0.6)';
    ctx.shadowBlur = 12;
  } else if(energized){
    ctx.strokeStyle = CFG.COLORS.wireOn;
    ctx.lineWidth = CFG.WIRE_WIDTH;
    ctx.shadowColor = CFG.COLORS.wireOnGlow;
    ctx.shadowBlur = 10;
  } else {
    ctx.strokeStyle = CFG.COLORS.wireOff;
    ctx.lineWidth = CFG.WIRE_WIDTH;
    ctx.shadowBlur = 0;
  }
  ctx.lineCap='round';
  ctx.stroke();
  ctx.shadowBlur=0;
  // inner highlight for energized
  if(energized && !isFlash){
    ctx.beginPath();
    ctx.moveTo(p0.x,p0.y);
    ctx.bezierCurveTo(c0x,p0.y,c1x,p1.y,p1.x,p1.y);
    ctx.strokeStyle='rgba(255,255,255,0.25)';
    ctx.lineWidth=1;
    ctx.stroke();
  }
}

function drawNode(n){
  const isSelected = n.id===selectedId;
  const isHover = hoverNodeId===n.id;
  const ti = CFG.NODE_TYPES[n.type];
  const sPos = worldToScreen(n.x,n.y);
  const w = CFG.NODE_W*viewport.scale, h = CFG.NODE_H*viewport.scale;
  const r = CFG.NODE_RADIUS*viewport.scale;

  // shadow
  ctx.save();
  ctx.shadowColor='rgba(0,0,0,0.35)';
  ctx.shadowBlur=8*viewport.scale;
  ctx.shadowOffsetY=2*viewport.scale;
  // card bg
  const bg = isSelected? '#162845' : (isHover? CFG.COLORS.nodeBgHover : CFG.COLORS.nodeBg);
  ctx.fillStyle = bg;
  ctx.strokeStyle = isSelected? CFG.COLORS.nodeBorderSel : CFG.COLORS.nodeBorder;
  ctx.lineWidth = isSelected? 2 : 1.2;
  roundRect(sPos.x,sPos.y,w,h,r);
  ctx.fill();
  ctx.stroke();
  ctx.restore();

  // top accent line — thin clipped strip
  ctx.save();
  ctx.beginPath();
  // top edge only: rounded top corners, flat bottom
  const aw = 2.5*viewport.scale;
  ctx.moveTo(sPos.x+r, sPos.y);
  ctx.arcTo(sPos.x+w, sPos.y, sPos.x+w, sPos.y+aw, r);
  ctx.lineTo(sPos.x+w, sPos.y+aw);
  ctx.lineTo(sPos.x, sPos.y+aw);
  ctx.arcTo(sPos.x, sPos.y, sPos.x+r, sPos.y, r);
  ctx.closePath();
  ctx.fillStyle = ti.color || (n._outVal||n.state||n._eval ? '#22d3ee' : '#334155');
  ctx.fill();
  ctx.restore();

  // label
  ctx.fillStyle = CFG.COLORS.text;
  ctx.font = `700 ${10*viewport.scale}px ui-sans-system, sans-serif`;
  ctx.textAlign='center';
  ctx.fillText(ti.label, sPos.x+w/2, sPos.y+18*viewport.scale);

  // type-specific body
  const cx = sPos.x+w/2, cy = sPos.y+h/2+6*viewport.scale;
  if(n.type==='TOGGLE'){
    const on = !!n.state;
    // switch track
    const swW=36*viewport.scale, swH=18*viewport.scale;
    const sx=cx-swW/2, sy=cy-swH/2;
    ctx.fillStyle = on? 'rgba(34,211,238,0.25)' : 'rgba(51,65,85,0.8)';
    ctx.strokeStyle = on? '#22d3ee' : '#334155';
    ctx.lineWidth=1.2;
    roundRect(sx,sy,swW,swH,swH/2);
    ctx.fill(); ctx.stroke();
    // knob
    const kx = on? sx+swW-swH/2-2*viewport.scale : sx+swH/2+2*viewport.scale;
    ctx.beginPath();
    ctx.arc(kx,sy+swH/2,7*viewport.scale,0,Math.PI*2);
    ctx.fillStyle = on? '#22d3ee' : '#94a3b8';
    ctx.shadowColor = on? 'rgba(34,211,238,0.6)':'transparent';
    ctx.shadowBlur=8;
    ctx.fill();
    ctx.shadowBlur=0;
    ctx.fillStyle = on? '#fff':'#0f172a';
    ctx.font=`700 ${8*viewport.scale}px sans-serif`;
    ctx.fillText(on?'ON':'OFF', cx, sy+swH+10*viewport.scale);
  } else if(n.type==='CLOCK'){
    const on = !!n.state;
    ctx.beginPath();
    ctx.arc(cx,cy-2*viewport.scale,14*viewport.scale,0,Math.PI*2);
    ctx.fillStyle = on? 'rgba(245,158,11,0.2)' : 'rgba(30,41,59,0.8)';
    ctx.strokeStyle = on? '#f59e0b' : '#334155';
    ctx.lineWidth=1.5;
    ctx.fill(); ctx.stroke();
    // pulse indicator
    ctx.fillStyle = on? '#f59e0b' : '#475569';
    ctx.beginPath();
    ctx.arc(cx,cy-2*viewport.scale,5*viewport.scale,0,Math.PI*2);
    ctx.fill();
    if(on){ ctx.shadowColor='#f59e0b'; ctx.shadowBlur=10; ctx.fill(); ctx.shadowBlur=0; }
    ctx.fillStyle=CFG.COLORS.muted;
    ctx.font=`600 ${8*viewport.scale}px sans-serif`;
    ctx.fillText(n.clockRate+' Hz', cx, cy+16*viewport.scale);
  } else if(n.type==='LAMP'){
    const on = !!n._eval;
    ctx.beginPath();
    ctx.arc(cx,cy,16*viewport.scale,0,Math.PI*2);
    ctx.fillStyle = on? '#fde047' : CFG.COLORS.lampOff;
    ctx.strokeStyle = on? '#facc15' : '#334155';
    ctx.lineWidth=1.5;
    ctx.fill(); ctx.stroke();
    if(on){
      ctx.shadowColor='#fde047'; ctx.shadowBlur=18;
      ctx.fill(); ctx.shadowBlur=0;
      ctx.beginPath(); ctx.arc(cx-4*viewport.scale,cy-4*viewport.scale,4*viewport.scale,0,Math.PI*2);
      ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.fill();
    }
    ctx.fillStyle = on? '#a16207' : '#475569';
    ctx.font=`700 ${7*viewport.scale}px sans-serif`;
    ctx.textAlign='center';
    ctx.fillText(on?'ON':'OFF', cx, cy+26*viewport.scale);
  } else if(n.type==='HEX'){
    const bits = Array.isArray(n._eval)? n._eval : [false,false,false,false];
    let val=0; for(let i=0;i<4;i++) if(bits[i]) val|=(1<<i);
    const hex = val.toString(16).toUpperCase();
    // 7-seg style
    ctx.fillStyle='#0f172a';
    roundRect(cx-22*viewport.scale, cy-14*viewport.scale,44*viewport.scale,28*viewport.scale,6*viewport.scale);
    ctx.fill();
    ctx.strokeStyle='#1e293b'; ctx.lineWidth=1; ctx.stroke();
    ctx.fillStyle = '#22d3ee';
    ctx.shadowColor='rgba(34,211,238,0.5)'; ctx.shadowBlur=6;
    ctx.font=`700 ${18*viewport.scale}px ui-monospace, monospace`;
    ctx.fillText(hex, cx, cy+6*viewport.scale);
    ctx.shadowBlur=0;
    ctx.fillStyle=CFG.COLORS.muted;
    ctx.font=`600 ${7*viewport.scale}px sans-serif`;
    let bin=''; for(let i=3;i>=0;i--) bin+=(bits[i]?'1':'0');
    ctx.fillText(bin, cx, cy+22*viewport.scale);
  } else {
    // gate symbol
    ctx.fillStyle = (n._outVal? '#22d3ee' : '#475569');
    ctx.font=`700 ${13*viewport.scale}px sans-serif`;
    const sym = {AND:'&',OR:'≥1',NOT:'1',XOR:'=1',NAND:'&○',NOR:'≥1○'}[n.type]||n.type;
    ctx.fillText(sym, cx, cy+4*viewport.scale);
    // small dot for output state
    ctx.beginPath();
    ctx.arc(sPos.x+w-10*viewport.scale, sPos.y+10*viewport.scale,4*viewport.scale,0,Math.PI*2);
    ctx.fillStyle = n._outVal? '#22d3ee':'#334155';
    if(n._outVal){ctx.shadowColor='#22d3ee';ctx.shadowBlur=8;ctx.fill();ctx.shadowBlur=0;} else ctx.fill();
  }

  // ports
  const portScale = viewport.scale;
  const ti2 = CFG.NODE_TYPES[n.type];
  for(let i=0;i<ti2.inputs;i++){
    const p = worldToScreen(...Object.values(getPortPos(n,true,i)));
    const hovered = hoverPort && hoverPort.node.id===n.id && hoverPort.isInput && hoverPort.idx===i;
    const hasWire = wires.some(w=>w.to===n.id && w.toPort===i);
    const energized = (()=>{ const v=nodeInVals.get(n.id); return v? !!v[i] : false; })();
    drawPort(p.x,p.y,true,hovered,energized,hasWire,portScale);
  }
  for(let i=0;i<ti2.outputs;i++){
    const p = worldToScreen(...Object.values(getPortPos(n,false,i)));
    const hovered = hoverPort && hoverPort.node.id===n.id && !hoverPort.isInput && hoverPort.idx===i;
    let energized = !!n._outVal || !!n.state;
    if(n.type==='TOGGLE'||n.type==='CLOCK') energized = !!n.state;
    drawPort(p.x,p.y,false,hovered,energized,false,portScale);
  }
}

function drawPort(x,y,isInput,hovered,energized,occupied,scale){
  const r = (hovered? 9 : CFG.PORT_R)*scale;
  ctx.beginPath();
  ctx.arc(x,y,r,0,Math.PI*2);
  const base = isInput? CFG.COLORS.inputPort : CFG.COLORS.outputPort;
  ctx.fillStyle = energized? base : '#1e293b';
  ctx.strokeStyle = energized? base : '#334155';
  ctx.lineWidth = hovered? 2.5 : 1.5;
  ctx.fill();
  ctx.stroke();
  if(energized){
    ctx.shadowColor = isInput? CFG.COLORS.inputPortGlow : CFG.COLORS.outputPortGlow;
    ctx.shadowBlur=10; ctx.fill(); ctx.shadowBlur=0;
  }
  // inner dot
  ctx.beginPath();
  ctx.arc(x,y,3*scale,0,Math.PI*2);
  ctx.fillStyle = energized? '#fff' : (occupied? '#475569' : '#0f172a');
  ctx.fill();
  if(hovered){
    ctx.beginPath(); ctx.arc(x,y,(r+4*scale),0,Math.PI*2);
    ctx.strokeStyle='rgba(56,189,248,0.25)'; ctx.lineWidth=1; ctx.stroke();
  }
}

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

let hoverNodeId = null;

function render(){
  const rect = canvas.getBoundingClientRect();
  const w = rect.width, h = rect.height;
  // clear
  ctx.clearRect(0,0,w,h);
  ctx.fillStyle = CFG.COLORS.bg;
  ctx.fillRect(0,0,w,h);
  drawGrid();

  // wires
  for(const wire of wires){
    const a = nodes.find(n=>n.id===wire.from), b = nodes.find(n=>n.id===wire.to);
    if(!a||!b) continue;
    const p0 = worldToScreen(...Object.values(getPortPos(a,false,wire.fromPort)));
    const p1 = worldToScreen(...Object.values(getPortPos(b,true,wire.toPort)));
    const energized = !!nodeOut.get(a.id);
    // handle array? not needed
    const isFlash = cycleFlash && cycleFlash.id===wire.id && Date.now()-cycleFlash.time<600;
    drawWireCurve(p0,p1,energized,isFlash);
  }
  // preview wire
  if(wirePreview){
    const p0 = worldToScreen(...Object.values(getPortPos(wirePreview.fromNode,false,wirePreview.fromPort)));
    const p1 = worldToScreen(wirePreview.x, wirePreview.y);
    // if hovering valid target, snap
    let target = hoverPort && hoverPort.isInput ? hoverPort : null;
    let end = target ? worldToScreen(...Object.values(target.pos)) : p1;
    const isValid = target && canConnect(wirePreview.fromNode.id, wirePreview.fromPort, target.node.id, target.idx);
    const isCycle = target && wouldCreateCycle(wirePreview.fromNode.id, target.node.id);
    if(target){
      if(isCycle){
        drawWireCurve(p0,end,false,true);
      } else if(isValid){
        drawWireCurve(p0,end,true,false);
        // highlight target port done in node draw
      } else {
        drawWireCurve(p0,end,false,false);
      }
    } else {
      // dashed preview
      ctx.save();
      ctx.setLineDash([6,6]);
      drawWireCurve(p0,p1,false,false);
      ctx.restore();
    }
  }

  // nodes (sorted by y for slight depth? keep order)
  for(const n of nodes) drawNode(n);

  // selection rect hint
  if(selectedId){
    // already highlighted via border
  }
}

function canConnect(fromId, fromPort, toId, toPort){
  if(fromId===toId) return false;
  const fromNode = nodes.find(n=>n.id===fromId);
  const toNode = nodes.find(n=>n.id===toId);
  if(!fromNode||!toNode) return false;
  // to port must be input, from must be output — caller ensures
  // one wire per input: allowed (will replace)
  return true;
}

/* ===== 8. INTERACTION ===== */
let isPanning=false, panStart={x:0,y:0}, vpStart={x:0,y:0};
let draggedNode=null, dragOffset={x:0,y:0};

canvas.addEventListener('mousedown', e=>{
  const world = screenToWorld(e.clientX,e.clientY);
  const port = hitPort(world);
  if(port){
    // start wiring
    if(!port.isInput){
      // from output
      wirePreview = {fromNode:port.node, fromPort:port.idx, x:world.x, y:world.y};
      dragState={type:'wire'};
      canvas.classList.add('wiring');
    } else {
      // from input — pick up existing wire if any
      const existing = wires.find(w=>w.to===port.node.id && w.toPort===port.idx);
      if(existing){
        const src = nodes.find(n=>n.id===existing.from);
        // remove old wire, start preview from its source
        wires.splice(wires.indexOf(existing),1);
        evaluate(); save(); render();
        wirePreview = {fromNode:src, fromPort:existing.fromPort, x:world.x, y:world.y};
        dragState={type:'wire'};
        canvas.classList.add('wiring');
      } else {
        // cannot start from input without wire — ignore
        return;
      }
    }
    e.preventDefault();
    return;
  }
  const node = hitNode(world);
  if(node){
    selectedId = node.id;
    // check if click on toggle/clock body to flip
    if(node.type==='TOGGLE' || node.type==='CLOCK'){
      // will handle on mouseup if not dragged
    }
    draggedNode=node;
    dragOffset={x:world.x-node.x, y:world.y-node.y};
    dragState={type:'node', moved:false};
    render();
    return;
  }
  // pan
  isPanning=true;
  panStart={x:e.clientX,y:e.clientY};
  vpStart={x:viewport.x,y:viewport.y};
  canvas.classList.add('panning');
  selectedId=null;
  render();
});

canvas.addEventListener('mousemove', e=>{
  const world = screenToWorld(e.clientX,e.clientY);
  // hover detection
  const prevHover = hoverPort;
  hoverPort = hitPort(world);
  const prevHoverNode = hoverNodeId;
  hoverNodeId = hitNode(world)?.id || null;
  if(hoverPort!==prevHover || hoverNodeId!==prevHoverNode) render();

  if(wirePreview){
    wirePreview.x=world.x; wirePreview.y=world.y;
    render();
    return;
  }
  if(draggedNode && dragState?.type==='node'){
    const dx = world.x - dragOffset.x - draggedNode.x;
    const dy = world.y - dragOffset.y - draggedNode.y;
    if(Math.hypot(dx,dy)>1) dragState.moved=true;
    draggedNode.x = world.x - dragOffset.x;
    draggedNode.y = world.y - dragOffset.y;
    render();
    return;
  }
  if(isPanning){
    viewport.x = vpStart.x + (e.clientX - panStart.x);
    viewport.y = vpStart.y + (e.clientY - panStart.y);
    render();
  }
});

canvas.addEventListener('mouseup', e=>{
  const world = screenToWorld(e.clientX,e.clientY);
  if(wirePreview){
    const target = hitPort(world);
    if(target && target.isInput){
      const fromId = wirePreview.fromNode.id, toId = target.node.id;
      if(wouldCreateCycle(fromId,toId)){
        // flash
        showToast('Cycle rejected — combinational only');
        // red flash on preview
        cycleFlash={id:'preview',time:Date.now()};
        // also flash existing wires if any? just toast
        setTimeout(render, 650);
      } else {
        // remove existing wire on that input
        const idx = wires.findIndex(w=>w.to===toId && w.toPort===target.idx);
        if(idx!==-1) wires.splice(idx,1);
        wires.push({id:wid(), from:fromId, fromPort:wirePreview.fromPort, to:toId, toPort:target.idx});
        evaluate(); save();
      }
    }
    wirePreview=null;
    dragState=null;
    canvas.classList.remove('wiring');
    render();
    return;
  }
  if(draggedNode){
    if(!dragState?.moved){
      // click — toggle if applicable
      if(draggedNode.type==='TOGGLE'){
        draggedNode.state = !draggedNode.state;
        evaluate(); save();
      } else if(draggedNode.type==='CLOCK'){
        // cycle rate on click? click toggles pause? We'll make click cycles rate
        // Actually click toggles enable? We'll make it cycle rate
        const rates = CFG.CLOCK_RATES;
        let idx = rates.indexOf(draggedNode.clockRate);
        // if ctrl pressed, toggle pause? simple: shift+click pauses
        if(e.shiftKey){
          draggedNode.paused = !draggedNode.paused;
        } else {
          idx = (idx+1)%rates.length;
          draggedNode.clockRate = rates[idx];
        }
        save();
      } else if(draggedNode.type==='LAMP' || draggedNode.type==='HEX'){
        // no action
      } else {
        // select only
      }
      render();
    } else {
      save();
    }
    draggedNode=null;
    dragState=null;
    render();
    return;
  }
  if(isPanning){
    isPanning=false;
    canvas.classList.remove('panning');
    save();
  }
});

canvas.addEventListener('wheel', e=>{
  e.preventDefault();
  const delta = -e.deltaY;
  const factor = delta>0? 1.08 : 0.92;
  setZoom(viewport.scale*factor, e.clientX, e.clientY);
  save();
},{passive:false});

canvas.addEventListener('dblclick', e=>{
  const world = screenToWorld(e.clientX,e.clientY);
  const n = hitNode(world);
  if(n && n.type==='CLOCK'){
    const rates = CFG.CLOCK_RATES;
    let idx = rates.indexOf(n.clockRate);
    idx=(idx+1)%rates.length;
    n.clockRate=rates[idx];
    save(); render();
  }
});

// Delete
window.addEventListener('keydown', e=>{
  if((e.key==='Delete' || e.key==='Backspace') && selectedId){
    // avoid deleting while typing? no input
    const idx = nodes.findIndex(n=>n.id===selectedId);
    if(idx!==-1){
      const id = nodes[idx].id;
      nodes.splice(idx,1);
      wires = wires.filter(w=>w.from!==id && w.to!==id);
      selectedId=null;
      evaluate(); save(); render();
    }
  }
  if(e.key==='Escape'){
    wirePreview=null; dragState=null; canvas.classList.remove('wiring'); render();
  }
});

// Palette
document.querySelectorAll('.pal-btn').forEach(btn=>{
  btn.addEventListener('click', ()=>{
    const type = btn.dataset.type;
    addNode(type);
  });
});
function addNode(type){
  const center = screenToWorld(window.innerWidth/2, window.innerHeight/2);
  // offset a bit random
  const x = center.x - CFG.NODE_W/2 + (Math.random()*40-20);
  const y = center.y - CFG.NODE_H/2 + (Math.random()*40-20);
  const n = {id:uid(), type, x, y, state:false};
  if(type==='CLOCK'){ n.clockRate=1; n.state=false; }
  if(type==='TOGGLE'){ n.state=false; }
  nodes.push(n);
  selectedId=n.id;
  evaluate(); save(); render();
  // slight pan to show?
}

// Header buttons
document.getElementById('btnClear').addEventListener('click', ()=>{
  if(confirm('Clear entire circuit?')){
    nodes=[]; wires=[]; selectedId=null; evaluate(); save(); render();
  }
});
document.getElementById('btnFit').addEventListener('click', fitView);
function fitView(){
  if(nodes.length===0){ viewport.x=0; viewport.y=0; viewport.scale=1; render(); return; }
  let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
  for(const n of nodes){
    minX=Math.min(minX,n.x); minY=Math.min(minY,n.y);
    maxX=Math.max(maxX,n.x+CFG.NODE_W); maxY=Math.max(maxY,n.y+CFG.NODE_H);
  }
  const pad=80;
  const rect = wrap.getBoundingClientRect();
  const w = rect.width - pad*2, h = rect.height - pad*2;
  const bw = maxX-minX, bh = maxY-minY;
  const scale = Math.max(CFG.ZOOM_MIN, Math.min(CFG.ZOOM_MAX, Math.min(w/bw, h/bh, 1.2)));
  viewport.scale=scale;
  viewport.x = (rect.width - bw*scale)/2 - minX*scale;
  viewport.y = (rect.height - bh*scale)/2 - minY*scale;
  document.getElementById('zoomRead').textContent=Math.round(scale*100)+'%';
  save(); render();
}
document.getElementById('zIn').addEventListener('click', ()=>{
  const r=wrap.getBoundingClientRect();
  setZoom(viewport.scale*1.2, r.left+r.width/2, r.top+r.height/2); save();
});
document.getElementById('zOut').addEventListener('click', ()=>{
  const r=wrap.getBoundingClientRect();
  setZoom(viewport.scale/1.2, r.left+r.width/2, r.top+r.height/2); save();
});
document.getElementById('btnAddToggle').addEventListener('click', ()=>addNode('TOGGLE'));

function showToast(msg){
  const t=document.getElementById('toast');
  t.textContent='⚠ '+msg;
  t.classList.add('show');
  // flash wires red
  cycleFlash={id:'preview',time:Date.now()};
  render();
  setTimeout(()=>t.classList.remove('show'),2200);
}

// Demo circuit: Toggle -> AND(1) with Toggle -> NOT -> Lamp ; plus HEX demo
function loadDemo(){
  nodes=[]; wires=[]; nextId=1;
  const t1={id:uid(),type:'TOGGLE',x:80,y:100,state:true};
  const t2={id:uid(),type:'TOGGLE',x:80,y:200,state:false};
  const and={id:uid(),type:'AND',x:270,y:135};
  const not={id:uid(),type:'NOT',x:440,y:145};
  const lamp={id:uid(),type:'LAMP',x:610,y:145};
  const clk={id:uid(),type:'CLOCK',x:80,y:620,state:false,clockRate:1};
  const xor={id:uid(),type:'XOR',x:270,y:620};
  const lamp2={id:uid(),type:'LAMP',x:480,y:620};
  const hex={id:uid(),type:'HEX',x:610,y:360};
  // 4 toggles feeding HEX — vertical bus, well clear of bottom row
  const h1={id:uid(),type:'TOGGLE',x:430,y:230,state:true};
  const h2={id:uid(),type:'TOGGLE',x:430,y:310,state:false};
  const h3={id:uid(),type:'TOGGLE',x:430,y:390,state:true};
  const h4={id:uid(),type:'TOGGLE',x:430,y:470,state:false};
  nodes.push(t1,t2,and,not,lamp,clk,xor,lamp2,hex,h1,h2,h3,h4);
  wires.push({id:wid(),from:t1.id,fromPort:0,to:and.id,toPort:0});
  wires.push({id:wid(),from:t2.id,fromPort:0,to:and.id,toPort:1});
  wires.push({id:wid(),from:and.id,fromPort:0,to:not.id,toPort:0});
  wires.push({id:wid(),from:not.id,fromPort:0,to:lamp.id,toPort:0});
  wires.push({id:wid(),from:clk.id,fromPort:0,to:xor.id,toPort:0});
  wires.push({id:wid(),from:t1.id,fromPort:0,to:xor.id,toPort:1});
  wires.push({id:wid(),from:xor.id,fromPort:0,to:lamp2.id,toPort:0});
  wires.push({id:wid(),from:h1.id,fromPort:0,to:hex.id,toPort:0});
  wires.push({id:wid(),from:h2.id,fromPort:0,to:hex.id,toPort:1});
  wires.push({id:wid(),from:h3.id,fromPort:0,to:hex.id,toPort:2});
  wires.push({id:wid(),from:h4.id,fromPort:0,to:hex.id,toPort:3});
  selectedId=null;
  evaluate(); save(); fitView();
}
document.getElementById('btnExample').addEventListener('click', loadDemo);

// Right-click menu to add node at cursor
wrap.addEventListener('contextmenu', e=>{
  e.preventDefault();
  const world = screenToWorld(e.clientX,e.clientY);
  // simple prompt: cycle through types? Instead show quick add at cursor
  const types = ['TOGGLE','AND','OR','NOT','XOR','NAND','NOR','LAMP','HEX','CLOCK'];
  // create floating menu
  let menu = document.getElementById('ctxMenu');
  if(menu) menu.remove();
  menu = document.createElement('div');
  menu.id='ctxMenu';
  menu.style.cssText=`position:fixed;left:${e.clientX}px;top:${e.clientY}px;background:#0f172a;border:1px solid #1e3a5f;border-radius:10px;padding:6px;z-index:99;box-shadow:0 12px 32px rgba(0,0,0,0.5);display:grid;grid-template-columns:1fr 1fr;gap:4px;min-width:160px`;
  types.forEach(t=>{
    const b=document.createElement('button');
    b.textContent=t;
    b.style.cssText='background:#16233a;border:1px solid #1e3355;color:#cbd5e1;padding:6px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer';
    b.onmouseenter=()=>b.style.borderColor='#38bdf8';
    b.onmouseleave=()=>b.style.borderColor='#1e3355';
    b.onclick=()=>{
      const n={id:uid(),type:t,x:world.x-CFG.NODE_W/2,y:world.y-CFG.NODE_H/2,state:false};
      if(t==='CLOCK'){n.clockRate=1;}
      nodes.push(n); selectedId=n.id; evaluate(); save(); render();
      menu.remove();
    };
    menu.appendChild(b);
  });
  document.body.appendChild(menu);
  const close = (ev)=>{ if(!menu.contains(ev.target)) {menu.remove(); document.removeEventListener('mousedown',close);} };
  setTimeout(()=>document.addEventListener('mousedown',close),0);
});

/* ===== 9. CLOCK & INIT ===== */
let lastClockTick = performance.now();
function clockLoop(now){
  requestAnimationFrame(clockLoop);
  let changed=false;
  for(const n of nodes){
    if(n.type==='CLOCK' && !n.paused){
      const period = 1000 / n.clockRate;
      if(!n._last) n._last = now;
      if(now - n._last >= period/2){
        // toggle every half period? Actually clock pulses: we toggle state each half period for square wave
        n._last = now;
        n.state = !n.state;
        changed=true;
      }
    }
  }
  if(changed){ evaluate(); render(); }
  // also update live dot
  const dot=document.getElementById('liveDot');
  if(changed) { dot.style.boxShadow='0 0 10px #10b981'; setTimeout(()=>dot.style.boxShadow='0 0 6px #10b981',200); }
}
requestAnimationFrame(clockLoop);

// Init
const hadSave = load();
if(!hadSave){
  resizeCanvas();
  loadDemo();
} else {
  resizeCanvas();
  evaluate();
  render();
}

// Expose for probe tests
window._graph = { get nodes(){return nodes}, get wires(){return wires}, evaluate, wouldCreateCycle, CFG, viewport, addNode, loadDemo };
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"7fa7fd35-7add-40c2-bcb4-9c0f41437363","tokensIn":3961223,"tokensOut":42977,"tokensTotal":4004200,"cost":0.042233438,"turns":56,"toolCalls":55,"failedToolCalls":0,"timestamp":"2026-08-23T02:23:31.590Z"} -->