← Node Graph Editor results

Node Graph Editor

GLM 5.3 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 29,978 bytes · SHA-256 fff6f313c85d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Logic Circuit Node Editor</title>
<style>
  :root{
    --bg:#0b0f14; --panel:#131a23; --panel2:#1a2330; --edge:#2a3646;
    --text:#c9d6e3; --dim:#6b7a8c;
    --cool:#4fc3f7; --warm:#ff9e64; --hot:#ffd166; --bad:#ff5566;
  }
  *{margin:0;padding:0;box-sizing:border-box}
  html,body{height:100%;overflow:hidden;background:var(--bg);font-family:"Segoe UI",system-ui,sans-serif;color:var(--text)}
  #wrap{position:relative;width:100%;height:100%}
  canvas{display:block;width:100%;height:100%;cursor:default}
  /* ---------- palette sidebar ---------- */
  #palette{position:absolute;top:12px;left:12px;background:var(--panel);border:1px solid var(--edge);
    border-radius:10px;padding:10px;user-select:none;box-shadow:0 6px 24px rgba(0,0,0,.5);z-index:5}
  #palette h3{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:2px 2px 8px}
  .pal-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
  .pal-btn{background:var(--panel2);border:1px solid var(--edge);color:var(--text);border-radius:7px;
    padding:6px 10px;font-size:12px;cursor:pointer;transition:all .12s}
  .pal-btn:hover{border-color:var(--cool);color:#fff;transform:translateY(-1px)}
  .pal-btn.out{border-left:3px solid var(--warm)}
  .pal-btn.gate{border-left:3px solid var(--cool)}
  .pal-btn.in{border-left:3px solid #9ccc65}
  #hint{position:absolute;bottom:10px;left:12px;font-size:11px;color:var(--dim);z-index:5;pointer-events:none}
  /* ---------- context menu ---------- */
  #ctxmenu{position:absolute;display:none;background:var(--panel);border:1px solid var(--edge);border-radius:9px;
    padding:6px;min-width:150px;box-shadow:0 8px 28px rgba(0,0,0,.6);z-index:20;user-select:none}
  #ctxmenu .mi{padding:6px 12px;border-radius:6px;font-size:12px;cursor:pointer}
  #ctxmenu .mi:hover{background:var(--panel2);color:#fff}
  #ctxmenu .sep{height:1px;background:var(--edge);margin:5px 4px}
  #toast{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:#2a1218;border:1px solid var(--bad);
    color:#ff8f9c;padding:7px 16px;border-radius:8px;font-size:12px;display:none;z-index:30;pointer-events:none}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="cv"></canvas>
  <div id="palette">
    <h3>Components</h3>
    <div class="pal-grid">
      <button class="pal-btn in"  data-add="toggle">Toggle</button>
      <button class="pal-btn in"  data-add="clock">Clock</button>
      <button class="pal-btn gate" data-add="AND">AND</button>
      <button class="pal-btn gate" data-add="OR">OR</button>
      <button class="pal-btn gate" data-add="NOT">NOT</button>
      <button class="pal-btn gate" data-add="XOR">XOR</button>
      <button class="pal-btn gate" data-add="NAND">NAND</button>
      <button class="pal-btn gate" data-add="NOR">NOR</button>
      <button class="pal-btn out" data-add="lamp">Lamp</button>
      <button class="pal-btn out" data-add="digit">Hex Digit</button>
    </div>
  </div>
  <div id="ctxmenu"></div>
  <div id="toast"></div>
  <div id="hint">Drag ports to wire · drag body to move · wheel zoom · right-click to add · Del removes selection · dbl-click clock for rate</div>
</div>
<script>
"use strict";
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONST = {
  ZOOM_MIN: 0.5, ZOOM_MAX: 2.0, ZOOM_SPEED: 0.0012,
  GRID: 48, GRID_DOT: 1.6,
  PORT_R: 7, PORT_R_HOVER: 10, PORT_HIT: 12,   // PORT_HIT in screen px
  NODE_W: 128, NODE_H_MIN: 62, PORT_SPACING: 30, HEADER: 22,
  WIRE_HIT: 8,                                  // screen px
  WIRE_GLOW: 14, WIRE_WIDTH: 2.4,
  COLORS: {
    bg:'#0b0f14', grid:'#1b2530',
    node:'#1a2330', nodeStroke:'#33445a', nodeHover:'#212d3d', nodeSel:'#4fc3f7',
    title:'#c9d6e3', dim:'#6b7a8c',
    inPort:'#4fc3f7', outPort:'#ff9e64',
    wireOff:'#33445c', wireOn:'#ffd166',
    lampOff:'#2a3646', lampOn:'#ffd166',
    segOff:'#1c2634', segOn:'#7CFC9A',
    bad:'#ff5566'
  },
  CLOCK_RATES: [2000,1000,500,250,100],
  LS_KEY: 'logic-circuit-scene-v1'
};

/* ============================================================
   NODE TYPE DEFINITIONS
   ============================================================ */
const DEFS = {
  toggle:{ title:'Toggle', inputs:0, outputs:1, color:'#9ccc65' },
  clock :{ title:'Clock',  inputs:0, outputs:1, color:'#ce93d8' },
  AND   :{ title:'AND',  inputs:2, outputs:1, color:'#4fc3f7' },
  OR    :{ title:'OR',   inputs:2, outputs:1, color:'#4fc3f7' },
  NOT   :{ title:'NOT',  inputs:1, outputs:1, color:'#4fc3f7' },
  XOR   :{ title:'XOR',  inputs:2, outputs:1, color:'#4fc3f7' },
  NAND  :{ title:'NAND', inputs:2, outputs:1, color:'#4fc3f7' },
  NOR   :{ title:'NOR',  inputs:2, outputs:1, color:'#4fc3f7' },
  lamp  :{ title:'Lamp', inputs:1, outputs:0, color:'#ffd166' },
  digit :{ title:'Hex',  inputs:4, outputs:0, color:'#7CFC9A' }
};
const nodeH = t => Math.max(CONST.NODE_H_MIN, Math.max(DEFS[t].inputs, DEFS[t].outputs,1)*CONST.PORT_SPACING + CONST.HEADER + 14);

/* ============================================================
   SCENE STATE (the single source of truth — serializable)
   ============================================================ */
let nodes = [];   // {id,type,x,y,state,value,_iv (clock interval, not serialized)}
let wires = [];   // {from,fromPort,to,toPort}
let nextId = 1;
let cam = { x: 0, y: 0, scale: 1 };
let topo = null;                 // cached topological order of node ids
let selNode = null, selWire = -1;
let hover = { node:null, port:null, wire:-1 };

/* ============================================================
   HELPERS
   ============================================================ */
const nodeById = id => nodes.find(n => n.id === id);
// (proper version below — kept simple & correct)
function outPortPos(n, i){
  const h = nodeH(n.type), cnt = DEFS[n.type].outputs || 1;
  return { x: n.x + CONST.NODE_W, y: n.y + CONST.HEADER + (h-CONST.HEADER)*(i+1)/(cnt+1) };
}
function inPortPos(n, i){
  const h = nodeH(n.type), cnt = DEFS[n.type].inputs || 1;
  return { x: n.x, y: n.y + CONST.HEADER + (h-CONST.HEADER)*(i+1)/(cnt+1) };
}
function wirePts(x1,y1,x2,y2){
  const dx = Math.max(40, Math.abs(x2-x1)*0.5);
  return [x1,y1, x1+dx,y1, x2-dx,y2, x2,y2];
}
function screenToWorld(sx,sy){ return { x:(sx-cam.x)/cam.scale, y:(sy-cam.y)/cam.scale }; }
function toast(msg){
  const t = document.getElementById('toast');
  t.textContent = msg; t.style.display = 'block';
  clearTimeout(t._h); t._h = setTimeout(()=>t.style.display='none', 1200);
}

/* ============================================================
   EVALUATION ENGINE — topological, single pass, downstream-only
   ============================================================ */
function inputValues(n){
  const vals = new Array(DEFS[n.type].inputs).fill(0);
  for (const w of wires) if (w.to === n.id){
    const src = nodeById(w.from);
    vals[w.toPort] = src ? (src.value ? 1 : 0) : 0;
  }
  return vals;
}
function calcOutput(n){
  const d = DEFS[n.type];
  if (n.type === 'toggle' || n.type === 'clock') return n.state ? 1 : 0;
  const v = inputValues(n);
  switch(n.type){
    case 'AND':  return (v[0] && v[1]) ? 1 : 0;
    case 'OR':   return (v[0] || v[1]) ? 1 : 0;
    case 'XOR':  return (v[0] ^  v[1]) ? 1 : 0;
    case 'NAND': return (v[0] && v[1]) ? 0 : 1;
    case 'NOR':  return (v[0] || v[1]) ? 0 : 1;
    case 'NOT':  return v[0] ? 0 : 1;
    case 'lamp': return v[0];
    case 'digit':return (v[0]<<3)|(v[1]<<2)|(v[2]<<1)|v[3];
  }
  return 0;
}
function computeTopo(){
  const indeg = new Map(nodes.map(n=>[n.id,0]));
  const adj = new Map(nodes.map(n=>[n.id,[]]));
  for (const w of wires){
    if (!nodeById(w.from) || !nodeById(w.to)) continue;
    adj.get(w.from).push(w.to);
    indeg.set(w.to, indeg.get(w.to)+1);
  }
  const q = nodes.filter(n=>indeg.get(n.id)===0).map(n=>n.id);
  const order = [];
  while (q.length){
    const id = q.shift(); order.push(id);
    for (const m of adj.get(id)){ const d = indeg.get(m) - 1; indeg.set(m, d); if (d === 0) q.push(m); }
  }
  for (const n of nodes) if (!order.includes(n.id)) order.push(n.id); // safety
  return order;
}
// dirty: Set of node ids whose OUTPUT may have changed. null => full evaluation.
function evaluate(dirty){
  if (!topo || !dirty) topo = computeTopo();
  if (!dirty) dirty = new Set(nodes.map(n=>n.id));
  for (const id of topo){
    if (!dirty.has(id)) continue;
    const n = nodeById(id); if (!n) continue;
    const v = calcOutput(n);
    if (v !== n.value){
      n.value = v;
      for (const w of wires) if (w.from === id) dirty.add(w.to);
    }
  }
}

/* ============================================================
   CYCLE DETECTION — adding from->to is a cycle if `from` is
   reachable forward from `to`.
   ============================================================ */
function createsCycle(fromId, toId){
  const stack = [toId], seen = new Set();
  while (stack.length){
    const id = stack.pop();
    if (id === fromId) return true;
    if (seen.has(id)) continue;
    seen.add(id);
    for (const w of wires) if (w.from === id) stack.push(w.to);
  }
  return false;
}

/* ============================================================
   CLOCKS
   ============================================================ */
function startClock(n){
  stopClock(n);
  n._iv = setInterval(()=>{
    n.state = n.state ? 0 : 1;
    evaluate(new Set([n.id]));
    save();
  }, n.rate || 500);
}
function stopClock(n){ if (n._iv){ clearInterval(n._iv); n._iv = null; } }
function cycleRate(n){
  const i = CONST.CLOCK_RATES.indexOf(n.rate || 500);
  n.rate = CONST.CLOCK_RATES[(i+1) % CONST.CLOCK_RATES.length];
  startClock(n); save();
}

/* ============================================================
   MUTATIONS
   ============================================================ */
function addNode(type, x, y){
  const n = { id: nextId++, type, x: Math.round(x), y: Math.round(y), state: 0, value: 0 };
  if (type === 'clock') n.rate = 500;
  nodes.push(n);
  if (type === 'clock') startClock(n);
  evaluate(new Set([n.id]));
  save();
  return n;
}
function deleteNode(id){
  const n = nodeById(id); if (!n) return;
  stopClock(n);
  nodes = nodes.filter(x => x.id !== id);
  wires = wires.filter(w => w.from !== id && w.to !== id);
  topo = null;
  evaluate(null); save();
}
function deleteWire(i){
  const w = wires[i]; if (!w) return;
  wires.splice(i,1); topo = null;
  evaluate(new Set([w.to])); save();
}
function connect(from, fromPort, to, toPort){
  if (createsCycle(from, to)) return false;
  wires = wires.filter(w => !(w.to === to && w.toPort === toPort)); // one wire per input
  wires.push({ from, fromPort, to, toPort });
  topo = null;
  evaluate(new Set([to])); save();
  return true;
}

/* ============================================================
   PERSISTENCE
   ============================================================ */
let saveTimer = null;
function save(){
  clearTimeout(saveTimer);
  saveTimer = setTimeout(()=>{
    const data = {
      nodes: nodes.map(n => ({ id:n.id, type:n.type, x:n.x, y:n.y, state:n.state, rate:n.rate })),
      wires: wires.map(w => ({ from:w.from, fromPort:w.fromPort, to:w.to, toPort:w.toPort })),
      nextId, cam
    };
    try { localStorage.setItem(CONST.LS_KEY, JSON.stringify(data)); } catch(e){}
  }, 120);
}
function load(){
  let data = null;
  try { data = JSON.parse(localStorage.getItem(CONST.LS_KEY)); } catch(e){}
  if (!data || !Array.isArray(data.nodes)) return false;
  nodes = data.nodes.map(n => ({ ...n, value: 0, _iv: null }));
  wires = (data.wires || []).filter(w => nodeById(w.from) && nodeById(w.to));
  nextId = data.nextId || (Math.max(0, ...nodes.map(n=>n.id)) + 1);
  if (data.cam) cam = data.cam;
  for (const n of nodes) if (n.type === 'clock') startClock(n);
  topo = null;
  evaluate(null);
  return true;
}

/* ============================================================
   RENDERING
   ============================================================ */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let dpr = 1;
function resize(){
  dpr = window.devicePixelRatio || 1;
  cv.width = innerWidth * dpr; cv.height = innerHeight * dpr;
}
addEventListener('resize', resize); resize();

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

function draw(){
  const W = innerWidth, H = innerHeight;
  ctx.setTransform(dpr,0,0,dpr,0,0);
  ctx.fillStyle = CONST.COLORS.bg; ctx.fillRect(0,0,W,H);

  // ---- grid dots (world space) ----
  ctx.setTransform(dpr*cam.scale,0,0,dpr*cam.scale,dpr*cam.x,dpr*cam.y);
  const wx0 = -cam.x/cam.scale, wy0 = -cam.y/cam.scale;
  const wx1 = wx0 + W/cam.scale, wy1 = wy0 + H/cam.scale;
  const g = CONST.GRID;
  ctx.fillStyle = CONST.COLORS.grid;
  const r = CONST.GRID_DOT / cam.scale;
  for (let gx = Math.floor(wx0/g)*g; gx <= wx1; gx += g)
    for (let gy = Math.floor(wy0/g)*g; gy <= wy1; gy += g){
      ctx.beginPath(); ctx.arc(gx,gy,r,0,7); ctx.fill();
    }

  // ---- wires ----
  for (let i=0;i<wires.length;i++){
    const w = wires[i];
    const a = nodeById(w.from), b = nodeById(w.to);
    if (!a || !b) continue;
    const p1 = outPortPos(a, w.fromPort), p2 = inPortPos(b, w.toPort);
    const on = a.value ? 1 : 0;
    const pts = wirePts(p1.x,p1.y,p2.x,p2.y);
    ctx.beginPath();
    ctx.moveTo(pts[0],pts[1]);
    ctx.bezierCurveTo(pts[2],pts[3],pts[4],pts[5],pts[6],pts[7]);
    if (i === selWire){ ctx.strokeStyle = '#ffffff'; ctx.lineWidth = CONST.WIRE_WIDTH+1.6; ctx.stroke(); }
    ctx.strokeStyle = on ? CONST.COLORS.wireOn : CONST.COLORS.wireOff;
    ctx.lineWidth = CONST.WIRE_WIDTH;
    ctx.shadowBlur = on ? CONST.WIRE_GLOW : 0;
    ctx.shadowColor = CONST.COLORS.wireOn;
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  // ---- wire drag preview ----
  if (drag && drag.mode === 'wire'){
    let p1;
    if (drag.fromKind === 'out') p1 = outPortPos(nodeById(drag.fromId), drag.fromPort);
    else p1 = { x: drag.wx, y: drag.wy };
    const p2 = drag.fromKind === 'out' ? { x: drag.wx, y: drag.wy }
             : inPortPos(nodeById(drag.fromId), drag.fromPort);
    const ok = drag.hover && !drag.hover.cycle;
    const pts = wirePts(p1.x,p1.y,p2.x,p2.y);
    ctx.beginPath();
    ctx.moveTo(pts[0],pts[1]);
    ctx.bezierCurveTo(pts[2],pts[3],pts[4],pts[5],pts[6],pts[7]);
    ctx.strokeStyle = ok ? CONST.COLORS.wireOn
                    : drag.hover && drag.hover.cycle ? CONST.COLORS.bad
                    : '#7f95ad';
    ctx.lineWidth = CONST.WIRE_WIDTH; ctx.setLineDash([6/cam.scale, 5/cam.scale]);
    ctx.stroke(); ctx.setLineDash([]);
  }

  // ---- rejected-wire red flash ----
  for (const f of flashes){
    const t = (performance.now() - f.t0) / 450;
    if (t >= 1) continue;
    const pts = f.pts;
    ctx.beginPath(); ctx.moveTo(pts[0],pts[1]);
    ctx.bezierCurveTo(pts[2],pts[3],pts[4],pts[5],pts[6],pts[7]);
    ctx.strokeStyle = CONST.COLORS.bad;
    ctx.globalAlpha = 1 - t;
    ctx.lineWidth = CONST.WIRE_WIDTH + 2.5; ctx.stroke();
    ctx.globalAlpha = 1;
  }
  flashes = flashes.filter(f => performance.now() - f.t0 < 450);

  // ---- nodes ----
  for (const n of nodes){
    const d = DEFS[n.type], h = nodeH(n.type), w = CONST.NODE_W;
    const isHover = hover.node === n.id, isSel = selNode === n.id;
    // lift on hover
    const lift = isHover ? -1.5 : 0;
    // shadow / depth
    ctx.shadowBlur = isHover ? 18 : 8;
    ctx.shadowColor = 'rgba(0,0,0,.55)';
    ctx.shadowOffsetY = isHover ? 4 : 2;
    ctx.fillStyle = isHover ? CONST.COLORS.nodeHover : CONST.COLORS.node;
    roundRect(n.x, n.y+lift, w, h, 10); ctx.fill();
    ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
    // state tint border
    const active = n.value ? 1 : 0;
    ctx.lineWidth = isSel ? 2.2 : 1.2;
    ctx.strokeStyle = isSel ? CONST.COLORS.nodeSel
                    : active ? d.color : CONST.COLORS.nodeStroke;
    if (active){ ctx.shadowBlur = 10; ctx.shadowColor = d.color; }
    roundRect(n.x, n.y+lift, w, h, 10); ctx.stroke();
    ctx.shadowBlur = 0;
    // header
    ctx.fillStyle = d.color;
    ctx.font = '600 11px "Segoe UI",sans-serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
    ctx.fillText(d.title, n.x + 12, n.y + lift + CONST.HEADER/2 + 1);
    // body content per type
    drawNodeBody(n, lift);
    // ports
    drawPorts(n, lift);
  }

  // restore screen transform for HUD
  ctx.setTransform(dpr,0,0,dpr,0,0);
  requestAnimationFrame(draw);
}

function drawNodeBody(n, lift){
  const d = DEFS[n.type];
  ctx.textBaseline = 'middle';
  if (n.type === 'toggle'){
    // switch track
    const tx = n.x + 14, ty = n.y + lift + CONST.HEADER + 12, tw = 44, th = 20;
    ctx.fillStyle = n.state ? '#3d6b2f' : '#232f3f';
    roundRect(tx,ty,tw,th,10); ctx.fill();
    ctx.fillStyle = n.state ? '#9ccc65' : '#5a6b7f';
    ctx.beginPath(); ctx.arc(n.state ? tx+tw-11 : tx+11, ty+th/2, 7.5, 0, 7); ctx.fill();
    ctx.fillStyle = CONST.COLORS.dim; ctx.font = '11px "Segoe UI",sans-serif';
    ctx.fillText(n.state ? 'ON' : 'OFF', tx + tw + 12, ty + th/2);
  } else if (n.type === 'clock'){
    ctx.fillStyle = CONST.COLORS.dim; ctx.font = '11px "Segoe UI",sans-serif';
    ctx.fillText((n.rate||500) + ' ms', n.x + 14, n.y + lift + CONST.HEADER + 20);
    // pulse indicator
    ctx.fillStyle = n.state ? '#ce93d8' : '#3a2f42';
    ctx.beginPath(); ctx.arc(n.x + CONST.NODE_W - 22, n.y + lift + CONST.HEADER + 20, 7, 0, 7); ctx.fill();
  } else if (n.type === 'lamp'){
    const cx = n.x + CONST.NODE_W/2, cy = n.y + lift + CONST.HEADER + (nodeH('lamp')-CONST.HEADER)/2;
    const R = 15;
    ctx.beginPath(); ctx.arc(cx,cy,R,0,7);
    ctx.fillStyle = n.value ? CONST.COLORS.lampOn : CONST.COLORS.lampOff;
    if (n.value){ ctx.shadowBlur = 22; ctx.shadowColor = CONST.COLORS.lampOn; }
    ctx.fill(); ctx.shadowBlur = 0;
    ctx.strokeStyle = '#3a4a5e'; ctx.lineWidth = 1.5; ctx.stroke();
  } else if (n.type === 'digit'){
    drawSevenSeg(n, lift);
  } else {
    // gates: show live inputs
    const v = inputValues(n);
    ctx.font = '11px "Segoe UI",sans-serif'; ctx.fillStyle = CONST.COLORS.dim;
    v.forEach((b,i)=>{
      const p = inPortPos(n, i);
      ctx.fillText(b ? '1' : '0', p.x + 10, p.y);
    });
  }
}

const SEG = [0b1111110,0b0110000,0b1101101,0b1111001,0b0110011,0b1011011,0b1011111,0b1110000,
             0b1111111,0b1111011,0b1110111,0b0011111,0b1001110,0b0111101,0b1001111,0b1000111];
function drawSevenSeg(n, lift){
  const val = n.value & 15, bits = SEG[val];
  const ox = n.x + 34, oy = n.y + lift + CONST.HEADER + 8;
  const W = 26, H = 40, t = 4;
  const segs = [
    [ox+3, oy, W-6, t],                    // a
    [ox+W-t, oy+3, t, H/2-4],              // b
    [ox+W-t, oy+H/2+1, t, H/2-4],          // c
    [ox+3, oy+H-t, W-6, t],                // d
    [ox, oy+H/2+1, t, H/2-4],              // e
    [ox, oy+3, t, H/2-4],                  // f
    [ox+3, oy+H/2-t/2, W-6, t]             // g
  ];
  segs.forEach((s,i)=>{
    ctx.fillStyle = (bits >> (6-i)) & 1 ? CONST.COLORS.segOn : CONST.COLORS.segOff;
    if ((bits>>i) & 1) {} // (bit order: seg a = MSB)
    ctx.fillRect(s[0],s[1],s[2],s[3]);
  });
  // port bit labels
  ctx.font = '10px "Segoe UI",sans-serif'; ctx.fillStyle = CONST.COLORS.dim;
  for (let i=0;i<4;i++){
    const p = inPortPos(n, i);
    ctx.fillText(String((val >> (3-i)) & 1), p.x + 9, p.y);
  }
}

function drawPorts(n, lift){
  const d = DEFS[n.type];
  for (let i=0;i<d.inputs;i++){
    const p = inPortPos(n, i);
    const isH = hover.port && hover.port.node === n.id && hover.port.kind === 'in' && hover.port.idx === i;
    const r = isH ? CONST.PORT_R_HOVER : CONST.PORT_R;
    const wired = wires.some(w => w.to === n.id && w.toPort === i);
    ctx.beginPath(); ctx.arc(p.x, p.y + lift, r, 0, 7);
    ctx.fillStyle = CONST.COLORS.inPort; ctx.shadowBlur = wired ? 8 : 0;
    ctx.shadowColor = CONST.COLORS.inPort; ctx.fill(); ctx.shadowBlur = 0;
    ctx.strokeStyle = '#0b0f14'; ctx.lineWidth = 2; ctx.stroke();
    if (isH){ ctx.beginPath(); ctx.arc(p.x, p.y+lift, r+4, 0, 7); ctx.strokeStyle = CONST.COLORS.inPort; ctx.lineWidth = 1.5; ctx.stroke(); }
  }
  for (let i=0;i<d.outputs;i++){
    const p = outPortPos(n, i);
    const isH = hover.port && hover.port.node === n.id && hover.port.kind === 'out' && hover.port.idx === i;
    const r = isH ? CONST.PORT_R_HOVER : CONST.PORT_R;
    ctx.beginPath(); ctx.arc(p.x, p.y + lift, r, 0, 7);
    ctx.fillStyle = CONST.COLORS.outPort; ctx.shadowBlur = n.value ? 10 : 0;
    ctx.shadowColor = CONST.COLORS.outPort; ctx.fill(); ctx.shadowBlur = 0;
    ctx.strokeStyle = '#0b0f14'; ctx.lineWidth = 2; ctx.stroke();
    if (isH){ ctx.beginPath(); ctx.arc(p.x, p.y+lift, r+4, 0, 7); ctx.strokeStyle = CONST.COLORS.outPort; ctx.lineWidth = 1.5; ctx.stroke(); }
  }
}

/* ============================================================
   HIT TESTING (all in world coords; tolerances screen-constant)
   ============================================================ */
function hitPort(wx, wy){
  const tol = CONST.PORT_HIT / cam.scale;
  let best = null, bestD = tol;
  for (const n of nodes){
    const d = DEFS[n.type];
    for (let i=0;i<d.inputs;i++){
      const p = inPortPos(n,i), dd = Math.hypot(wx-p.x, wy-p.y);
      if (dd < bestD){ bestD = dd; best = { node:n.id, kind:'in', idx:i, x:p.x, y:p.y }; }
    }
    for (let i=0;i<d.outputs;i++){
      const p = outPortPos(n,i), dd = Math.hypot(wx-p.x, wy-p.y);
      if (dd < bestD){ bestD = dd; best = { node:n.id, kind:'out', idx:i, x:p.x, y:p.y }; }
    }
  }
  return best;
}
function hitNode(wx, wy){
  for (let i = nodes.length-1; i >= 0; i--){
    const n = nodes[i];
    if (wx >= n.x && wx <= n.x + CONST.NODE_W && wy >= n.y && wy <= n.y + nodeH(n.type)) return n;
  }
  return null;
}
function hitWire(wx, wy){
  const tol = CONST.WIRE_HIT / cam.scale;
  for (let i = wires.length-1; i >= 0; i--){
    const w = wires[i];
    const a = nodeById(w.from), b = nodeById(w.to);
    if (!a || !b) continue;
    const p1 = outPortPos(a, w.fromPort), p2 = inPortPos(b, w.toPort);
    const pts = wirePts(p1.x,p1.y,p2.x,p2.y);
    for (let s = 0; s <= 28; s++){
      const t = s/28, mt = 1-t;
      // cubic bezier point
      const bx = mt*mt*mt*pts[0] + 3*mt*mt*t*pts[2] + 3*mt*t*t*pts[4] + t*t*t*pts[6];
      const by = mt*mt*mt*pts[1] + 3*mt*mt*t*pts[3] + 3*mt*t*t*pts[5] + t*t*t*pts[7];
      if (Math.hypot(wx-bx, wy-by) < tol) return i;
    }
  }
  return -1;
}

/* ============================================================
   INTERACTION
   ============================================================ */
let drag = null;   // {mode:'pan'|'node'|'wire', ...}
let mouse = { x:0, y:0, wx:0, wy:0 };
let flashes = [];

cv.addEventListener('mousedown', e => {
  if (e.button !== 0) return;
  const { x:wx, y:wy } = screenToWorld(e.clientX, e.clientY);
  const port = hitPort(wx, wy);
  if (port){
    if (port.kind === 'out'){
      drag = { mode:'wire', fromKind:'out', fromId:port.node, fromPort:port.idx, wx, wy, hover:null };
    } else {
      // pick up existing wire from occupied input port
      const wi = wires.findIndex(w => w.to === port.node && w.toPort === port.idx);
      if (wi >= 0){
        const w = wires[wi];
        wires.splice(wi,1); topo = null;
        evaluate(new Set([port.node]));
        drag = { mode:'wire', fromKind:'out', fromId:w.from, fromPort:w.fromPort, wx, wy, hover:null, moved:true };
        save();
      } else {
        drag = { mode:'wire', fromKind:'in', fromId:port.node, fromPort:port.idx, wx, wy, hover:null };
      }
    }
    return;
  }
  const n = hitNode(wx, wy);
  if (n){
    selNode = n.id; selWire = -1;
    drag = { mode:'node', id:n.id, ox:wx-n.x, oy:wy-n.y, sx:e.clientX, sy:e.clientY, moved:false };
    return;
  }
  const wi = hitWire(wx, wy);
  if (wi >= 0){ selWire = wi; selNode = null; return; }
  selNode = null; selWire = -1;
  drag = { mode:'pan', sx:e.clientX, sy:e.clientY, cx:cam.x, cy:cam.y };
});

cv.addEventListener('mousemove', e => {
  mouse.x = e.clientX; mouse.y = e.clientY;
  const w = screenToWorld(e.clientX, e.clientY);
  mouse.wx = w.x; mouse.wy = w.y;
  if (drag){
    if (drag.mode === 'pan'){
      cam.x = drag.cx + (e.clientX - drag.sx);
      cam.y = drag.cy + (e.clientY - drag.sy);
    } else if (drag.mode === 'node'){
      const n = nodeById(drag.id);
      if (n){ n.x = Math.round(w.x - drag.ox); n.y = Math.round(w.y - drag.oy); drag.moved = true; }
    } else if (drag.mode === 'wire'){
      drag.wx = w.x; drag.wy = w.y;
      const hp = hitPort(w.x, w.y);
      if (hp && hp.kind === 'in' && !(hp.node === drag.fromId && drag.fromKind === 'out' && false)){
        const cycle = drag.fromKind === 'out' && createsCycle(drag.fromId, hp.node);
        drag.hover = { ...hp, cycle };
      } else if (hp && drag.fromKind === 'in' && hp.kind === 'in'){
        drag.hover = null; // input->input not allowed
      } else {
        drag.hover = null;
      }
    }
  } else {
    // hover state
    const hp = hitPort(w.x, w.y);
    const hn = hp ? null : hitNode(w.x, w.y);
    hover.port = hp;
    hover.node = hn ? hn.id : null;
    hover.wire = (!hp && !hn) ? hitWire(w.x, w.y) : -1;
    cv.style.cursor = hp ? 'crosshair' : hn ? 'grab' : hover.wire >= 0 ? 'pointer' : 'default';
  }
});

addEventListener('mouseup', e => {
  if (!drag) return;
  if (drag.mode === 'wire'){
    const w = screenToWorld(e.clientX, e.clientY);
    const hp = hitPort(w.x, w.y);
    if (hp && hp.kind === 'in'){
      if (drag.fromKind === 'out'){
        if (createsCycle(drag.fromId, hp.node)){
          // ---- cycle rejected: red flash along attempted wire ----
          const a = nodeById(drag.fromId);
          const p1 = outPortPos(a, drag.fromPort), p2 = inPortPos(nodeById(hp.node), hp.idx);
          flashes.push({ pts: wirePts(p1.x,p1.y,p2.x,p2.y), t0: performance.now() });
          toast('⚠ Feedback loop rejected — combinational circuits only');
        } else {
          connect(drag.fromId, drag.fromPort, hp.node, hp.idx);
        }
      }
      // picking up from an input and dropping on another input is a no-op
    }
  } else if (drag.mode === 'node'){
    if (drag.moved) save();
    else {
      // click (no drag): toggle switch or cycle clock rate
      const n = nodeById(drag.id);
      if (n && n.type === 'toggle'){ n.state = n.state ? 0 : 1; evaluate(new Set([n.id])); save(); }
    }
  }
  drag = null;
});

cv.addEventListener('dblclick', e => {
  const { x:wx, y:wy } = screenToWorld(e.clientX, e.clientY);
  const n = hitNode(wx, wy);
  if (n && n.type === 'clock') cycleRate(n);
});

cv.addEventListener('wheel', e => {
  e.preventDefault();
  const s0 = cam.scale;
  const s1 = Math.min(CONST.ZOOM_MAX, Math.max(CONST.ZOOM_MIN, s0 * Math.exp(-e.deltaY * CONST.ZOOM_SPEED)));
  // zoom-to-cursor: world point under cursor stays put
  const wx = (e.clientX - cam.x) / s0, wy = (e.clientY - cam.y) / s0;
  cam.scale = s1;
  cam.x = e.clientX - wx * s1;
  cam.y = e.clientY - wy * s1;
  save();
}, { passive:false });

cv.addEventListener('contextmenu', e => {
  e.preventDefault();
  const { x:wx, y:wy } = screenToWorld(e.clientX, e.clientY);
  // delete node via right-click? No — show add menu
  showCtxMenu(e.clientX, e.clientY, wx, wy);
});

addEventListener('keydown', e => {
  if (e.key === 'Delete' || e.key === 'Backspace'){
    if (selNode != null){ deleteNode(selNode); selNode = null; }
    else if (selWire >= 0){ deleteWire(selWire); selWire = -1; }
  }
});

/* ---------- context menu ---------- */
const ctxMenu = document.getElementById('ctxmenu');
function showCtxMenu(sx, sy, wx, wy){
  ctxMenu.innerHTML = '';
  const addTypes = Object.keys(DEFS);
  for (const t of addTypes){
    const div = document.createElement('div');
    div.className = 'mi';
    div.textContent = '+ ' + DEFS[t].title;
    div.onclick = () => { addNode(t, wx, wy); ctxMenu.style.display = 'none'; };
    ctxMenu.appendChild(div);
  }
  const sep = document.createElement('div'); sep.className = 'sep'; ctxMenu.appendChild(sep);
  if (selNode != null){
    const del = document.createElement('div');
    del.className = 'mi'; del.textContent = 'Delete selected';
    del.onclick = () => { deleteNode(selNode); selNode = null; ctxMenu.style.display='none'; };
    ctxMenu.appendChild(del);
  }
  ctxMenu.style.left = sx + 'px'; ctxMenu.style.top = sy + 'px';
  ctxMenu.style.display = 'block';
}
addEventListener('mousedown', e => { if (!ctxMenu.contains(e.target)) ctxMenu.style.display = 'none'; }, true);

/* ---------- palette buttons ---------- */
let palCount = 0;
document.querySelectorAll('[data-add]').forEach(b => {
  b.onclick = () => {
    const w = screenToWorld(innerWidth/2 + (palCount%5)*30 - 60, innerHeight/2 + (palCount%4)*30 - 60);
    palCount++;
    addNode(b.dataset.add, w.x, w.y);
  };
});

/* ============================================================
   BOOT
   ============================================================ */
if (!load()){
  // starter circuit: Toggle -> AND(toggle2) -> NOT -> Lamp
  const t1 = addNode('toggle', 120, 140); t1.state = 1;
  const t2 = addNode('toggle', 120, 260);
  const g  = addNode('AND', 340, 190);
  const nt = addNode('NOT', 540, 190);
  const lp = addNode('lamp', 720, 190);
  connect(t1.id,0,g.id,0); connect(t2.id,0,g.id,1); connect(g.id,0,nt.id,0); connect(nt.id,0,lp.id,0);
  evaluate(null);
}
requestAnimationFrame(draw);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"70b9b4f4-4ca8-4c24-93bb-c273a2ebd0f6","tokensIn":494739,"tokensOut":17866,"tokensTotal":512605,"cost":0.018341485000000008,"turns":19,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-08-31T14:35:17.530Z"} -->