Node Graph Editor
Qwen 3.6 27b MTP · full

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;">&</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"} -->