← Node Graph Editor results

Node Graph Editor

Qwen 3.6 27b MTP · 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 47,129 bytes · SHA-256 91c07c6dbdcd
<!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 Editor</title>
<style>
/* ============================================================
   GLOBAL STYLES & RESET
   ============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; font-family: 'Segoe UI', system-ui, sans-serif; color: #e0e0e0; user-select: none; }

/* ============================================================
   LAYOUT
   ============================================================ */
#app { display: flex; width: 100%; height: 100%; }
#sidebar { width: 200px; min-width: 200px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; z-index: 10; }
#canvas-container { flex: 1; position: relative; overflow: hidden; cursor: grab; }
#canvas-container.grabbing { cursor: grabbing; }
#canvas-container.wire-dragging { cursor: crosshair; }
#mainCanvas { display: block; width: 100%; height: 100%; }

/* ============================================================
   SIDEBAR
   ============================================================ */
.sidebar-title { padding: 16px; font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 2px; color: #e94560; border-bottom: 1px solid #0f3460; }
.sidebar-section { padding: 8px 12px; }
.sidebar-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: #7a7a9e; margin-bottom: 6px; }
.node-btn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 12px; margin-bottom: 4px; background: #1a1a3e; border: 1px solid #2a2a5e; border-radius: 6px; color: #c0c0d0; font-size: 13px; cursor: pointer; transition: all 0.15s; }
.node-btn:hover { background: #2a2a5e; border-color: #4a4a8e; transform: translateX(2px); }
.node-btn .icon { width: 20px; height: 20px; border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; }
.sidebar-actions { padding: 12px; margin-top: auto; border-top: 1px solid #0f3460; }
.action-btn { width: 100%; padding: 8px; margin-bottom: 4px; background: #e94560; border: none; border-radius: 6px; color: white; font-size: 12px; cursor: pointer; transition: background 0.15s; }
.action-btn:hover { background: #ff6b81; }
.action-btn.secondary { background: #2a2a5e; }
.action-btn.secondary:hover { background: #3a3a7e; }

/* ============================================================
   CONTEXT MENU
   ============================================================ */
#contextMenu { position: fixed; display: none; background: #16213e; border: 1px solid #0f3460; border-radius: 8px; padding: 6px 0; min-width: 160px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 100; }
#contextMenu .ctx-item { padding: 8px 16px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; transition: background 0.1s; }
#contextMenu .ctx-item:hover { background: #2a2a5e; }
#contextMenu .ctx-sep { height: 1px; background: #0f3460; margin: 4px 0; }

/* ============================================================
   CLOCK RATE POPUP
   ============================================================ */
#clockPopup { position: fixed; display: none; background: #16213e; border: 1px solid #0f3460; border-radius: 8px; padding: 12px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
#clockPopup label { font-size: 12px; color: #aaa; display: block; margin-bottom: 6px; }
#clockPopup select { width: 100%; padding: 4px 8px; background: #1a1a3e; border: 1px solid #2a2a5e; border-radius: 4px; color: #e0e0e0; font-size: 13px; }

/* ============================================================
   TOAST NOTIFICATIONS
   ============================================================ */
#toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #e94560; color: white; padding: 10px 20px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 200; }
#toast.show { opacity: 1; }

/* ============================================================
   ZOOM INDICATOR
   ============================================================ */
#zoomIndicator { position: absolute; bottom: 12px; right: 12px; background: rgba(22,33,62,0.8); padding: 4px 10px; border-radius: 4px; font-size: 11px; color: #7a7a9e; pointer-events: none; }
</style>
</head>
<body>
<div id="app">
  <div id="sidebar">
    <div class="sidebar-title">Logic Editor</div>
    <div class="sidebar-section">
      <div class="sidebar-section-title">Inputs</div>
      <button class="node-btn" data-type="toggle"><span class="icon" style="background:#4ecca3;">T</span> Toggle Switch</button>
      <button class="node-btn" data-type="clock"><span class="icon" style="background:#f7b731;">C</span> Clock</button>
    </div>
    <div class="sidebar-section">
      <div class="sidebar-section-title">Gates</div>
      <button class="node-btn" data-type="and"><span class="icon" style="background:#5f27cd;">&amp;</span> AND</button>
      <button class="node-btn" data-type="or"><span class="icon" style="background:#5f27cd;">|</span> OR</button>
      <button class="node-btn" data-type="not"><span class="icon" style="background:#5f27cd;">!</span> NOT</button>
      <button class="node-btn" data-type="xor"><span class="icon" style="background:#5f27cd;">^</span> XOR</button>
      <button class="node-btn" data-type="nand"><span class="icon" style="background:#5f27cd;">N</span> NAND</button>
      <button class="node-btn" data-type="nor"><span class="icon" style="background:#5f27cd;">N</span> NOR</button>
    </div>
    <div class="sidebar-section">
      <div class="sidebar-section-title">Outputs</div>
      <button class="node-btn" data-type="lamp"><span class="icon" style="background:#fd9644;">L</span> Lamp</button>
      <button class="node-btn" data-type="hexdisplay"><span class="icon" style="background:#fd9644;">H</span> Hex Display</button>
    </div>
    <div class="sidebar-actions">
      <button class="action-btn secondary" id="clearBtn">Clear All</button>
    </div>
  </div>
  <div id="canvas-container">
    <canvas id="mainCanvas"></canvas>
    <div id="zoomIndicator">100%</div>
  </div>
</div>

<div id="contextMenu"></div>
<div id="clockPopup"><label>Clock Rate (Hz)</label><select id="clockRateSelect"><option value="0.25">0.25 Hz</option><option value="0.5" selected>0.5 Hz</option><option value="1">1 Hz</option><option value="2">2 Hz</option><option value="4">4 Hz</option></select></div>
<div id="toast"></div>

<script>
/* ============================================================
   TUNABLE CONSTANTS
   ============================================================ */
const CONFIG = {
  // Colors
  colors: {
    bg: '#1a1a2e',
    gridDot: 'rgba(255,255,255,0.06)',
    nodeBg: '#1e2a4a',
    nodeBorder: '#3a4a7a',
    nodeSelected: '#e94560',
    nodeHover: '#2a3a6a',
    inputPort: '#4ecca3',
    outputPort: '#f7b731',
    wireOn: '#4ecca3',
    wireOff: 'rgba(100,100,150,0.4)',
    wirePreview: 'rgba(247,183,49,0.6)',
    wireCycleError: '#e94560',
    lampOn: '#fdcb6e',
    lampOff: 'rgba(253,203,110,0.15)',
    textPrimary: '#e0e0e0',
    textSecondary: '#7a7a9e',
  },
  // Dimensions
  nodeWidth: 140,
  nodeHeight: 60,
  portRadius: 8,
  portHitTolerance: 12, // in world coords
  gridSpacing: 30,
  
  // Zoom
  zoomMin: 0.5,
  zoomMax: 2.0,
  zoomStep: 1.1,
  
  // Clock rates (Hz)
  clockRates: [0.25, 0.5, 1, 2, 4],
};

/* ============================================================
   NODE DEFINITIONS
   ============================================================ */
const NODE_DEFS = {
  toggle: { label: 'Toggle', inputs: [], outputs: ['Out'], color: '#4ecca3', stateDefault: false },
  clock:  { label: 'Clock',  inputs: [], outputs: ['Pulse'], color: '#f7b731', stateDefault: false, rate: 0.5 },
  and:    { label: 'AND',    inputs: ['A','B'], outputs: ['Out'], color: '#5f27cd' },
  or:     { label: 'OR',     inputs: ['A','B'], outputs: ['Out'], color: '#5f27cd' },
  not:    { label: 'NOT',    inputs: ['A'], outputs: ['Out'], color: '#5f27cd' },
  xor:    { label: 'XOR',    inputs: ['A','B'], outputs: ['Out'], color: '#5f27cd' },
  nand:   { label: 'NAND',   inputs: ['A','B'], outputs: ['Out'], color: '#5f27cd' },
  nor:    { label: 'NOR',    inputs: ['A','B'], outputs: ['Out'], color: '#5f27cd' },
  lamp:   { label: 'Lamp',   inputs: ['In'], outputs: [], color: '#fd9644' },
  hexdisplay: { label: 'Hex Display', inputs: ['D0','D1','D2','D3'], outputs: [], color: '#fd9644' },
};

/* ============================================================
   DATA MODEL (Scene)
   ============================================================ */
let scene = {
  nodes: [],    // [{ id, type, x, y, state, rate? }]
  wires: [],    // [{ from, fromPortIdx, to, toPortIdx }]
  nextId: 1,
};

/* ============================================================
   RENDERER & STATE
   ============================================================ */
const canvas = document.getElementById('mainCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('canvas-container');
const zoomIndicator = document.getElementById('zoomIndicator');

let camera = { x: 0, y: 0, zoom: 1 };
let dpr = window.devicePixelRatio || 1;

// Interaction state
let interaction = {
  mode: 'idle', // idle | panning | dragging-node | dragging-wire | selecting-clock
  panStart: null,
  dragNode: null,
  dragOffset: { x: 0, y: 0 },
  wireFrom: null,
  wireMouseWorld: null,
  selectedNodeId: null,
};

// Clock timers
let clockTimers = {}; // nodeId -> intervalId
let clockStates = {}; // nodeId -> boolean (current pulse state)

/* ============================================================
   UTILITY FUNCTIONS
   ============================================================ */
function worldToScreen(wx, wy) {
  return {
    x: (wx - camera.x) * camera.zoom + canvas.width / (2 * dpr),
    y: (wy - camera.y) * camera.zoom + canvas.height / (2 * dpr),
  };
}

function screenToWorld(sx, sy) {
  return {
    x: (sx - canvas.width / (2 * dpr)) / camera.zoom + camera.x,
    y: (sy - canvas.height / (2 * dpr)) / camera.zoom + camera.y,
  };
}

function getPortWorldPos(node, portIdx, isInput) {
  const def = NODE_DEFS[node.type];
  const ports = isInput ? def.inputs : def.outputs;
  if (portIdx < 0 || portIdx >= ports.length) return null;
  
  const totalPorts = ports.length;
  const spacing = CONFIG.nodeHeight / (totalPorts + 1);
  const py = node.y - CONFIG.nodeHeight / 2 + spacing * (portIdx + 1);
  const px = isInput ? node.x - CONFIG.nodeWidth / 2 : node.x + CONFIG.nodeWidth / 2;
  
  return { x: px, y: py };
}

function getNodePortPositions(node) {
  const def = NODE_DEFS[node.type];
  return {
    inputs: def.inputs.map((_, i) => getPortWorldPos(node, i, true)),
    outputs: def.outputs.map((_, i) => getPortWorldPos(node, i, false)),
  };
}

function dist(a, b) {
  return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}

function showToast(msg) {
  const toast = document.getElementById('toast');
  toast.textContent = msg;
  toast.classList.add('show');
  clearTimeout(toast._timeout);
  toast._timeout = setTimeout(() => toast.classList.remove('show'), 2000);
}

/* ============================================================
   EVALUATION ENGINE (Topological)
   ============================================================ */
function evaluateNode(node, inputValues) {
  const def = NODE_DEFS[node.type];
  
  if (node.type === 'toggle' || node.type === 'clock') return; // sources
  
  let result;
  switch (node.type) {
    case 'and':  result = inputValues[0] && inputValues[1]; break;
    case 'or':   result = inputValues[0] || inputValues[1]; break;
    case 'not':  result = !inputValues[0]; break;
    case 'xor':  result = (inputValues[0] ? 1 : 0) ^ (inputValues[1] ? 1 : 0); break;
    case 'nand': result = !(inputValues[0] && inputValues[1]); break;
    case 'nor':  result = !(inputValues[0] || inputValues[1]); break;
    case 'lamp':
      result = inputValues[0] || false; break;
    case 'hexdisplay':
      // Hex display doesn't produce a boolean output — store raw bits for rendering
      node._inputBits = inputValues.map(v => v ? 1 : 0);
      return;
    default: return;
  }
  
  node.state = !!result;
}

function getTopoOrder() {
  const nodes = scene.nodes;
  const adj = {}; // nodeId -> set of downstream nodeIds
  const inDeg = {};
  
  for (const n of nodes) {
    adj[n.id] = new Set();
    inDeg[n.id] = 0;
  }
  
  for (const w of scene.wires) {
    if (!adj[w.from]) continue;
    if (!adj[w.to]) continue;
    if (!adj[w.from].has(w.to)) {
      adj[w.from].add(w.to);
      inDeg[w.to]++;
    }
  }
  
  // Kahn's algorithm
  const queue = [];
  for (const n of nodes) {
    if (inDeg[n.id] === 0) queue.push(n.id);
  }
  
  const order = [];
  while (queue.length > 0) {
    const id = queue.shift();
    order.push(id);
    for (const next of adj[id]) {
      inDeg[next]--;
      if (inDeg[next] === 0) queue.push(next);
    }
  }
  
  return order; // nodes that are part of cycles won't appear — but we prevent cycles at wire time
}

function evaluateAll() {
  const order = getTopoOrder();
  for (const nodeId of order) {
    const node = scene.nodes.find(n => n.id === nodeId);
    if (!node) continue;
    
    // Gather input values from wires
    const def = NODE_DEFS[node.type];
    const inputValues = [];
    for (let i = 0; i < def.inputs.length; i++) {
      const wire = scene.wires.find(w => w.to === nodeId && w.toPortIdx === i);
      if (!wire) {
        inputValues.push(false); // unconnected inputs default to false
      } else {
        const srcNode = scene.nodes.find(n => n.id === wire.from);
        if (srcNode) {
          inputValues.push(srcNode.state || false);
        } else {
          inputValues.push(false);
        }
      }
    }
    
    evaluateNode(node, inputValues);
  }
}

function evaluateFromSource(sourceNodeId) {
  // BFS/DFS from source through downstream nodes in topo order
  const visited = new Set();
  const toVisit = [sourceNodeId];
  
  while (toVisit.length > 0) {
    const id = toVisit.shift();
    if (visited.has(id)) continue;
    visited.add(id);
    
    const node = scene.nodes.find(n => n.id === id);
    if (!node) continue;
    
    // Gather inputs for this node
    const def = NODE_DEFS[node.type];
    if (def.inputs.length > 0 && !['toggle','clock'].includes(node.type)) {
      const inputValues = [];
      let allInputsReady = true;
      for (let i = 0; i < def.inputs.length; i++) {
        const wire = scene.wires.find(w => w.to === id && w.toPortIdx === i);
        if (!wire) {
          inputValues.push(false);
        } else {
          const srcNode = scene.nodes.find(n => n.id === wire.from);
          if (srcNode) {
            inputValues.push(srcNode.state || false);
          } else {
            inputValues.push(false);
          }
        }
      }
      evaluateNode(node, inputValues);
    }
    
    // Find downstream nodes
    for (const w of scene.wires) {
      if (w.from === id && !visited.has(w.to)) {
        toVisit.push(w.to);
      }
    }
  }
}

/* ============================================================
   CYCLE DETECTION
   ============================================================ */
function wouldCreateCycle(fromNodeId, toNodeId) {
  // Check if adding edge fromNode -> toNode creates a cycle
  // i.e., is there already a path from toNode back to fromNode?
  if (fromNodeId === toNodeId) return true;
  
  const visited = new Set();
  const queue = [toNodeId];
  
  while (queue.length > 0) {
    const current = queue.shift();
    if (current === fromNodeId) return true;
    if (visited.has(current)) continue;
    visited.add(current);
    
    // Follow outgoing wires from current
    for (const w of scene.wires) {
      if (w.from === current && !visited.has(w.to)) {
        queue.push(w.to);
      }
    }
  }
  
  return false;
}

/* ============================================================
   PERSISTENCE
   ============================================================ */
function saveScene() {
  try {
    localStorage.setItem('logicCircuit', JSON.stringify(scene));
  } catch(e) {}
}

function loadScene() {
  try {
    const data = localStorage.getItem('logicCircuit');
    if (data) {
      scene = JSON.parse(data);
      // Restore clock timers
      for (const node of scene.nodes) {
        if (node.type === 'clock') {
          startClock(node);
        }
      }
      evaluateAll();
      return true;
    }
  } catch(e) {}
  return false;
}

/* ============================================================
   CLOCK MANAGEMENT
   ============================================================ */
function startClock(node) {
  stopClock(node.id);
  const rate = node.rate || 0.5;
  clockStates[node.id] = false;
  
  clockTimers[node.id] = setInterval(() => {
    clockStates[node.id] = !clockStates[node.id];
    node.state = clockStates[node.id];
    evaluateFromSource(node.id);
    render();
  }, (1 / rate) * 1000);
}

function stopClock(nodeId) {
  if (clockTimers[nodeId]) {
    clearInterval(clockTimers[nodeId]);
    delete clockTimers[nodeId];
  }
}

/* ============================================================
   NODE CREATION & DELETION
   ============================================================ */
function createNode(type, x, y) {
  const def = NODE_DEFS[type];
  const node = {
    id: scene.nextId++,
    type: type,
    x: x,
    y: y,
    state: def.stateDefault !== undefined ? def.stateDefault : false,
  };
  if (type === 'clock') node.rate = def.rate;
  
  scene.nodes.push(node);
  
  if (type === 'clock') startClock(node);
  
  saveScene();
  render();
  return node;
}

function deleteNode(nodeId) {
  // Stop clock if applicable
  stopClock(nodeId);
  
  // Remove wires connected to this node
  scene.wires = scene.wires.filter(w => w.from !== nodeId && w.to !== nodeId);
  
  // Remove the node
  scene.nodes = scene.nodes.filter(n => n.id !== nodeId);
  
  saveScene();
  render();
}

function clearAll() {
  for (const node of scene.nodes) {
    stopClock(node.id);
  }
  scene.nodes = [];
  scene.wires = [];
  scene.nextId = 1;
  clockStates = {};
  saveScene();
  render();
}

/* ============================================================
   WIRE MANAGEMENT
   ============================================================ */
function addWire(fromNodeId, fromPortIdx, toNodeId, toPortIdx) {
  // Check cycle
  if (wouldCreateCycle(fromNodeId, toNodeId)) {
    showToast('⚠ Cycle detected — connection refused');
    return false;
  }
  
  // Remove existing wire on this input port
  scene.wires = scene.wires.filter(w => !(w.to === toNodeId && w.toPortIdx === toPortIdx));
  
  // Add new wire
  scene.wires.push({ from: fromNodeId, fromPortIdx, to: toNodeId, toPortIdx });
  
  evaluateAll();
  saveScene();
  render();
  return true;
}

function removeWire(wire) {
  scene.wires = scene.wires.filter(w => w !== wire);
  evaluateAll();
  saveScene();
  render();
}

/* ============================================================
   HIT TESTING
   ============================================================ */
function hitTestPort(wx, wy) {
  const tolerance = CONFIG.portHitTolerance / camera.zoom; // scale with zoom
  
  for (const node of scene.nodes) {
    const def = NODE_DEFS[node.type];
    
    // Check input ports
    for (let i = 0; i < def.inputs.length; i++) {
      const pos = getPortWorldPos(node, i, true);
      if (pos && dist({ x: wx, y: wy }, pos) <= tolerance + CONFIG.portRadius / camera.zoom) {
        return { node, portIdx: i, isInput: true };
      }
    }
    
    // Check output ports
    for (let i = 0; i < def.outputs.length; i++) {
      const pos = getPortWorldPos(node, i, false);
      if (pos && dist({ x: wx, y: wy }, pos) <= tolerance + CONFIG.portRadius / camera.zoom) {
        return { node, portIdx: i, isInput: false };
      }
    }
  }
  
  return null;
}

function hitTestNode(wx, wy) {
  // Check nodes in reverse order (top-most first)
  for (let i = scene.nodes.length - 1; i >= 0; i--) {
    const node = scene.nodes[i];
    const hw = CONFIG.nodeWidth / 2;
    const hh = CONFIG.nodeHeight / 2;
    
    if (wx >= node.x - hw && wx <= node.x + hw && wy >= node.y - hh && wy <= node.y + hh) {
      return node;
    }
  }
  return null;
}

/* ============================================================
   RENDERING
   ============================================================ */
function resizeCanvas() {
  const rect = container.getBoundingClientRect();
  canvas.width = rect.width * dpr;
  canvas.height = rect.height * dpr;
  canvas.style.width = rect.width + 'px';
  canvas.style.height = rect.height + 'px';
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

function render() {
  const w = canvas.width / dpr;
  const h = canvas.height / dpr;
  
  // Clear
  ctx.fillStyle = CONFIG.colors.bg;
  ctx.fillRect(0, 0, w, h);
  
  // Draw grid
  drawGrid(w, h);
  
  // Draw wires
  drawWires();
  
  // Draw wire preview if dragging
  if (interaction.mode === 'dragging-wire' && interaction.wireFrom) {
    drawWirePreview();
  }
  
  // Draw nodes
  for (const node of scene.nodes) {
    drawNode(node);
  }
}

function drawGrid(w, h) {
  const spacing = CONFIG.gridSpacing * camera.zoom;
  if (spacing < 8) return; // too dense
  
  const topLeft = screenToWorld(0, 0);
  const bottomRight = screenToWorld(w, h);
  
  const startX = Math.floor(topLeft.x / CONFIG.gridSpacing) * CONFIG.gridSpacing;
  const startY = Math.floor(topLeft.y / CONFIG.gridSpacing) * CONFIG.gridSpacing;
  
  ctx.fillStyle = CONFIG.colors.gridDot;
  
  for (let wx = startX; wx <= bottomRight.x; wx += CONFIG.gridSpacing) {
    for (let wy = startY; wy <= bottomRight.y; wy += CONFIG.gridSpacing) {
      const sp = worldToScreen(wx, wy);
      ctx.beginPath();
      ctx.arc(sp.x, sp.y, Math.max(1, 1.5 * camera.zoom), 0, Math.PI * 2);
      ctx.fill();
    }
  }
}

function drawWires() {
  for (const wire of scene.wires) {
    const fromNode = scene.nodes.find(n => n.id === wire.from);
    const toNode = scene.nodes.find(n => n.id === wire.to);
    if (!fromNode || !toNode) continue;
    
    const fromPos = getPortWorldPos(fromNode, wire.fromPortIdx, false);
    const toPos = getPortWorldPos(toNode, wire.toPortIdx, true);
    if (!fromPos || !toPos) continue;
    
    // Determine if energized
    const isOn = !!fromNode.state;
    
    drawBezierWire(fromPos, toPos, isOn ? CONFIG.colors.wireOn : CONFIG.colors.wireOff, isOn);
  }
}

function drawBezierWire(from, to, color, glow) {
  const fromS = worldToScreen(from.x, from.y);
  const toS = worldToScreen(to.x, to.y);
  
  // Bezier control points (horizontal curves)
  const dx = Math.abs(toS.x - fromS.x);
  const cpOffset = Math.max(40, dx * 0.4);
  
  const cp1x = fromS.x + cpOffset;
  const cp2x = toS.x - cpOffset;
  
  ctx.beginPath();
  ctx.moveTo(fromS.x, fromS.y);
  ctx.bezierCurveTo(cp1x, fromS.y, cp2x, toS.y, toS.x, toS.y);
  
  if (glow) {
    ctx.save();
    ctx.shadowColor = color;
    ctx.shadowBlur = 12 * camera.zoom;
    ctx.strokeStyle = color;
    ctx.lineWidth = 3 * camera.zoom;
    ctx.stroke();
    ctx.restore();
    
    // Inner bright line
    ctx.beginPath();
    ctx.moveTo(fromS.x, fromS.y);
    ctx.bezierCurveTo(cp1x, fromS.y, cp2x, toS.y, toS.x, toS.y);
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 1.5 * camera.zoom;
    ctx.globalAlpha = 0.4;
    ctx.stroke();
    ctx.globalAlpha = 1;
  } else {
    ctx.strokeStyle = color;
    ctx.lineWidth = 2 * camera.zoom;
    ctx.stroke();
  }
}

function drawWirePreview() {
  const fromNode = scene.nodes.find(n => n.id === interaction.wireFrom.nodeId);
  if (!fromNode) return;
  
  const fromPos = getPortWorldPos(fromNode, interaction.wireFrom.portIdx, false);
  if (!fromPos) return;
  
  const mouseWorld = interaction.wireMouseWorld;
  if (!mouseWorld) return;
  
  const fromS = worldToScreen(fromPos.x, fromPos.y);
  const toS = worldToScreen(mouseWorld.x, mouseWorld.y);
  
  // Check if hovering over a valid port
  const hit = hitTestPort(mouseWorld.x, mouseWorld.y);
  let isValidTarget = false;
  let isCycleError = false;
  
  if (hit && hit.isInput) {
    if (wouldCreateCycle(fromNode.id, hit.node.id)) {
      isCycleError = true;
    } else {
      isValidTarget = true;
    }
  }
  
  const dx = Math.abs(toS.x - fromS.x);
  const cpOffset = Math.max(40, dx * 0.4);
  
  ctx.beginPath();
  ctx.moveTo(fromS.x, fromS.y);
  ctx.bezierCurveTo(fromS.x + cpOffset, fromS.y, toS.x - cpOffset, toS.y, toS.x, toS.y);
  
  if (isCycleError) {
    ctx.strokeStyle = CONFIG.colors.wireCycleError;
    ctx.lineWidth = 3 * camera.zoom;
    ctx.setLineDash([8 * camera.zoom, 4 * camera.zoom]);
    ctx.stroke();
    ctx.setLineDash([]);
    
    // Red glow
    ctx.save();
    ctx.shadowColor = CONFIG.colors.wireCycleError;
    ctx.shadowBlur = 15 * camera.zoom;
    ctx.strokeStyle = 'rgba(233,69,96,0.5)';
    ctx.lineWidth = 6 * camera.zoom;
    ctx.stroke();
    ctx.restore();
  } else if (isValidTarget) {
    ctx.strokeStyle = CONFIG.colors.wireOn;
    ctx.lineWidth = 3 * camera.zoom;
    ctx.save();
    ctx.shadowColor = CONFIG.colors.wireOn;
    ctx.shadowBlur = 10 * camera.zoom;
    ctx.stroke();
    ctx.restore();
  } else {
    ctx.strokeStyle = CONFIG.colors.wirePreview;
    ctx.lineWidth = 2 * camera.zoom;
    ctx.setLineDash([6 * camera.zoom, 4 * camera.zoom]);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

function drawNode(node) {
  const def = NODE_DEFS[node.type];
  const hw = CONFIG.nodeWidth / 2;
  const hh = CONFIG.nodeHeight / 2;
  
  const pos = worldToScreen(node.x, node.y);
  const nw = CONFIG.nodeWidth * camera.zoom;
  const nh = CONFIG.nodeHeight * camera.zoom;
  
  // Node body
  ctx.save();
  
  // Shadow
  ctx.shadowColor = 'rgba(0,0,0,0.4)';
  ctx.shadowBlur = 12 * camera.zoom;
  ctx.shadowOffsetY = 4 * camera.zoom;
  
  const isSelected = interaction.selectedNodeId === node.id;
  const borderColor = isSelected ? CONFIG.colors.nodeSelected : CONFIG.colors.nodeBorder;
  
  // Rounded rect
  const r = 8 * camera.zoom;
  ctx.beginPath();
  ctx.moveTo(pos.x - hw + r, pos.y - hh);
  ctx.lineTo(pos.x + hw - r, pos.y - hh);
  ctx.quadraticCurveTo(pos.x + hw, pos.y - hh, pos.x + hw, pos.y - hh + r);
  ctx.lineTo(pos.x + hw, pos.y + hh - r);
  ctx.quadraticCurveTo(pos.x + hw, pos.y + hh, pos.x + hw - r, pos.y + hh);
  ctx.lineTo(pos.x - hw + r, pos.y + hh);
  ctx.quadraticCurveTo(pos.x - hw, pos.y + hh, pos.x - hw, pos.y + hh - r);
  ctx.lineTo(pos.x - hw, pos.y - hh + r);
  ctx.quadraticCurveTo(pos.x - hw, pos.y - hh, pos.x - hw + r, pos.y - hh);
  ctx.closePath();
  
  // Fill with state tint
  let fillColor = CONFIG.colors.nodeBg;
  if (node.type === 'lamp' && node.state) {
    fillColor = '#3a2a10';
  } else if (['and','or','not','xor','nand','nor'].includes(node.type)) {
    const onAlpha = node.state ? 0.3 : 0;
    fillColor = `rgba(95,39,205,${onAlpha + 0.1})`;
  } else if (node.type === 'toggle') {
    fillColor = node.state ? '#1a3a2e' : CONFIG.colors.nodeBg;
  } else if (node.type === 'clock') {
    fillColor = node.state ? '#3a2a10' : CONFIG.colors.nodeBg;
  }
  
  ctx.fillStyle = fillColor;
  ctx.fill();
  
  // Border
  ctx.shadowColor = 'transparent';
  ctx.strokeStyle = borderColor;
  ctx.lineWidth = isSelected ? 2.5 * camera.zoom : 1.5 * camera.zoom;
  ctx.stroke();
  
  ctx.restore();
  
  // Label
  const fontSize = Math.max(10, 13 * camera.zoom);
  ctx.font = `bold ${fontSize}px 'Segoe UI', system-ui, sans-serif`;
  ctx.fillStyle = CONFIG.colors.textPrimary;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  
  // For hex display, show the hex value
  if (node.type === 'hexdisplay') {
    const bits = [];
    for (let i = 0; i < 4; i++) {
      const wire = scene.wires.find(w => w.to === node.id && w.toPortIdx === i);
      if (wire) {
        const srcNode = scene.nodes.find(n => n.id === wire.from);
        bits.push(srcNode ? (srcNode.state ? 1 : 0) : 0);
      } else {
        bits.push(0);
      }
    }
    const val = bits[3] * 8 + bits[2] * 4 + bits[1] * 2 + bits[0];
    const hexChar = val.toString(16).toUpperCase();
    
    // Draw hex digit in a box
    const hexSize = nh * 0.7;
    ctx.fillStyle = '#0a0a1e';
    ctx.fillRect(pos.x - hexSize/2, pos.y - hexSize/2 + 4*camera.zoom, hexSize, hexSize);
    ctx.strokeStyle = node.state !== undefined ? (val > 0 ? CONFIG.colors.lampOn : '#333') : '#333';
    ctx.lineWidth = 1.5 * camera.zoom;
    ctx.strokeRect(pos.x - hexSize/2, pos.y - hexSize/2 + 4*camera.zoom, hexSize, hexSize);
    
    const hexFontSize = Math.max(14, 20 * camera.zoom);
    ctx.font = `bold ${hexFontSize}px monospace`;
    ctx.fillStyle = val > 0 ? CONFIG.colors.lampOn : '#555';
    ctx.fillText(hexChar, pos.x, pos.y + 4*camera.zoom);
    
    // Label above
    const labelSize = Math.max(8, 10 * camera.zoom);
    ctx.font = `${labelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = CONFIG.colors.textSecondary;
    ctx.fillText('HEX', pos.x, pos.y - hh + 12*camera.zoom);
    
  } else if (node.type === 'lamp') {
    // Draw lamp circle
    const lampR = nh * 0.35;
    ctx.beginPath();
    ctx.arc(pos.x, pos.y + 4*camera.zoom, lampR, 0, Math.PI * 2);
    
    if (node.state) {
      ctx.save();
      ctx.shadowColor = CONFIG.colors.lampOn;
      ctx.shadowBlur = 20 * camera.zoom;
      ctx.fillStyle = CONFIG.colors.lampOn;
      ctx.fill();
      ctx.restore();
      
      // Inner glow
      ctx.beginPath();
      ctx.arc(pos.x, pos.y + 4*camera.zoom, lampR * 0.6, 0, Math.PI * 2);
      ctx.fillStyle = '#fff';
      ctx.globalAlpha = 0.3;
      ctx.fill();
      ctx.globalAlpha = 1;
    } else {
      ctx.fillStyle = CONFIG.colors.lampOff;
      ctx.fill();
      ctx.strokeStyle = 'rgba(253,203,110,0.3)';
      ctx.lineWidth = 1.5 * camera.zoom;
      ctx.stroke();
    }
    
    // Label above
    const labelSize = Math.max(8, 10 * camera.zoom);
    ctx.font = `${labelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = CONFIG.colors.textSecondary;
    ctx.fillText('LAMP', pos.x, pos.y - hh + 12*camera.zoom);
    
  } else if (node.type === 'toggle') {
    // Draw toggle switch indicator
    const swW = nw * 0.4;
    const swH = nh * 0.35;
    const swX = pos.x - swW/2;
    const swY = pos.y + 2*camera.zoom;
    
    ctx.fillStyle = node.state ? '#1a3a2e' : '#2a1a1a';
    ctx.beginPath();
    ctx.roundRect(swX, swY, swW, swH, 4*camera.zoom);
    ctx.fill();
    ctx.strokeStyle = node.state ? CONFIG.colors.inputPort : '#666';
    ctx.lineWidth = 1.5 * camera.zoom;
    ctx.stroke();
    
    // Knob
    const knobR = swH/2 - 2*camera.zoom;
    const knobX = node.state ? pos.x + swW/4 : pos.x - swW/4;
    ctx.beginPath();
    ctx.arc(knobX, swY + swH/2, knobR, 0, Math.PI * 2);
    ctx.fillStyle = node.state ? CONFIG.colors.inputPort : '#888';
    ctx.fill();
    
    // Label above
    const labelSize = Math.max(8, 10 * camera.zoom);
    ctx.font = `${labelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = CONFIG.colors.textSecondary;
    ctx.fillText('TOGGLE', pos.x, pos.y - hh + 12*camera.zoom);
    
  } else if (node.type === 'clock') {
    // Draw clock indicator
    const pulseR = nh * 0.3;
    ctx.beginPath();
    ctx.arc(pos.x, pos.y + 4*camera.zoom, pulseR, 0, Math.PI * 2);
    
    if (node.state) {
      ctx.save();
      ctx.shadowColor = CONFIG.colors.outputPort;
      ctx.shadowBlur = 15 * camera.zoom;
      ctx.fillStyle = CONFIG.colors.outputPort;
      ctx.fill();
      ctx.restore();
    } else {
      ctx.fillStyle = 'rgba(247,183,49,0.15)';
      ctx.fill();
      ctx.strokeStyle = 'rgba(247,183,49,0.3)';
      ctx.lineWidth = 1.5 * camera.zoom;
      ctx.stroke();
    }
    
    // Label above
    const labelSize = Math.max(8, 10 * camera.zoom);
    ctx.font = `${labelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = CONFIG.colors.textSecondary;
    ctx.fillText('CLOCK', pos.x, pos.y - hh + 12*camera.zoom);
    
  } else {
    // Gate label centered
    ctx.fillText(def.label.toUpperCase(), pos.x, pos.y);
  }
  
  // Draw ports
  const portR = CONFIG.portRadius * camera.zoom;
  
  // Input ports (left side)
  for (let i = 0; i < def.inputs.length; i++) {
    const pPos = getPortWorldPos(node, i, true);
    if (!pPos) continue;
    const sp = worldToScreen(pPos.x, pPos.y);
    
    // Check if this port has a wire connected
    const hasWire = scene.wires.some(w => w.to === node.id && w.toPortIdx === i);
    const wireOn = hasWire ? (() => {
      const srcNode = scene.nodes.find(n => n.id === scene.wires.find(w => w.to === node.id && w.toPortIdx === i).from);
      return srcNode ? !!srcNode.state : false;
    })() : false;
    
    ctx.beginPath();
    ctx.arc(sp.x, sp.y, portR, 0, Math.PI * 2);
    ctx.fillStyle = hasWire && wireOn ? CONFIG.colors.inputPort : 'rgba(78,204,163,0.3)';
    ctx.fill();
    ctx.strokeStyle = CONFIG.colors.inputPort;
    ctx.lineWidth = 1.5 * camera.zoom;
    ctx.stroke();
    
    // Port label
    const portLabelSize = Math.max(7, 9 * camera.zoom);
    ctx.font = `${portLabelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = CONFIG.colors.textSecondary;
    ctx.textAlign = 'right';
    ctx.fillText(def.inputs[i], sp.x - portR - 4*camera.zoom, sp.y + 1);
    ctx.textAlign = 'center';
  }
  
  // Output ports (right side)
  for (let i = 0; i < def.outputs.length; i++) {
    const pPos = getPortWorldPos(node, i, false);
    if (!pPos) continue;
    const sp = worldToScreen(pPos.x, pPos.y);
    
    ctx.beginPath();
    ctx.arc(sp.x, sp.y, portR, 0, Math.PI * 2);
    ctx.fillStyle = node.state ? CONFIG.colors.outputPort : 'rgba(247,183,49,0.3)';
    ctx.fill();
    ctx.strokeStyle = CONFIG.colors.outputPort;
    ctx.lineWidth = 1.5 * camera.zoom;
    ctx.stroke();
    
    // Port label
    const portLabelSize = Math.max(7, 9 * camera.zoom);
    ctx.font = `${portLabelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = CONFIG.colors.textSecondary;
    ctx.textAlign = 'left';
    ctx.fillText(def.outputs[i], sp.x + portR + 4*camera.zoom, sp.y + 1);
    ctx.textAlign = 'center';
  }
}

/* ============================================================
   INPUT HANDLING
   ============================================================ */
function getMouseWorldPos(e) {
  const rect = canvas.getBoundingClientRect();
  return screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
}

canvas.addEventListener('mousedown', (e) => {
  if (e.button !== 0) return;
  
  const worldPos = getMouseWorldPos(e);
  
  // Check port hit first
  const portHit = hitTestPort(worldPos.x, worldPos.y);
  if (portHit) {
    if (!portHit.isInput) {
      // Start dragging wire from output port
      interaction.mode = 'dragging-wire';
      interaction.wireFrom = { nodeId: portHit.node.id, portIdx: portHit.portIdx };
      interaction.wireMouseWorld = { x: worldPos.x, y: worldPos.y };
      container.classList.add('wire-dragging');
      return;
    } else {
      // Check if this input has an existing wire — pick it up to re-route
      const existingWire = scene.wires.find(w => w.to === portHit.node.id && w.toPortIdx === portHit.portIdx);
      if (existingWire) {
        const fromNode = scene.nodes.find(n => n.id === existingWire.from);
        if (fromNode) {
          interaction.mode = 'dragging-wire';
          interaction.wireFrom = { nodeId: existingWire.from, portIdx: existingWire.fromPortIdx };
          interaction._replacingWire = existingWire;
          interaction.wireMouseWorld = { x: worldPos.x, y: worldPos.y };
          container.classList.add('wire-dragging');
          return;
        }
      }
    }
  }
  
  // Check node hit (for dragging)
  const nodeHit = hitTestNode(worldPos.x, worldPos.y);
  if (nodeHit) {
    interaction.mode = 'dragging-node';
    interaction.dragNode = nodeHit;
    interaction.dragOffset = { x: worldPos.x - nodeHit.x, y: worldPos.y - nodeHit.y };
    interaction.selectedNodeId = nodeHit.id;
    
    // Bring to front
    scene.nodes = scene.nodes.filter(n => n.id !== nodeHit.id);
    scene.nodes.push(nodeHit);
    
    render();
    return;
  }
  
  // Pan mode
  interaction.mode = 'panning';
  interaction.panStart = { x: e.clientX, y: e.clientY, camX: camera.x, camY: camera.y };
  container.classList.add('grabbing');
  
  // Deselect
  interaction.selectedNodeId = null;
  render();
});

canvas.addEventListener('mousemove', (e) => {
  const worldPos = getMouseWorldPos(e);
  
  if (interaction.mode === 'panning' && interaction.panStart) {
    const dx = e.clientX - interaction.panStart.x;
    const dy = e.clientY - interaction.panStart.y;
    camera.x = interaction.panStart.camX - dx / camera.zoom;
    camera.y = interaction.panStart.camY - dy / camera.zoom;
    render();
  } else if (interaction.mode === 'dragging-node' && interaction.dragNode) {
    const node = interaction.dragNode;
    node.x = worldPos.x - interaction.dragOffset.x;
    node.y = worldPos.y - interaction.dragOffset.y;
    
    // Snap to grid
    node.x = Math.round(node.x / CONFIG.gridSpacing) * CONFIG.gridSpacing;
    node.y = Math.round(node.y / CONFIG.gridSpacing) * CONFIG.gridSpacing;
    
    render();
  } else if (interaction.mode === 'dragging-wire') {
    interaction.wireMouseWorld = { x: worldPos.x, y: worldPos.y };
    render();
  }
});

canvas.addEventListener('mouseup', (e) => {
  const worldPos = getMouseWorldPos(e);
  
  if (interaction.mode === 'dragging-wire' && interaction.wireFrom) {
    // Check if dropped on a valid input port
    const portHit = hitTestPort(worldPos.x, worldPos.y);
    
    if (portHit && portHit.isInput) {
      // Don't connect to same node
      if (portHit.node.id !== interaction.wireFrom.nodeId) {
        // Check cycle
        if (!wouldCreateCycle(interaction.wireFrom.nodeId, portHit.node.id)) {
          // Remove replacing wire first
          if (interaction._replacingWire) {
            scene.wires = scene.wires.filter(w => w !== interaction._replacingWire);
          }
          
          addWire(
            interaction.wireFrom.nodeId,
            interaction.wireFrom.portIdx,
            portHit.node.id,
            portHit.portIdx
          );
        } else {
          showToast('⚠ Cycle detected — connection refused');
          if (interaction._replacingWire) {
            // Restore the wire we picked up
            scene.wires.push(interaction._replacingWire);
            evaluateAll();
            saveScene();
          }
        }
      } else {
        showToast('Cannot connect a node to itself');
        if (interaction._replacingWire) {
          scene.wires.push(interaction._replacingWire);
          evaluateAll();
          saveScene();
        }
      }
    } else {
      // Dropped on nothing — restore wire if we picked one up
      if (interaction._replacingWire) {
        scene.wires.push(interaction._replacingWire);
        evaluateAll();
        saveScene();
      }
    }
    
    interaction._replacingWire = null;
  } else if (interaction.mode === 'dragging-node') {
    // Save node position
    saveScene();
  } else if (interaction.mode === 'panning') {
    container.classList.remove('grabbing');
  }
  
  interaction.mode = 'idle';
  interaction.wireFrom = null;
  interaction.dragNode = null;
  render();
});

// Double-click on toggle to flip state
canvas.addEventListener('dblclick', (e) => {
  const worldPos = getMouseWorldPos(e);
  
  // Check port hit first — ignore ports
  if (hitTestPort(worldPos.x, worldPos.y)) return;
  
  const nodeHit = hitTestNode(worldPos.x, worldPos.y);
  if (nodeHit && nodeHit.type === 'toggle') {
    nodeHit.state = !nodeHit.state;
    evaluateFromSource(nodeHit.id);
    saveScene();
    render();
  } else if (nodeHit && nodeHit.type === 'clock') {
    // Show clock rate popup
    showClockPopup(nodeHit, e.clientX, e.clientY);
  }
});

// Single click on toggle to flip state
canvas.addEventListener('click', (e) => {
  const worldPos = getMouseWorldPos(e);
  
  // Check port hit first — ignore ports
  if (hitTestPort(worldPos.x, worldPos.y)) return;
  
  const nodeHit = hitTestNode(worldPos.x, worldPos.y);
  if (nodeHit && nodeHit.type === 'toggle') {
    nodeHit.state = !nodeHit.state;
    evaluateFromSource(nodeHit.id);
    saveScene();
    render();
  }
});

// Zoom with mouse wheel
canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  
  const rect = canvas.getBoundingClientRect();
  const mx = e.clientX - rect.left;
  const my = e.clientY - rect.top;
  
  // World point under cursor before zoom
  const worldBefore = screenToWorld(mx, my);
  
  // Apply zoom
  if (e.deltaY < 0) {
    camera.zoom = Math.min(CONFIG.zoomMax, camera.zoom * CONFIG.zoomStep);
  } else {
    camera.zoom = Math.max(CONFIG.zoomMin, camera.zoom / CONFIG.zoomStep);
  }
  
  // Adjust camera so world point stays under cursor
  const worldAfter = screenToWorld(mx, my);
  camera.x -= (worldAfter.x - worldBefore.x);
  camera.y -= (worldAfter.y - worldBefore.y);
  
  zoomIndicator.textContent = Math.round(camera.zoom * 100) + '%';
  render();
}, { passive: false });

// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
  if (e.key === 'Delete' || e.key === 'Backspace') {
    if (interaction.selectedNodeId !== null && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'SELECT') {
      deleteNode(interaction.selectedNodeId);
      interaction.selectedNodeId = null;
    }
  }
});

/* ============================================================
   CLOCK POPUP
   ============================================================ */
function showClockPopup(node, cx, cy) {
  const popup = document.getElementById('clockPopup');
  const select = document.getElementById('clockRateSelect');
  
  select.value = node.rate || 0.5;
  popup.style.left = cx + 'px';
  popup.style.top = cy + 'px';
  popup.style.display = 'block';
  
  const closeAndApply = () => {
    const newRate = parseFloat(select.value);
    if (newRate !== node.rate) {
      node.rate = newRate;
      startClock(node);
      saveScene();
      render();
    }
    popup.style.display = 'none';
  };
  
  select.onchange = closeAndApply;
  
  // Close on outside click
  const handler = (e) => {
    if (!popup.contains(e.target)) {
      document.removeEventListener('mousedown', handler);
      closeAndApply();
    }
  };
  setTimeout(() => document.addEventListener('mousedown', handler), 0);
}

/* ============================================================
   SIDEBAR BUTTONS
   ============================================================ */
document.querySelectorAll('.node-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const type = btn.dataset.type;
    
    // Place node at center of viewport in world coords
    const centerWorld = screenToWorld(canvas.width / (2 * dpr), canvas.height / (2 * dpr));
    
    // Offset slightly to avoid stacking
    const offset = scene.nodes.length * 15;
    createNode(type, centerWorld.x + offset, centerWorld.y + offset);
  });
});

document.getElementById('clearBtn').addEventListener('click', () => {
  if (scene.nodes.length === 0) return;
  clearAll();
  showToast('Circuit cleared');
});

/* ============================================================
   CONTEXT MENU (right-click to add nodes)
   ============================================================ */
const contextMenu = document.getElementById('contextMenu');

canvas.addEventListener('contextmenu', (e) => {
  e.preventDefault();
  
  const worldPos = getMouseWorldPos(e);
  
  // Don't show if clicking on a node or port
  if (hitTestPort(worldPos.x, worldPos.y)) return;
  if (hitTestNode(worldPos.x, worldPos.y)) return;
  
  contextMenu.innerHTML = '';
  
  const categories = [
    { title: 'Inputs', types: ['toggle', 'clock'] },
    { title: 'Gates', types: ['and', 'or', 'not', 'xor', 'nand', 'nor'] },
    { title: 'Outputs', types: ['lamp', 'hexdisplay'] },
  ];
  
  categories.forEach(cat => {
    const sep = document.createElement('div');
    sep.className = 'ctx-sep';
    contextMenu.appendChild(sep);
    
    cat.types.forEach(type => {
      const def = NODE_DEFS[type];
      const item = document.createElement('div');
      item.className = 'ctx-item';
      item.innerHTML = `<span style="color:${def.color}">●</span> ${def.label}`;
      item.addEventListener('click', () => {
        createNode(type, worldPos.x, worldPos.y);
        contextMenu.style.display = 'none';
      });
      contextMenu.appendChild(item);
    });
  });
  
  contextMenu.style.left = e.clientX + 'px';
  contextMenu.style.top = e.clientY + 'px';
  contextMenu.style.display = 'block';
});

document.addEventListener('click', (e) => {
  if (!contextMenu.contains(e.target)) {
    contextMenu.style.display = 'none';
  }
});

/* ============================================================
   INITIALIZATION
   ============================================================ */
function init() {
  resizeCanvas();
  
  // Try to load saved scene
  const loaded = loadScene();
  
  if (!loaded) {
    // Create a demo circuit
    createNode('toggle', -200, -100);
    createNode('toggle', -200, 50);
    createNode('and', 50, -25);
    createNode('not', 50, 100);
    createNode('lamp', 250, -25);
    
    // Wire: toggle1 -> AND.A, toggle2 -> AND.B
    addWire(1, 0, 3, 0); // Toggle1 Out -> AND A
    addWire(2, 0, 3, 1); // Toggle2 Out -> AND B
    
    // Wire: toggle2 -> NOT.A (re-wire demo)
    addWire(2, 0, 4, 0); // Toggle2 Out -> NOT A
    
    // Wire: AND.Out -> Lamp.In
    addWire(3, 0, 5, 0); // AND Out -> Lamp In
    
    evaluateAll();
    saveScene();
  }
  
  render();
}

window.addEventListener('resize', () => {
  resizeCanvas();
  render();
});

// Polyfill roundRect if needed
if (!ctx.roundRect) {
  CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
    if (typeof r === 'number') r = [r];
    const radii = r.map(v => Math.min(v, w/2, h/2));
    const rad = radii[0] || 0;
    this.moveTo(x + rad, y);
    this.lineTo(x + w - rad, y);
    this.quadraticCurveTo(x + w, y, x + w, y + rad);
    this.lineTo(x + w, y + h - rad);
    this.quadraticCurveTo(x + w, y + h, x + w - rad, y + h);
    this.lineTo(x + rad, y + h);
    this.quadraticCurveTo(x, y + h, x, y + h - rad);
    this.lineTo(x, y + rad);
    this.quadraticCurveTo(x, y, x + rad, y);
    this.closePath();
  };
}

init();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"bc6c345f-09a7-4d51-98c7-31e17b36dd16","tokensIn":701437,"tokensOut":23402,"tokensTotal":724839,"turns":25,"toolCalls":24,"failedToolCalls":0,"timestamp":"2026-07-29T21:26:34.153Z"} -->