Node Graph Editor
Qwen 3.6 27b MTP · composer

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"} -->