← Node Graph Editor results

Node Graph Editor

Qwen 3.6 27b MTP · composer

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 45,270 bytes · SHA-256 e1ece551510a
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logic Circuit Node-Graph Editor</title>
<style>
/* ── css: layout ── */
* { 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: #c8d6e5; }
#app { display: flex; flex-direction: column; width: 100%; height: 100%; }
#canvas-container { flex: 1; position: relative; overflow: hidden; cursor: grab; }
#canvas-container.grabbing { cursor: grabbing; }
#graphCanvas { display: block; width: 100%; height: 100%; }
#info-bar { background: #16213e; padding: 4px 12px; font-size: 11px; color: #576574; text-align: center; border-top: 1px solid #0f3460; user-select: none; }
/* ── css: palette ── */
#palette { position: absolute; top: 8px; left: 8px; width: 150px; background: rgba(22, 33, 62, 0.95); border-radius: 10px; padding: 10px; z-index: 10; box-shadow: 0 4px 20px rgba(0,0,0,0.5); border: 1px solid #0f3460; user-select: none; }
#palette h3 { font-size: 13px; color: #e94560; margin-bottom: 8px; text-align: center; letter-spacing: 1px; }
.palette-section { margin-top: 8px; }
.section-label { font-size: 10px; color: #576574; text-transform: uppercase; letter-spacing: 1px; display: block; margin-bottom: 3px; }
#palette button { width: 100%; padding: 6px 8px; margin: 2px 0; background: linear-gradient(135deg, #1a1a2e, #16213e); border: 1px solid #0f3460; border-radius: 6px; color: #c8d6e5; font-size: 12px; cursor: pointer; transition: all 0.15s ease; text-align: left; }
#palette button:hover { background: linear-gradient(135deg, #0f3460, #1a1a2e); border-color: #e94560; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(233, 69, 96, 0.3); }
#palette button:active { transform: translateY(0); }
</style>
</head>
<body>
<div id="canvas-container">
  <canvas id="graphCanvas"></canvas>
</div>
<div id="palette">
  <h3>Add Nodes</h3>
  <div class="palette-section"><span class="section-label">Inputs</span></div>
  <button data-node-type="toggle" title="Toggle switch (click to flip)">⊘ Toggle</button>
  <button data-node-type="clock" title="Clock pulse generator">◷ Clock</button>
  <div class="palette-section"><span class="section-label">Gates</span></div>
  <button data-node-type="and" title="AND gate (2 inputs)">⊼ AND</button>
  <button data-node-type="or" title="OR gate (2 inputs)">⊽ OR</button>
  <button data-node-type="not" title="NOT gate (1 input)">¬ NOT</button>
  <button data-node-type="xor" title="XOR gate (2 inputs)">⊕ XOR</button>
  <button data-node-type="nand" title="NAND gate (2 inputs)">⊼ NAND</button>
  <button data-node-type="nor" title="NOR gate (2 inputs)">⊽ NOR</button>
  <div class="palette-section"><span class="section-label">Outputs</span></div>
  <button data-node-type="lamp" title="Output lamp">💡 Lamp</button>
<button id="clearAllBtn" style="margin-top:10px; border-color:#e94560; color:#e94560;">✕ Clear All</button></div>
<div id="info-bar">Scroll to zoom · Drag background to pan · Click toggle to flip · Delete key removes selected node</div>
<script>
'use strict';

// ── const: CONSTANTS ──
// ============================================================
// CONSTANTS & CONFIGURATION
// ============================================================
const CONSTANTS = {
  // Colors
  colors: {
    bg: '#1a1a2e',
    gridDot: 'rgba(255,255,255,0.06)',
    nodeBg: '#16213e',
    nodeBorder: '#0f3460',
    nodeSelected: '#e94560',
    nodeHover: '#1a1a4e',
    portInput: '#4fc3f7',   // cool blue for inputs
    portOutput: '#ff8a65',  // warm orange for outputs
    wireDim: 'rgba(255,255,255,0.15)',
    wireEnergized: '#e94560',
    wirePreview: 'rgba(233, 69, 96, 0.5)',
    wireCycleReject: '#ff1744',
    lampOff: '#333355',
    lampOn: '#ffd700',
    toggleOff: '#333355',
    toggleOn: '#4caf50',
    clockPulse: '#e94560',
    textPrimary: '#c8d6e5',
    textSecondary: '#576574',
  },
  // Zoom
  zoomMin: 0.3,
  zoomMax: 2.5,
  zoomStep: 1.1,
  // Node dimensions
  nodeWidth: 120,
  nodeHeight: 50,
  notNodeHeight: 46,
  lampNodeHeight: 50,
  headerHeight: 22,
  portRadius: 7,
  portHitTolerance: 12, // in world coords
  // Grid
  gridSize: 30,
  gridDotSize: 1.5,
  // Animation
  wireGlowPulseSpeed: 2,
};

// ── const: NODE_TYPES ──
// Node type definitions
const NODE_TYPES = {
  toggle: { label: 'Toggle', inputs: [], outputs: ['out'], category: 'input' },
  clock: { label: 'Clock', inputs: [], outputs: ['clk'], category: 'input' },
  and: { label: 'AND', inputs: ['A', 'B'], outputs: ['out'], category: 'gate' },
  or: { label: 'OR', inputs: ['A', 'B'], outputs: ['out'], category: 'gate' },
  not: { label: 'NOT', inputs: ['in'], outputs: ['out'], category: 'gate' },
  xor: { label: 'XOR', inputs: ['A', 'B'], outputs: ['out'], category: 'gate' },
  nand: { label: 'NAND', inputs: ['A', 'B'], outputs: ['out'], category: 'gate' },
  nor: { label: 'NOR', inputs: ['A', 'B'], outputs: ['out'], category: 'gate' },
  lamp: { label: 'Lamp', inputs: ['in'], outputs: [], category: 'output' },
};

// ── const: GATE_FUNCS ──
// Gate truth table functions
const GATE_FUNCS = {
  and: (a, b) => a && b,
  or: (a, b) => a || b,
  not: (a) => !a,
  xor: (a, b) => a !== b,
  nand: (a, b) => !(a && b),
  nor: (a, b) => !(a || b),
};

// ── let: nextNodeId ──
let nextNodeId = 1;

// ── function: createScene ──
// ============================================================
// SCENE DATA MODEL & FACTORY FUNCTIONS
// ============================================================

function createScene() {
  return { nodes: [], wires: [] };
}

// ── function: createNode ──
function createNode(type, x, y) {
  const def = NODE_TYPES[type];
  if (!def) throw new Error('Unknown node type: ' + type);
  const state = {};
  // Initialize input states to false
  for (const inp of def.inputs) state[inp] = false;
  // Output state
  if (type === 'toggle') state.out = false;
  else if (type === 'clock') { state.clk = false; state.rate = 1000; }
  else if (GATE_FUNCS[type]) state.out = false;
  else if (type === 'lamp') state.in = false;

  return { id: nextNodeId++, type, x, y, state };
}

// ── function: createWire ──
function createWire(fromId, fromPort, toId, toPort) {
  return { from: fromId, fromPort, to: toId, toPort };
}

// ── function: getNodeHeight ──
// Get node height based on type
function getNodeHeight(node) {
  const def = NODE_TYPES[node.type];
  if (node.type === 'not') return CONSTANTS.notNodeHeight;
  // Count input ports for height calculation
  const numInputs = def.inputs.length;
  const numOutputs = def.outputs.length;
  const maxPorts = Math.max(numInputs, numOutputs);
  return CONSTANTS.headerHeight + (maxPorts + 1) * 20 + 6;
}

// ── function: getNodeWidth ──
function getNodeWidth(node) {
  return CONSTANTS.nodeWidth;
}

// ── function: getPortPositions ──
// Get port positions for a node in world coordinates
function getPortPositions(node) {
  const def = NODE_TYPES[node.type];
  const w = getNodeWidth(node);
  const h = getNodeHeight(node);
  const numInputs = def.inputs.length;
  const numOutputs = def.outputs.length;
  const maxPorts = Math.max(numInputs, numOutputs);

  const positions = { inputs: [], outputs: [] };

  for (let i = 0; i < numInputs; i++) {
    positions.inputs.push({
      name: def.inputs[i],
      x: node.x,
      y: node.y + CONSTANTS.headerHeight + 14 + i * 20,
    });
  }

  for (let i = 0; i < numOutputs; i++) {
    positions.outputs.push({
      name: def.outputs[i],
      x: node.x + w,
      y: node.y + CONSTANTS.headerHeight + 14 + i * 20,
    });
  }

  return positions;
}

// ── function: getInputPortWorld ──
// Get all input port world coords for a node
function getInputPortWorld(node) {
  const def = NODE_TYPES[node.type];
  const positions = [];
  for (let i = 0; i < def.inputs.length; i++) {
    positions.push({
      name: def.inputs[i],
      x: node.x,
      y: node.y + CONSTANTS.headerHeight + 14 + i * 20,
    });
  }
  return positions;
}

// ── function: getOutputPortWorld ──
// Get all output port world coords for a node
function getOutputPortWorld(node) {
  const def = NODE_TYPES[node.type];
  const positions = [];
  for (let i = 0; i < def.outputs.length; i++) {
    positions.push({
      name: def.outputs[i],
      x: node.x + getNodeWidth(node),
      y: node.y + CONSTANTS.headerHeight + 14 + i * 20,
    });
  }
  return positions;
}

// ── function: hitTestNodeBody ──
// Check if a point (world coords) is inside a node's body
function hitTestNodeBody(px, py, node) {
  const w = getNodeWidth(node);
  const h = getNodeHeight(node);
  return px >= node.x && px <= node.x + w && py >= node.y && py <= node.y + h;
}

// ── function: hitTestInputPort ──
// Check if a point (world coords) is on an input port of a node
function hitTestInputPort(px, py, node) {
  const def = NODE_TYPES[node.type];
  const tol = CONSTANTS.portHitTolerance;
  for (let i = 0; i < def.inputs.length; i++) {
    const px2 = node.x;
    const py2 = node.y + CONSTANTS.headerHeight + 14 + i * 20;
    if (Math.hypot(px - px2, py - py2) <= tol) {
      return { name: def.inputs[i], x: px2, y: py2 };
    }
  }
  return null;
}

// ── function: hitTestOutputPort ──
// Check if a point (world coords) is on an output port of a node
function hitTestOutputPort(px, py, node) {
  const def = NODE_TYPES[node.type];
  const tol = CONSTANTS.portHitTolerance;
  for (let i = 0; i < def.outputs.length; i++) {
    const px2 = node.x + getNodeWidth(node);
    const py2 = node.y + CONSTANTS.headerHeight + 14 + i * 20;
    if (Math.hypot(px - px2, py - py2) <= tol) {
      return { name: def.outputs[i], x: px2, y: py2 };
    }
  }
  return null;
}

// ── function: findWireToInput ──
// Find wire connected to a specific input port
function findWireToInput(scene, nodeId, portName) {
  return scene.wires.find(w => w.to === nodeId && w.toPort === portName);
}

// ── function: removeNodeWires ──
// Remove all wires from/to a node
function removeNodeWires(scene, nodeId) {
  scene.wires = scene.wires.filter(w => w.from !== nodeId && w.to !== nodeId);
}

// ── function: buildAdjacency ──
// ============================================================
// EVALUATION ENGINE — Topological + Reactive Propagation
// ============================================================

// Build adjacency list: node -> set of downstream nodes
function buildAdjacency(scene) {
  const adj = {}; // nodeId -> Set of downstream nodeIds
  for (const w of scene.wires) {
    if (!adj[w.from]) adj[w.from] = new Set();
    adj[w.from].add(w.to);
  }
  return adj;
}

// ── function: buildReverseAdjacency ──
// Build reverse adjacency: node -> set of upstream nodes
function buildReverseAdjacency(scene) {
  const rev = {};
  for (const w of scene.wires) {
    if (!rev[w.to]) rev[w.to] = new Set();
    rev[w.to].add(w.from);
  }
  return rev;
}

// ── function: wouldCreateCycle ──
// Check if adding a wire from -> to would create a cycle.
// Uses DFS from 'to' following existing wires to see if we can reach 'from'.
function wouldCreateCycle(scene, fromId, toId) {
  if (fromId === toId) return true; // self-loop

  const adj = buildAdjacency(scene);
  const visited = new Set();

  function dfs(current) {
    if (current === fromId) return true;
    if (visited.has(current)) return false;
    visited.add(current);
    for (const next of (adj[current] || [])) {
      if (dfs(next)) return true;
    }
    return false;
  }

  // Start DFS from 'toId' — can we reach 'fromId'?
  return dfs(toId);
}

// ── function: evaluateNode ──
// Evaluate a single node based on its current input states
function evaluateNode(node) {
  const def = NODE_TYPES[node.type];
  if (!def) return;

  if (node.type === 'toggle') {
    // Toggle state is set by user interaction, not evaluation
    return;
  }
  if (node.type === 'clock') {
    // Clock state is set by timer tick
    return;
  }
  if (GATE_FUNCS[node.type]) {
    const fn = GATE_FUNCS[node.type];
    if (def.inputs.length === 1) {
      node.state.out = fn(node.state[def.inputs[0]] || false);
    } else {
      node.state.out = fn(
        node.state[def.inputs[0]] || false,
        node.state[def.inputs[1]] || false
      );
    }
  }
  // Lamp just reflects its input state — no computation needed
}

// ── function: propagateFrom ──
// Propagate changes from a source node through the graph in topological order.
// Only re-evaluates nodes that are downstream of changed inputs.
function propagateFrom(scene, sourceNodeId) {
  const adj = buildAdjacency(scene);
  const visited = new Set();
  const queue = [sourceNodeId];

  // BFS to collect all downstream nodes in topological order
  const topoOrder = [];
  while (queue.length > 0) {
    const current = queue.shift();
    if (visited.has(current)) continue;
    visited.add(current);
    topoOrder.push(current);

    for (const next of (adj[current] || [])) {
      if (!visited.has(next)) {
        queue.push(next);
      }
    }
  }

  // Propagate input states along wires first, then evaluate in order
  for (const nodeId of topoOrder) {
    const node = scene.nodes.find(n => n.id === nodeId);
    if (!node) continue;

    // First, update this node's input states from connected wires
    const def = NODE_TYPES[node.type];
    for (let i = 0; i < def.inputs.length; i++) {
      const portName = def.inputs[i];
      const wire = findWireToInput(scene, nodeId, portName);
      if (wire) {
        const srcNode = scene.nodes.find(n => n.id === wire.from);
        if (srcNode) {
          node.state[portName] = srcNode.state[wire.fromPort] || false;
        } else {
          node.state[portName] = false;
        }
      } else {
        node.state[portName] = false;
      }
    }

    // Then evaluate the node's output
    evaluateNode(node);
  }
}

// ── function: fullEvaluate ──
// Full re-evaluation of the entire graph (used after loading or major changes)
function fullEvaluate(scene) {
  // First, collect all input nodes (toggles and clocks) as sources
  const sources = [];
  for (const node of scene.nodes) {
    if (node.type === 'toggle' || node.type === 'clock') {
      sources.push(node.id);
    }
  }

  // Propagate from each source
  for (const srcId of sources) {
    propagateFrom(scene, srcId);
  }

  // Also evaluate any nodes that aren't reachable from inputs but have wires
  const evaluated = new Set();
  for (const node of scene.nodes) {
    if (!evaluated.has(node.id)) {
      const def = NODE_TYPES[node.type];
      for (let i = 0; i < def.inputs.length; i++) {
        const portName = def.inputs[i];
        const wire = findWireToInput(scene, node.id, portName);
        if (wire) {
          const srcNode = scene.nodes.find(n => n.id === wire.from);
          if (srcNode) {
            node.state[portName] = srcNode.state[wire.fromPort] || false;
          } else {
            node.state[portName] = false;
          }
        } else {
          node.state[portName] = false;
        }
      }
      evaluateNode(node);
    }
  }
}

// ── function: toggleInput ──
// Toggle a toggle node and propagate changes
function toggleInput(scene, nodeId) {
  const node = scene.nodes.find(n => n.id === nodeId);
  if (!node || node.type !== 'toggle') return;
  node.state.out = !node.state.out;
  propagateFrom(scene, nodeId);
}

// ── function: tickClock ──
// Tick a clock node and propagate changes
function tickClock(scene, nodeId) {
  const node = scene.nodes.find(n => n.id === nodeId);
  if (!node || node.type !== 'clock') return;
  node.state.clk = !node.state.clk;
  propagateFrom(scene, nodeId);
}

// ── function: roundRect ──
// ============================================================
// RENDERING ENGINE — Canvas-based with world transforms
// ============================================================

// Helper: rounded rectangle path
function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

// ── function: render ──
function render(ctx, scene, camera) {
  const W = ctx.canvas.width;
  const H = ctx.canvas.height;
  const c = CONSTANTS.colors;
  const zoom = camera.zoom;

  // Clear background
  ctx.fillStyle = c.bg;
  ctx.fillRect(0, 0, W, H);

  ctx.save();

  // Apply camera transform: translate + scale
  ctx.translate(camera.panX, camera.panY);
  ctx.scale(zoom, zoom);

  // Draw dot grid
  drawGrid(ctx, scene, camera);

  // Draw wires (behind nodes)
  drawWires(ctx, scene, zoom);

  // Draw wire preview if dragging
  if (camera.wirePreview) {
    drawWirePreview(ctx, camera.wirePreview, zoom);
  }

  // Draw nodes
  for (const node of scene.nodes) {
    drawNode(ctx, node, scene, camera, zoom);
  }

  ctx.restore();
}

// ── function: drawGrid ──
// Optimized grid rendering — uses fillRect for speed
function drawGrid(ctx, scene, camera) {
  const c = CONSTANTS.colors;
  const gs = CONSTANTS.gridSize;
  // Dot size in world coords so it appears constant on screen after scale
  const dotR = CONSTANTS.gridDotSize / camera.zoom;

  // Calculate visible world bounds (accounting for DPR)
  const dpr = window.devicePixelRatio || 1;
  const left = -camera.panX / camera.zoom;
  const top = -camera.panY / camera.zoom;
  const right = (ctx.canvas.width / dpr - camera.panX) / camera.zoom;
  const bottom = (ctx.canvas.height / dpr - camera.panY) / camera.zoom;

  // Snap to grid
  const startX = Math.floor(left / gs) * gs;
  const startY = Math.floor(top / gs) * gs;

  ctx.fillStyle = c.gridDot;
  
  // Use fillRect for faster rendering (small squares instead of circles)
  const halfDot = dotR;
  for (let x = startX; x <= right; x += gs) {
    for (let y = startY; y <= bottom; y += gs) {
      ctx.fillRect(x - halfDot, y - halfDot, halfDot * 2, halfDot * 2);
    }
  }
}

// ── function: drawWires ──
function drawWires(ctx, scene, zoom) {
  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 outPorts = getOutputPortWorld(fromNode);
    const inPorts = getInputPortWorld(toNode);

    const fromPort = outPorts.find(p => p.name === wire.fromPort);
    const toPort = inPorts.find(p => p.name === wire.toPort);
    if (!fromPort || !toPort) continue;

    // Determine if energized
    const isEnergized = !!fromNode.state[wire.fromPort];

    drawBezierWire(ctx, fromPort.x, fromPort.y, toPort.x, toPort.y, isEnergized, zoom);
  }
}

// ── function: drawBezierWire ──
function drawBezierWire(ctx, x1, y1, x2, y2, energized, zoom) {
  const c = CONSTANTS.colors;
  const dx = Math.abs(x2 - x1);
  const cpOffset = Math.max(dx * 0.5, 40);

  // Wire width in world coords (constant screen size after scale)
  const baseWidth = energized ? 3 : 2;
  const lineWidth = baseWidth / zoom;

  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.bezierCurveTo(x1 + cpOffset, y1, x2 - cpOffset, y2, x2, y2);

  if (energized) {
    // Glow effect for energized wires
    ctx.save();
    ctx.shadowColor = c.wireEnergized;
    ctx.shadowBlur = 8 / zoom;
    ctx.strokeStyle = c.wireEnergized;
    ctx.lineWidth = lineWidth + 1/zoom;
    ctx.stroke();
    ctx.restore();

    // Core line
    ctx.strokeStyle = '#ff6b6b';
    ctx.lineWidth = lineWidth;
    ctx.stroke();
  } else {
    ctx.strokeStyle = c.wireDim;
    ctx.lineWidth = lineWidth;
    ctx.stroke();
  }
}

// ── function: drawWirePreview ──
function drawWirePreview(ctx, preview, zoom) {
  const c = CONSTANTS.colors;
  const dx = Math.abs(preview.toX - preview.fromX);
  const cpOffset = Math.max(dx * 0.5, 40);

  ctx.beginPath();
  ctx.moveTo(preview.fromX, preview.fromY);
  ctx.bezierCurveTo(
    preview.fromX + cpOffset, preview.fromY,
    preview.toX - cpOffset, preview.toY,
    preview.toX, preview.toY
  );

  if (preview.cycleDetected) {
    // Red flash for cycle rejection
    ctx.save();
    ctx.shadowColor = c.wireCycleReject;
    ctx.shadowBlur = 12 / zoom;
    ctx.strokeStyle = c.wireCycleReject;
    ctx.lineWidth = 3 / zoom;
    ctx.setLineDash([6 / zoom, 4 / zoom]);
    ctx.stroke();
    ctx.restore();
  } else if (preview.validTarget) {
    // Green highlight for valid target
    ctx.save();
    ctx.shadowColor = '#4caf50';
    ctx.shadowBlur = 8 / zoom;
    ctx.strokeStyle = '#4caf50';
    ctx.lineWidth = 2.5 / zoom;
    ctx.stroke();
    ctx.restore();
  } else {
    // Default preview color
    ctx.strokeStyle = c.wirePreview;
    ctx.lineWidth = 2 / zoom;
    ctx.setLineDash([4 / zoom, 3 / zoom]);
    ctx.stroke();
  }
}

// ── function: drawNode ──
// ============================================================
// NODE RENDERING — Individual node drawing with proper ctx management
// ============================================================

function drawNode(ctx, node, scene, camera, zoom) {
  const def = NODE_TYPES[node.type];
  if (!def) return;
  const c = CONSTANTS.colors;
  const w = getNodeWidth(node);
  const h = getNodeHeight(node);
  const isSelected = camera.selectedNodeId === node.id;
  const isHovered = camera.hoveredNodeId === node.id && !isSelected;

  // Hover shadow effect (drawn behind the node)
  if (isHovered) {
    ctx.save();
    ctx.shadowColor = 'rgba(233, 69, 96, 0.3)';
    ctx.shadowBlur = 12 / zoom;
    ctx.shadowOffsetY = 2 / zoom;
    roundRect(ctx, node.x, node.y, w, h, 8/zoom);
    ctx.fillStyle = c.nodeBg;
    ctx.fill();
    ctx.restore();
  }

  // --- Node body (rounded rect with gradient fill and border) ---
  const radius = 8 / zoom;
  roundRect(ctx, node.x, node.y, w, h, radius);

  const grad = ctx.createLinearGradient(node.x, node.y, node.x, node.y + h);
  if (isSelected) {
    grad.addColorStop(0, '#2a1a3e');
    grad.addColorStop(1, '#1a1040');
  } else if (isHovered) {
    grad.addColorStop(0, '#1e2850');
    grad.addColorStop(1, '#121d3a');
  } else {
    grad.addColorStop(0, c.nodeBg);
    grad.addColorStop(1, '#0d1b36');
  }
  ctx.fillStyle = grad;
  ctx.fill();

  // Border
  ctx.strokeStyle = isSelected ? c.nodeSelected : (isHovered ? '#2a4a7f' : c.nodeBorder);
  ctx.lineWidth = (isSelected ? 2.5 : (isHovered ? 1.5 : 1)) / zoom;
  ctx.stroke();

  // --- Header bar with label ---
  ctx.save();
  // Clip to header area
  roundRect(ctx, node.x, node.y, w, CONSTANTS.headerHeight, radius);
  ctx.clip();

  const headerGrad = ctx.createLinearGradient(node.x, node.y, node.x + w, node.y);
  if (node.type === 'toggle' || node.type === 'clock') {
    headerGrad.addColorStop(0, '#1a3a5c');
    headerGrad.addColorStop(1, '#162d4a');
  } else if (GATE_FUNCS[node.type]) {
    headerGrad.addColorStop(0, '#2a1a3e');
    headerGrad.addColorStop(1, '#1f1540');
  } else {
    headerGrad.addColorStop(0, '#3a2a1a');
    headerGrad.addColorStop(1, '#2d1f10');
  }
  ctx.fillStyle = headerGrad;
  ctx.fillRect(node.x - 1/zoom, node.y - 1/zoom, w + 2/zoom, CONSTANTS.headerHeight + 2/zoom);

  // Node label
  const fontSize = Math.max(9, 11 / zoom);
  ctx.font = `bold ${fontSize}px 'Segoe UI', system-ui, sans-serif`;
  ctx.fillStyle = c.textPrimary;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(def.label, node.x + w / 2, node.y + CONSTANTS.headerHeight / 2);

  // Toggle switch indicator
  if (node.type === 'toggle') {
    const swX = node.x + w - 16 / zoom;
    const swY = node.y + CONSTANTS.headerHeight / 2;
    const swW = 14 / zoom;
    const swH = 8 / zoom;

    roundRect(ctx, swX - swW/2, swY - swH/2, swW, swH, 3/zoom);
    ctx.fillStyle = node.state.out ? c.toggleOn : c.toggleOff;
    ctx.fill();

    const knobR = 3 / zoom;
    const knobX = node.state.out ? (swX + swW/2 - 2/zoom) : (swX - swW/2 + 2/zoom);
    ctx.beginPath();
    ctx.arc(knobX, swY, knobR, 0, Math.PI * 2);
    ctx.fillStyle = '#fff';
    ctx.fill();
  }

  // Clock pulse indicator
  if (node.type === 'clock') {
    const clkX = node.x + w - 16 / zoom;
    const clkY = node.y + CONSTANTS.headerHeight / 2;
    const r = 5 / zoom;
    ctx.beginPath();
    ctx.arc(clkX, clkY, r, 0, Math.PI * 2);
    if (node.state.clk) {
      ctx.save();
      ctx.shadowColor = c.clockPulse;
      ctx.shadowBlur = 6 / zoom;
      ctx.fillStyle = c.clockPulse;
      ctx.fill();
      ctx.restore();
    } else {
      ctx.fillStyle = '#333';
      ctx.fill();
    }
  }

  ctx.restore(); // Unclip header area

  // --- Input ports (left side) ---
  for (let i = 0; i < def.inputs.length; i++) {
    const portX = node.x;
    const portY = node.y + CONSTANTS.headerHeight + 14 + i * 20;
    const isPortHovered = camera.hoveredPort && 
      camera.hoveredPort.nodeId === node.id && 
      camera.hoveredPort.portName === def.inputs[i] &&
      camera.hoveredPort.side === 'input';

    drawPort(ctx, portX, portY, c.portInput, node.state[def.inputs[i]] || false, isPortHovered, zoom);

    // Port label
    const labelSize = Math.max(7, 9 / zoom);
    ctx.font = `${labelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = c.textSecondary;
    ctx.textAlign = 'right';
    ctx.textBaseline = 'middle';
    ctx.fillText(def.inputs[i], portX + 14 / zoom, portY);
  }

  // --- Output ports (right side) ---
  for (let i = 0; i < def.outputs.length; i++) {
    const portX = node.x + w;
    const portY = node.y + CONSTANTS.headerHeight + 14 + i * 20;
    const isPortHovered = camera.hoveredPort && 
      camera.hoveredPort.nodeId === node.id && 
      camera.hoveredPort.portName === def.outputs[i] &&
      camera.hoveredPort.side === 'output';

    drawPort(ctx, portX, portY, c.portOutput, !!node.state[def.outputs[i]], isPortHovered, zoom);

    // Port label
    const labelSize = Math.max(7, 9 / zoom);
    ctx.font = `${labelSize}px 'Segoe UI', system-ui, sans-serif`;
    ctx.fillStyle = c.textSecondary;
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    ctx.fillText(def.outputs[i], portX - 14 / zoom, portY);
  }

  // --- Lamp indicator ---
  if (node.type === 'lamp') {
    const lampR = 12 / zoom;
    const lampX = node.x + w / 2;
    const lampY = node.y + h - 16 / zoom;
    
    ctx.beginPath();
    ctx.arc(lampX, lampY, lampR, 0, Math.PI * 2);
    if (node.state.in) {
      ctx.save();
      ctx.shadowColor = c.lampOn;
      ctx.shadowBlur = 15 / zoom;
      const lampGrad = ctx.createRadialGradient(lampX, lampY, 0, lampX, lampY, lampR);
      lampGrad.addColorStop(0, '#fff8e1');
      lampGrad.addColorStop(0.5, c.lampOn);
      lampGrad.addColorStop(1, '#ff8f00');
      ctx.fillStyle = lampGrad;
      ctx.fill();
      ctx.restore();
    } else {
      const lampGrad = ctx.createRadialGradient(lampX - 2/zoom, lampY - 2/zoom, 0, lampX, lampY, lampR);
      lampGrad.addColorStop(0, '#444');
      lampGrad.addColorStop(1, c.lampOff);
      ctx.fillStyle = lampGrad;
      ctx.fill();
    }
    ctx.strokeStyle = node.state.in ? '#ff8f00' : '#555';
    ctx.lineWidth = 1.5 / zoom;
    ctx.stroke();
  }

  // --- State indicator for gates (ON/OFF) ---
  if (GATE_FUNCS[node.type]) {
    const stateText = node.state.out ? 'ON' : 'OFF';
    const fontSize2 = Math.max(7, 9 / zoom);
    ctx.font = `bold ${fontSize2}px monospace`;
    ctx.fillStyle = node.state.out ? '#4caf50' : c.textSecondary;
    ctx.textAlign = 'right';
    ctx.textBaseline = 'bottom';
    ctx.fillText(stateText, node.x + w - 6/zoom, node.y + h - 3/zoom);
  }

  // --- Selection highlight (dashed border) ---
  if (isSelected) {
    roundRect(ctx, node.x - 2/zoom, node.y - 2/zoom, w + 4/zoom, h + 4/zoom, radius + 1/zoom);
    ctx.strokeStyle = c.nodeSelected;
    ctx.lineWidth = 1.5 / zoom;
    ctx.setLineDash([4/zoom, 3/zoom]);
    ctx.stroke();
    ctx.setLineDash([]);
  }
}

// ── function: drawPort ──
function drawPort(ctx, x, y, color, energized, hovered, zoom) {
  const r = CONSTANTS.portRadius / zoom;
  const glowR = hovered ? r * 1.8 : r;

  ctx.beginPath();
  ctx.arc(x, y, glowR, 0, Math.PI * 2);

  if (energized) {
    ctx.save();
    ctx.shadowColor = color;
    ctx.shadowBlur = 6 / zoom;
    const grad = ctx.createRadialGradient(x, y, 0, x, y, glowR);
    grad.addColorStop(0, '#fff');
    grad.addColorStop(0.4, color);
    grad.addColorStop(1, color + '88');
    ctx.fillStyle = grad;
    ctx.fill();
    ctx.restore();
  } else {
    const grad = ctx.createRadialGradient(x - 1/zoom, y - 1/zoom, 0, x, y, glowR);
    grad.addColorStop(0, color + 'aa');
    grad.addColorStop(1, color + '44');
    ctx.fillStyle = grad;
    ctx.fill();
  }

  ctx.strokeStyle = hovered ? '#fff' : color;
  ctx.lineWidth = (hovered ? 2.5 : 1.5) / zoom;
  ctx.stroke();
}

// ── function: createInteractionHandler ──
// ============================================================
// INTERACTION HANDLER — Mouse/touch events, pan/zoom, drag
// ============================================================

function createInteractionHandler(canvas, scene) {
  const camera = {
    panX: 0,
    panY: 0,
    zoom: 1,
    selectedNodeId: null,
    hoveredPort: null,
    hoveredNodeId: null,
    wirePreview: null,
  };

  // Interaction state machine
  let mode = 'idle'; // idle | panning | draggingNode | creatingWire
  let dragStartScreenX = 0;
  let dragStartScreenY = 0;
  let dragNodeOffsetX = 0;
  let dragNodeOffsetY = 0;
  let wireDragSource = null; // { nodeId, portName, x, y }

  function screenToWorld(sx, sy) {
    return {
      x: (sx - camera.panX) / camera.zoom,
      y: (sy - camera.panY) / camera.zoom,
    };
  }

  function worldToScreen(wx, wy) {
    return {
      x: wx * camera.zoom + camera.panX,
      y: wy * camera.zoom + camera.panY,
    };
  }

  // Find the topmost node at a world position (for hit testing)
  function findNodeAt(wx, wy) {
    for (let i = scene.nodes.length - 1; i >= 0; i--) {
      if (hitTestNodeBody(wx, wy, scene.nodes[i])) {
        return scene.nodes[i];
      }
    }
    return null;
  }

  function findPortAt(wx, wy) {
    for (let i = scene.nodes.length - 1; i >= 0; i--) {
      const node = scene.nodes[i];
      // Check output ports first (they're on the right edge)
      const outHit = hitTestOutputPort(wx, wy, node);
      if (outHit) return { nodeId: node.id, portName: outHit.name, side: 'output', x: outHit.x, y: outHit.y };

      // Check input ports
      const inHit = hitTestInputPort(wx, wy, node);
      if (inHit) return { nodeId: node.id, portName: inHit.name, side: 'input', x: inHit.x, y: inHit.y };
    }
    return null;
  }

  function onMouseDown(e) {
    const rect = canvas.getBoundingClientRect();
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;
    const world = screenToWorld(sx, sy);

    // Check if clicking on a port first
    const portHit = findPortAt(world.x, world.y);
    if (portHit) {
      mode = 'creatingWire';
      wireDragSource = { ...portHit };
      camera.wirePreview = {
        fromX: portHit.x,
        fromY: portHit.y,
        toX: world.x,
        toY: world.y,
        validTarget: false,
        cycleDetected: false,
      };
      return;
    }

    // Check if clicking on a node body
    const nodeHit = findNodeAt(world.x, world.y);
    if (nodeHit) {
      camera.selectedNodeId = nodeHit.id;
      mode = 'draggingNode';
      dragStartScreenX = sx;
      dragStartScreenY = sy;
      dragNodeOffsetX = world.x - nodeHit.x;
      dragNodeOffsetY = world.y - nodeHit.y;
      return;
    }

    // Clicking empty space — start panning
    mode = 'panning';
    camera.selectedNodeId = null;
    dragStartScreenX = sx;
    dragStartScreenY = sy;
  }

  function onMouseMove(e) {
    const rect = canvas.getBoundingClientRect();
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;
    const world = screenToWorld(sx, sy);

    if (mode === 'panning') {
      camera.panX += sx - dragStartScreenX;
      camera.panY += sy - dragStartScreenY;
      dragStartScreenX = sx;
      dragStartScreenY = sy;
      return;
    }

    if (mode === 'draggingNode' && camera.selectedNodeId !== null) {
      const node = scene.nodes.find(n => n.id === camera.selectedNodeId);
      if (node) {
        node.x = world.x - dragNodeOffsetX;
        node.y = world.y - dragNodeOffsetY;
      }
      return;
    }

    if (mode === 'creatingWire' && wireDragSource) {
      camera.wirePreview.toX = world.x;
      camera.wirePreview.toY = world.y;

      // Check for valid target port
      const portHit = findPortAt(world.x, world.y);
      if (portHit && portHit.nodeId !== wireDragSource.nodeId) {
        // Must connect output -> input or input -> output (opposite sides)
        if (wireDragSource.side === 'output' && portHit.side === 'input') {
          camera.wirePreview.validTarget = true;
          camera.wirePreview.cycleDetected = wouldCreateCycle(scene, wireDragSource.nodeId, portHit.nodeId);
          if (!camera.wirePreview.cycleDetected) {
            camera.hoveredPort = portHit;
          }
        } else if (wireDragSource.side === 'input' && portHit.side === 'output') {
          // Reversing: output -> input
          camera.wirePreview.validTarget = true;
          camera.wirePreview.cycleDetected = wouldCreateCycle(scene, portHit.nodeId, wireDragSource.nodeId);
          if (!camera.wirePreview.cycleDetected) {
            camera.hoveredPort = portHit;
          }
        } else {
          camera.wirePreview.validTarget = false;
          camera.hoveredPort = null;
        }
      } else {
        camera.wirePreview.validTarget = false;
        camera.hoveredPort = null;
      }
      return;
    }

    // Idle mode — hover detection
    const portHit2 = findPortAt(world.x, world.y);
    if (portHit2) {
      camera.hoveredPort = portHit2;
      camera.hoveredNodeId = portHit2.nodeId;
      canvas.style.cursor = 'crosshair';
    } else {
      const nodeHit2 = findNodeAt(world.x, world.y);
      if (nodeHit2) {
        camera.hoveredPort = null;
        camera.hoveredNodeId = nodeHit2.id;
        canvas.style.cursor = 'grab';
      } else {
        camera.hoveredPort = null;
        camera.hoveredNodeId = null;
        canvas.style.cursor = 'default';
      }
    }
  }

  function onMouseUp(e) {
    const rect = canvas.getBoundingClientRect();
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;
    const world = screenToWorld(sx, sy);

    if (mode === 'creatingWire' && wireDragSource) {
      // Try to complete the wire connection
      const portHit = findPortAt(world.x, world.y);
      if (portHit && portHit.nodeId !== wireDragSource.nodeId) {
        let fromNodeId, fromPortName, toNodeId, toPortName;

        if (wireDragSource.side === 'output' && portHit.side === 'input') {
          fromNodeId = wireDragSource.nodeId;
          fromPortName = wireDragSource.portName;
          toNodeId = portHit.nodeId;
          toPortName = portHit.portName;
        } else if (wireDragSource.side === 'input' && portHit.side === 'output') {
          // Reversed connection: the output is the source, input is the target
          fromNodeId = portHit.nodeId;
          fromPortName = portHit.portName;
          toNodeId = wireDragSource.nodeId;
          toPortName = wireDragSource.portName;
        } else {
          // Invalid connection type (output->output or input->input)
          mode = 'idle';
          camera.wirePreview = null;
          wireDragSource = null;
          return;
        }

        // Check for cycle
        if (!wouldCreateCycle(scene, fromNodeId, toNodeId)) {
          // Remove existing wire on this input port (one wire per input)
          scene.wires = scene.wires.filter(w => !(w.to === toNodeId && w.toPort === toPortName));

          // Add new wire
          scene.wires.push(createWire(fromNodeId, fromPortName, toNodeId, toPortName));

          // Re-evaluate
          fullEvaluate(scene);
        }
      }

      mode = 'idle';
      camera.wirePreview = null;
      wireDragSource = null;
      return;
    }

    if (mode === 'draggingNode') {
      // Check if it was a click (no significant movement) — toggle the node
      const dx = sx - dragStartScreenX;
      const dy = sy - dragStartScreenY;
      if (Math.abs(dx) < 3 && Math.abs(dy) < 3 && camera.selectedNodeId !== null) {
        const node = scene.nodes.find(n => n.id === camera.selectedNodeId);
        if (node && node.type === 'toggle') {
          toggleInput(scene, node.id);
        }
      }
      mode = 'idle';
    }

    if (mode === 'panning') {
      mode = 'idle';
    }
  }

  function onWheel(e) {
    e.preventDefault();
    const rect = canvas.getBoundingClientRect();
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;

    // Zoom toward cursor
    const worldBefore = screenToWorld(sx, sy);
    const zoomFactor = e.deltaY < 0 ? CONSTANTS.zoomStep : 1 / CONSTANTS.zoomStep;
    const newZoom = Math.max(CONSTANTS.zoomMin, Math.min(CONSTANTS.zoomMax, camera.zoom * zoomFactor));

    // Adjust pan so the world point under cursor stays fixed
    camera.zoom = newZoom;
    camera.panX = sx - worldBefore.x * camera.zoom;
    camera.panY = sy - worldBefore.y * camera.zoom;
  }

  function onKeyDown(e) {
    if (e.key === 'Delete' || e.key === 'Backspace') {
      if (camera.selectedNodeId !== null) {
        // Remove node and all its wires
        removeNodeWires(scene, camera.selectedNodeId);
        scene.nodes = scene.nodes.filter(n => n.id !== camera.selectedNodeId);
        camera.selectedNodeId = null;
        fullEvaluate(scene);
      }
    }
  }

  return {
    camera,
    onMouseDown,
    onMouseMove,
    onMouseUp,
    onWheel,
    onKeyDown,
    screenToWorld,
    worldToScreen,
  };
}

// ── const: STORAGE_KEY ──
// ============================================================
// PERSISTENCE — localStorage serialization & restoration
// ============================================================

const STORAGE_KEY = 'logicCircuitEditor';

// ── function: saveScene ──
function saveScene(scene) {
  try {
    const data = JSON.stringify({ scene, nextNodeId });
    localStorage.setItem(STORAGE_KEY, data);
  } catch (e) {
    console.warn('Failed to save scene:', e);
  }
}

// ── function: loadScene ──
function loadScene() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const data = JSON.parse(raw);
    // Restore nextNodeId to avoid ID collisions
    if (data.nextNodeId) {
      nextNodeId = Math.max(nextNodeId, data.nextNodeId + 1);
    }
    return data.scene;
  } catch (e) {
    console.warn('Failed to load scene:', e);
    return null;
  }
}

// ── function: clearStorage ──
function clearStorage() {
  localStorage.removeItem(STORAGE_KEY);
}

// ── let: clockTimers ──
// ============================================================
// CLOCK TIMER MANAGEMENT — Periodic tick for clock nodes
// ============================================================

let clockTimers = {};

// ── function: startClock ──
// nodeId -> intervalId

function startClock(scene, nodeId) {
  const node = scene.nodes.find(n => n.id === nodeId);
  if (!node || node.type !== 'clock') return;

  stopClock(nodeId);

  const rate = node.state.rate || 1000;
  clockTimers[nodeId] = setInterval(() => {
    tickClock(scene, nodeId);
    // Trigger re-render
    if (renderCallback) renderCallback();
  }, rate);
}

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

// ── function: stopAllClocks ──
function stopAllClocks() {
  for (const id of Object.keys(clockTimers)) {
    stopClock(parseInt(id));
  }
}

// ── let: renderCallback ──
// ============================================================
// RENDER LOOP & ANIMATION FRAME MANAGEMENT
// ============================================================

let renderCallback = null;

// ── let: needsRender ──
// Set by main to trigger re-renders
let needsRender = true;

// ── function: scheduleRender ──
function scheduleRender() {
  if (!needsRender) {
    needsRender = true;
    requestAnimationFrame(renderFrame);
  }
}

// ── function: renderFrame ──
function renderFrame(timestamp) {
  if (!renderCallback || !needsRender) return;
  needsRender = false;
  renderCallback();
}

// ── main ──
// ============================================================
// MAIN — Initialization, event wiring, render loop
// ============================================================

(function main() {
  const canvas = document.getElementById('graphCanvas');
  const ctx = canvas.getContext('2d');
  const container = document.getElementById('canvas-container');

  // --- Canvas sizing with DPR support ---
  function resizeCanvas() {
    const dpr = window.devicePixelRatio || 1;
    const rect = container.getBoundingClientRect();
    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    canvas.style.width = rect.width + 'px';
    canvas.style.height = rect.height + 'px';
    needsRender = true;
    scheduleRender();
  }

  window.addEventListener('resize', resizeCanvas);
  resizeCanvas();

  // --- Load or create scene ---
  let scene = loadScene() || createScene();

  // --- Create interaction handler ---
  const handler = createInteractionHandler(canvas, scene);

  // --- Event listeners ---
  canvas.addEventListener('mousedown', (e) => {
    e.preventDefault();
    handler.onMouseDown(e);
  });

  window.addEventListener('mousemove', (e) => {
    handler.onMouseMove(e);
    needsRender = true;
    scheduleRender();
  });

  window.addEventListener('mouseup', (e) => {
    handler.onMouseUp(e);
    needsRender = true;
    scheduleRender();
    // Auto-save after interaction
    saveScene(scene);
  });

  canvas.addEventListener('wheel', (e) => {
    handler.onWheel(e);
    needsRender = true;
    scheduleRender();
  }, { passive: false });

  window.addEventListener('keydown', (e) => {
    handler.onKeyDown(e);
    if (e.key === 'Delete' || e.key === 'Backspace') {
      needsRender = true;
      scheduleRender();
      saveScene(scene);
    }
  });

  // Prevent context menu on canvas
  canvas.addEventListener('contextmenu', (e) => e.preventDefault());

  // --- Palette: add nodes by clicking buttons ---
  document.querySelectorAll('#palette button[data-node-type]').forEach(btn => {
    btn.addEventListener('click', () => {
      const type = btn.dataset.nodeType;
      // Place node in center of visible viewport with slight random offset
      const rect = container.getBoundingClientRect();
      const worldCenter = handler.screenToWorld(rect.width / 2, rect.height / 2);
      const offsetX = (Math.random() - 0.5) * 80;
      const offsetY = (Math.random() - 0.5) * 60;
      const node = createNode(type, worldCenter.x - 60 + offsetX, worldCenter.y - 25 + offsetY);

      scene.nodes.push(node);

      // Start clock timer if it's a clock
      if (type === 'clock') {
        startClock(scene, node.id);
      }

      fullEvaluate(scene);
      needsRender = true;
      scheduleRender();
      saveScene(scene);
    });
  });

  // --- Clear All button ---
  const clearBtn = document.getElementById('clearAllBtn');
  if (clearBtn) {
    clearBtn.addEventListener('click', () => {
      stopAllClocks();
      scene.nodes = [];
      scene.wires = [];
      handler.camera.selectedNodeId = null;
      fullEvaluate(scene);
      needsRender = true;
      scheduleRender();
      saveScene(scene);
    });
  }

  // --- Render callback ---
  renderCallback = function() {
    const dpr = window.devicePixelRatio || 1;
    ctx.save();
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    render(ctx, scene, handler.camera);

    ctx.restore();
  };

  // --- Continuous render for animations (clock pulse glow) ---
  function animationLoop() {
    needsRender = true;
    scheduleRender();
    requestAnimationFrame(animationLoop);
  }

  // Initial evaluation and save
  fullEvaluate(scene);
  saveScene(scene);

  // Start clock timers for existing clocks
  for (const node of scene.nodes) {
    if (node.type === 'clock') {
      startClock(scene, node.id);
    }
  }

  // Kick off render loop
  animationLoop();

})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"5e240db7-0e08-4d53-9f54-b09be71dc135","tokensIn":2861717,"tokensOut":49932,"tokensTotal":2911649,"turns":74,"toolCalls":73,"failedToolCalls":3,"timestamp":"2026-07-30T04:23:01.896Z"} -->