Node Graph Editor
Ornith 1.5 35B A3B · 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 36,340 bytes · SHA-256 1abe2f48ed9a
<!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</title>
<style>
/* ===== Base / Reset ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
background: #0b0e14; color: #cdd3e0;
user-select: none; -webkit-user-select: none;
}
#app { position: fixed; inset: 0; }
/* ===== Canvas ===== */
#stage { position: absolute; inset: 0; display: block; cursor: grab; }
#stage.grabbing { cursor: grabbing; }
#stage.wiring { cursor: crosshair; }
/* ===== Sidebar Palette ===== */
#palette {
position: absolute; top: 12px; left: 12px; width: 168px;
background: rgba(18, 23, 34, 0.92); border: 1px solid #26304180;
border-radius: 12px; padding: 10px; box-shadow: 0 8px 30px #00000060;
backdrop-filter: blur(6px); z-index: 10;
}
#palette h2 {
font-size: 12px; letter-spacing: 1.5px; text-transform: uppercase;
color: #6f7a92; margin-bottom: 8px; font-weight: 600;
}
.pal-group { margin-bottom: 8px; }
.pal-label { font-size: 10px; color: #55607a; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.pal-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: #171d29; border: 1px solid #2a3446; color: #cdd3e0;
padding: 7px 9px; margin-bottom: 4px; border-radius: 8px; cursor: pointer;
font-size: 13px; font-family: inherit; transition: background .12s, border-color .12s, transform .1s;
}
.pal-btn:hover { background: #222c3d; border-color: #3d4c66; }
.pal-btn:active { transform: scale(0.98); }
.pal-swatch { width: 14px; height: 14px; border-radius: 4px; flex: 0 0 auto; box-shadow: 0 0 6px #00000055; }
/* ===== Toolbar ===== */
#toolbar {
position: absolute; top: 12px; right: 12px; display: flex; gap: 6px;
background: rgba(18, 23, 34, 0.92); border: 1px solid #26304180;
border-radius: 10px; padding: 6px; box-shadow: 0 8px 30px #00000060; z-index: 10;
}
#toolbar button {
background: #171d29; border: 1px solid #2a3446; color: #cdd3e0;
padding: 6px 10px; border-radius: 7px; cursor: pointer; font-size: 12px; font-family: inherit;
transition: background .12s;
}
#toolbar button:hover { background: #222c3d; }
#zoom-label { display:flex; align-items:center; font-size: 12px; color:#8f9ab2; min-width: 52px; text-align:center; }
/* ===== Hint ===== */
#hint {
position: absolute; bottom: 12px; left: 12px; max-width: 380px;
background: rgba(18, 23, 34, 0.85); border: 1px solid #26304180;
border-radius: 10px; padding: 9px 12px; font-size: 11px; line-height: 1.6; color: #8f9ab2; z-index: 10;
}
#hint b { color: #cdd3e0; }
#hint kbd {
background: #222c3d; border: 1px solid #34405a; border-bottom-width: 2px;
border-radius: 4px; padding: 0 5px; font-size: 10px; color: #cdd3e0; font-family: monospace;
}
/* ===== Context menu ===== */
#context {
position: absolute; display: none; min-width: 176px;
background: rgba(20, 26, 38, 0.97); border: 1px solid #34405a;
border-radius: 10px; padding: 5px; box-shadow: 0 12px 40px #00000080; z-index: 100;
}
.ctx-item {
display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 7px;
cursor: pointer; font-size: 13px;
}
.ctx-item:hover { background: #28344a; }
.ctx-item .sw { width: 12px; height: 12px; border-radius: 3px; flex: 0 0 auto; }
.ctx-sep { height: 1px; background: #2a3446; margin: 5px 4px; }
</style>
</head>
<body>
<div id="app">
<canvas id="stage"></canvas>
<div id="palette">
<h2>Nodes</h2>
<div class="pal-group">
<div class="pal-label">Inputs</div>
<button class="pal-btn" data-add="input"><span class="pal-swatch" style="background:#46b8ff"></span>Switch</button>
<button class="pal-btn" data-add="clock"><span class="pal-swatch" style="background:#ffd24a"></span>Clock</button>
</div>
<div class="pal-group">
<div class="pal-label">Gates</div>
<button class="pal-btn" data-add="and"><span class="pal-swatch" style="background:#a06bff"></span>AND</button>
<button class="pal-btn" data-add="or"><span class="pal-swatch" style="background:#c05bff"></span>OR</button>
<button class="pal-btn" data-add="not"><span class="pal-swatch" style="background:#e05bff"></span>NOT</button>
<button class="pal-btn" data-add="xor"><span class="pal-swatch" style="background:#ff6bd0"></span>XOR</button>
<button class="pal-btn" data-add="nand"><span class="pal-swatch" style="background:#ff8a4a"></span>NAND</button>
<button class="pal-btn" data-add="nor"><span class="pal-swatch" style="background:#ffb14a"></span>NOR</button>
</div>
<div class="pal-group">
<div class="pal-label">Outputs</div>
<button class="pal-btn" data-add="lamp"><span class="pal-swatch" style="background:#4aff9e"></span>Lamp</button>
<button class="pal-btn" data-add="display"><span class="pal-swatch" style="background:#3fe0c8"></span>7-Segment</button>
</div>
</div>
<div id="toolbar">
<button id="btn-fit">Fit</button>
<button id="btn-zin">+</button>
<div id="zoom-label">100%</div>
<button id="btn-zout">-</button>
<button id="btn-clear">Clear</button>
</div>
<div id="hint">
<b>Logic Node Graph</b><br>
<kbd>Drag</kbd> empty space to pan · <kbd>Scroll</kbd> zoom · <kbd>Del</kbd> delete<br>
Drag from a <b>warm</b> output → <b>cool</b> input to wire · Right-click to add
</div>
<div id="context"></div>
</div>
<script>
"use strict";
/* ============================================================
1. TUNABLE CONSTANTS
============================================================ */
const CFG = {
bgTop: '#0b0e14', bgBottom: '#0d1119',
gridDot: '#1b2434',
nodeBody: '#161c28', nodeHead: '#1e2736', nodeBorder: '#2c3850', nodeSel: '#46b8ff',
portIn: '#2f5f8c', portInHi: '#46b8ff',
portOut: '#8c5a2f', portOutHi: '#ffb14a',
wireOff: '#3a4a63', wireOn: '#4aff9e', wirePreview: '#7f8db0', wireBad: '#ff4a6b',
text: '#cdd3e0', textDim: '#7f8db0',
lampOn: '#7dffb0', lampOff: '#1e4a33', segOn: '#3fe0c8', segOff: '#1c3330',
nodeW: 148, nodeH: 64, headH: 26,
portR: 7, portHitR: 15, portGap: 26, portMarginX: 16, bezierK: 0.55,
zoomMin: 0.5, zoomMax: 2.0, zoomStep: 1.12,
clockRates: [0.5, 1, 2, 4],
panThreshold: 3,
};
/* ============================================================
2. TYPE DEFINITIONS + TRUTH TABLES
============================================================ */
const TYPES = {
input: { label: 'Switch', inputs: 0, outputs: 1, head: '#20507a', toggle: true },
clock: { label: 'Clock', inputs: 0, outputs: 1, head: '#7a6620', toggle: true },
and: { label: 'AND', inputs: 2, outputs: 1, head: '#4a2f8c' },
or: { label: 'OR', inputs: 3, outputs: 1, head: '#5a2f8c' },
not: { label: 'NOT', inputs: 1, outputs: 1, head: '#7a2f8c' },
xor: { label: 'XOR', inputs: 2, outputs: 1, head: '#8c2f7a' },
nand: { label: 'NAND', inputs: 2, outputs: 1, head: '#8c5a2f' },
nor: { label: 'NOR', inputs: 2, outputs: 1, head: '#8c6a2f' },
lamp: { label: 'Lamp', inputs: 1, outputs: 0, head: '#1f7a4f', output: true },
display: { label: '7-Seg', inputs: 4, outputs: 0, head: '#1f7a73', output: true },
};
function gateOutput(type, inputs) {
// Missing (unwired) inputs take their gate's identity element so that a
// partially-wired gate evaluates correctly: AND/NAND default to 1,
// OR/NOR/XOR default to 0.
const isOrFamily = type === 'or' || type === 'nor';
const pad = isOrFamily ? 0 : 1;
const a = (inputs[0] != null ? inputs[0] : pad) ? 1 : 0;
const b = (inputs[1] != null ? inputs[1] : pad) ? 1 : 0;
const c = (inputs[2] != null ? inputs[2] : pad) ? 1 : 0;
switch (type) {
case 'and': return (a & b & c) ? 1 : 0;
case 'or': return (a | b | c) ? 1 : 0;
case 'not': return inputs[0] ? 0 : 1;
case 'xor': return (a ^ b ^ c) ? 1 : 0;
case 'nand': return (a & b & c) ? 0 : 1;
case 'nor': return (a | b | c) ? 0 : 1;
default: return 0;
}
}
/* ============================================================
3. SCENE MODEL (serializable source of truth)
============================================================ */
let scene = { nodes: [], wires: [] };
let uid = 1;
const nid = () => 'n' + (uid++);
function makeNode(type, x, y) {
return { id: nid(), type, x, y, state: 0, rate: 1 };
}
function findNode(id) { return scene.nodes.find(n => n.id === id); }
/* ============================================================
4. VIEWPORT (world <-> screen)
============================================================ */
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
let DPR = window.devicePixelRatio || 1;
const view = { x: 0, y: 0, zoom: 1 };
function resize() {
DPR = window.devicePixelRatio || 1;
canvas.width = Math.floor(innerWidth * DPR);
canvas.height = Math.floor(innerHeight * DPR);
}
window.addEventListener('resize', () => { resize(); render(); });
resize();
function worldToScreen(x, y) {
return [ (x - view.x) * view.zoom + view.x + innerWidth / 2,
(y - view.y) * view.zoom + view.y + innerHeight / 2 ];
}
function screenToWorld(sx, sy) {
return [ (sx - innerWidth / 2 - view.x) / view.zoom + view.x,
(sy - innerHeight / 2 - view.y) / view.zoom + view.y ];
}
function applyZoom(nz) {
nz = Math.max(CFG.zoomMin, Math.min(CFG.zoomMax, nz));
const s = mouse.x, t = mouse.y;
const [wx, wy] = screenToWorld(s, t);
if (Math.abs(nz - 1) < 1e-6) { view.zoom = nz; }
else {
view.x = (nz * wx - (s - innerWidth / 2)) / (nz - 1);
view.y = (nz * wy - (t - innerHeight / 2)) / (nz - 1);
view.zoom = nz;
}
updateZoomLabel();
}
function updateZoomLabel() { document.getElementById('zoom-label').textContent = Math.round(view.zoom * 100) + '%'; }
/* ============================================================
5. PORT LOCATIONS
============================================================ */
function portLoc(node, kind, i) {
const t = TYPES[node.type];
const count = kind === 'in' ? t.inputs : t.outputs;
const baseY = node.y - CFG.headH / 2 - (count - 1) * CFG.portGap / 2 + CFG.portGap / 2;
const x = node.x + (kind === 'in' ? -CFG.nodeW / 2 : CFG.nodeW / 2);
return [x, baseY + i * CFG.portGap];
}
function portsOf(node) {
const t = TYPES[node.type], res = { in: [], out: [] };
for (let i = 0; i < t.inputs; i++) res.in.push(portLoc(node, 'in', i));
for (let i = 0; i < t.outputs; i++) res.out.push(portLoc(node, 'out', i));
return res;
}
/* ============================================================
6. WIRE HELPERS
============================================================ */
function wiresToInput(toId, port) { return scene.wires.find(w => w.to === toId && w.toPort === port); }
function wireFromOutput(outId, port) { return scene.wires.find(w => w.from === outId && w.fromPort === port); }
function removeWire(w) { scene.wires = scene.wires.filter(x => x !== w); }
function wireAt(toId, port) { return wiresToInput(toId, port); }
function pruneDanglingWires() {
scene.wires = scene.wires.filter(w => findNode(w.from) && findNode(w.to));
}
/* ---- Cycle rejection: would connecting out->in close a feedback loop? ---- */
function wouldCreateCycle(outId, outPort, inId, inPort) {
if (outId === inId) return true; // self-loop
// Signal flows: output port drives its input port; input port drives node's output port(s).
const adj = new Map(); // key "node#port" -> Set of driven keys
const addEdge = (a, b) => { if (!adj.has(a)) adj.set(a, new Set()); adj.get(a).add(b); };
// intrinsic edge: every input port of an output-capable node drives its output port(s)
for (const n of scene.nodes) {
if (n && TYPES[n.type] && TYPES[n.type].outputs > 0) {
for (let i = 0; i < TYPES[n.type].inputs; i++) addEdge(n.id + '.' + i, n.id + '.0');
}
}
for (const w of scene.wires) {
if (!findNode(w.from) || !findNode(w.to)) continue;
addEdge(w.from + '.' + w.fromPort, w.to + '.' + w.toPort); // outPort drives inPort
const node = findNode(w.to);
if (node && TYPES[node.type].outputs > 0) addEdge(w.to + '.' + w.toPort, w.to + '.0'); // inPort drives node out
}
// proposed edge: outPort drives inPort
addEdge(outId + '.' + outPort, inId + '.' + inPort);
// DFS from inPort (head of edge): if we can reach outPort (tail), it's a cycle
const seen = new Set(), stack = [inId + '.' + inPort];
while (stack.length) {
const cur = stack.pop();
if (cur === outId + '.' + outPort) return true;
if (seen.has(cur)) continue;
seen.add(cur);
const nb = adj.get(cur);
if (nb) for (const nx of nb) stack.push(nx);
}
return false;
}
/* ============================================================
7. EVALUATION ENGINE (single topological pass)
============================================================ */
function topoOrder() {
const order = [], vis = new Set(), tmp = new Set();
const visit = (id) => {
if (vis.has(id)) return true;
if (tmp.has(id)) return false; // cycle guard (safety)
tmp.add(id);
const node = findNode(id);
if (node) {
const t = TYPES[node.type];
for (let i = 0; i < t.inputs; i++) {
const w = wiresToInput(id, i);
if (w && !visit(w.from)) return false;
}
}
tmp.delete(id); vis.add(id); order.push(node);
return true;
};
for (const n of scene.nodes) if (!visit(n.id)) return null;
return order;
}
function evaluate() {
const order = topoOrder();
if (!order) return; // cycle present (shouldn't happen) — don't hang
for (const n of order) {
const t = TYPES[n.type];
if (n.type === 'input') continue;
if (n.type === 'clock') {
const w = wiresToInput(n.id, 0);
n.state = w ? findNode(w.from).state : n.state; // gated clock mirrors input; else timer
continue;
}
const ins = [];
for (let i = 0; i < t.inputs; i++) {
const w = wiresToInput(n.id, i);
if (w) ins.push(findNode(w.from).state); // only wired inputs
}
if (n.type === 'lamp') { n.state = ins[0] ? 1 : 0; }
else if (n.type === 'display') { n.state = ins[0] | (ins[1] << 1) | (ins[2] << 2) | (ins[3] << 3); }
else { n.state = gateOutput(n.type, ins) ? 1 : 0; }
}
}
/* ============================================================
8. CLOCK TIMER
============================================================ */
let lastTick = performance.now();
function tickClocks(now) {
const dt = (now - lastTick) / 1000;
lastTick = now;
let changed = false;
for (const n of scene.nodes) {
if (n.type !== 'clock') continue;
const gated = wiresToInput(n.id, 0);
if (gated) continue; // driven externally
const period = 1 / n.rate;
n._acc = (n._acc || 0) + dt;
if (n._acc >= period) {
n._acc -= period;
n.state = n.state ? 0 : 1;
changed = true;
}
}
if (changed) evaluate();
}
/* ============================================================
9. RENDERING
============================================================ */
function render() {
const W = innerWidth, H = innerHeight;
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, CFG.bgTop); g.addColorStop(1, CFG.bgBottom);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
drawGrid(W, H);
ctx.save();
const [ox, oy] = worldToScreen(0, 0);
ctx.translate(ox, oy); ctx.scale(view.zoom, view.zoom);
drawWires();
drawPreviewWire();
drawNodes();
ctx.restore();
}
function drawGrid(W, H) {
const step = view.zoom < 0.8 ? 80 : (view.zoom < 1.4 ? 40 : 20);
const dotR = 1.0;
const [x0] = screenToWorld(0, 0);
const [x1] = screenToWorld(W, 0);
const [, y0] = screenToWorld(0, 0);
const [, y1] = screenToWorld(0, H);
ctx.fillStyle = CFG.gridDot;
const sx0 = Math.floor(x0 / step) * step;
const sy0 = Math.floor(y0 / step) * step;
ctx.beginPath();
for (let gx = sx0; gx <= x1; gx += step) {
for (let gy = sy0; gy <= y1; gy += step) {
const [px, py] = worldToScreen(gx, gy);
ctx.fillRect(px - dotR, py - dotR, dotR * 2, dotR * 2);
}
}
}
function drawNodes() {
for (const n of scene.nodes) {
const t = TYPES[n.type];
const w = CFG.nodeW, h = CFG.nodeH, hh = CFG.headH;
const sel = selected === n.id;
// card
ctx.save();
ctx.shadowColor = sel ? CFG.nodeSel : '#00000060';
ctx.shadowBlur = sel ? 18 : 8; ctx.shadowOffsetY = sel ? 4 : 2;
roundRect(n.x - w / 2, n.y - h / 2, w, h, 10);
ctx.fillStyle = CFG.nodeBody; ctx.fill(); ctx.restore();
roundRect(n.x - w / 2, n.y - h / 2, w, h, 10);
ctx.lineWidth = sel ? 2 : 1; ctx.strokeStyle = sel ? CFG.nodeSel : CFG.nodeBorder; ctx.stroke();
// head
ctx.beginPath();
ctx.moveTo(n.x - w / 2, n.y - h / 2 + hh);
ctx.lineTo(n.x + w / 2, n.y - h / 2 + hh);
ctx.arc(n.x + w / 2, n.y - h / 2 + hh / 2, hh / 2, -Math.PI / 2, Math.PI / 2, false);
ctx.lineTo(n.x - w / 2, n.y - h / 2 + hh);
ctx.arc(n.x - w / 2, n.y - h / 2 + hh / 2, hh / 2, Math.PI / 2, -Math.PI / 2, false);
ctx.closePath(); ctx.fillStyle = t.head; ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = '600 13px Segoe UI, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(t.label, n.x, n.y - h / 2 + hh / 2 + 1);
// state readout
ctx.font = '12px Segoe UI, sans-serif'; ctx.fillStyle = CFG.textDim;
let txt = '';
if (n.type === 'input') txt = n.state ? 'ON' : 'OFF';
else if (n.type === 'clock') txt = (n.state ? 'HI ' : 'LO ') + n.rate.toFixed(1) + ' Hz';
else if (n.type === 'lamp') txt = n.state ? 'ON' : 'off';
else if (n.type === 'display') txt = n.state.toString(16).toUpperCase();
else txt = n.state ? '1' : '0';
ctx.fillText(txt, n.x, n.y + 7);
// output symbol
if (t.output) {
const oc = n.state ? CFG.lampOn : CFG.portIn;
ctx.beginPath(); ctx.arc(n.x, n.y - hh / 2 - 6, 6, 0, Math.PI * 2);
if (n.state) { ctx.shadowColor = CFG.lampOn; ctx.shadowBlur = 16; }
ctx.fillStyle = n.state ? CFG.lampOn : '#2a3a34'; ctx.fill();
ctx.shadowBlur = 0; ctx.lineWidth = 1.5; ctx.strokeStyle = '#0b0e14'; ctx.stroke();
}
drawPorts(n);
}
}
function drawPorts(n) {
const ps = portsOf(n), hv = hoveredPort;
for (let i = 0; i < ps.in.length; i++) {
const [x, y] = ps.in[i];
const isH = hv && hv.node === n.id && hv.kind === 'in' && hv.index === i;
const wired = !!wiresToInput(n.id, i);
drawPortCircle(x, y, CFG.portR, isH ? CFG.portInHi : CFG.portIn, isH, wired, false);
}
for (let i = 0; i < ps.out.length; i++) {
const [x, y] = ps.out[i];
const isH = hv && hv.node === n.id && hv.kind === 'out' && hv.index === i;
const wired = !!wireFromOutput(n.id, i);
drawPortCircle(x, y, CFG.portR, isH ? CFG.portOutHi : CFG.portOut, isH, wired, true);
}
}
function drawPortCircle(x, y, r, color, hover, wired, isOut) {
ctx.beginPath(); ctx.arc(x, y, hover ? r + 2 : r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.shadowColor = color; ctx.shadowBlur = hover ? 14 : (wired ? 6 : 0);
ctx.fill(); ctx.shadowBlur = 0;
ctx.lineWidth = 2; ctx.strokeStyle = '#0b0e14'; ctx.stroke();
if (wired) { ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fillStyle = '#0b0e14'; ctx.fill(); }
}
function drawWires() {
for (const w of scene.wires) {
if (findNode(w.from) && findNode(w.to)) drawBezier(w.from, w.fromPort, w.to, w.toPort, getNodeState(w.from));
}
}
function getNodeState(id) { const n = findNode(id); return n ? n.state : 0; }
function drawBezier(fromId, fromPort, toId, toPort, on) {
const fo = portLoc(findNode(fromId), 'out', fromPort);
const to = portLoc(findNode(toId), 'in', toPort);
const x1 = fo[0], y1 = fo[1], x2 = to[0], y2 = to[1];
const kx = (x2 - x1) * CFG.bezierK;
ctx.beginPath(); ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1 + kx, y1, x2 - kx, y2, x2, y2);
if (on) { ctx.strokeStyle = CFG.wireOn; ctx.lineWidth = 3; ctx.shadowColor = CFG.wireOn; ctx.shadowBlur = 12; }
else { ctx.strokeStyle = CFG.wireOff; ctx.lineWidth = 2; ctx.shadowBlur = 0; }
ctx.stroke(); ctx.shadowBlur = 0;
}
function drawPreviewWire() {
if (!wireState) return;
if (!findNode(wireState.fromId)) return;
const fo = portLoc(findNode(wireState.fromId), 'out', wireState.fromPort);
const x1 = fo[0], y1 = fo[1];
let x2, y2, bad = false;
if (snapTarget) {
x2 = portLoc(findNode(snapTarget.node), 'in', snapTarget.index)[0];
y2 = portLoc(findNode(snapTarget.node), 'in', snapTarget.index)[1];
bad = snapTarget.bad;
} else { x2 = mouse.x; y2 = mouse.y; }
const kx = (x2 - x1) * CFG.bezierK;
ctx.beginPath(); ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1 + kx, y1, x2 - kx, y2, x2, y2);
ctx.lineWidth = 2.5;
if (bad) { ctx.strokeStyle = CFG.wireBad; ctx.shadowColor = CFG.wireBad; ctx.shadowBlur = 16; }
else { ctx.strokeStyle = snapTarget ? CFG.wireOn : CFG.wirePreview; ctx.shadowColor = snapTarget ? CFG.wireOn : 'transparent'; ctx.shadowBlur = snapTarget ? 12 : 0; }
ctx.setLineDash([6, 5]); ctx.stroke(); ctx.setLineDash([]); ctx.shadowBlur = 0;
// highlight origin output
const op = portLoc(findNode(wireState.fromId), 'out', wireState.fromPort);
drawPortCircle(op[0], op[1], CFG.portR, CFG.portOutHi, false, true, true);
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/* ============================================================
10. HIT TESTING
============================================================ */
function nearestPort(world, maxDist) {
let best = null, bestD = maxDist * maxDist;
for (const n of scene.nodes) {
const ps = portsOf(n);
for (let i = 0; i < ps.in.length; i++) {
const [x, y] = ps.in[i];
const d = (x - world[0]) ** 2 + (y - world[1]) ** 2;
if (d < bestD) { bestD = d; best = { node: n.id, kind: 'in', index: i }; }
}
for (let i = 0; i < ps.out.length; i++) {
const [x, y] = ps.out[i];
const d = (x - world[0]) ** 2 + (y - world[1]) ** 2;
if (d < bestD) { bestD = d; best = { node: n.id, kind: 'out', index: i }; }
}
}
return best;
}
function hitNode(world) {
for (let i = scene.nodes.length - 1; i >= 0; i--) {
const n = scene.nodes[i];
if (Math.abs(world[0] - n.x) < CFG.nodeW / 2 && Math.abs(world[1] - n.y) < CFG.nodeH / 2) return n;
}
return null;
}
function hitPort(world) {
const p = nearestPort(world, CFG.portHitR);
if (!p) return null;
return { node: findNode(p.node), kind: p.kind, index: p.index, id: p.node };
}
/* ============================================================
11. INTERACTION STATE
============================================================ */
let selected = null;
let hoveredPort = null;
const mouse = { x: 0, y: 0 };
let wireState = null; // { fromId, fromPort, rewiring, removed }
let dragKind = null; // 'pan' | 'node' | 'toggle'
let dragMoved = false;
let dragFrom = { x: 0, y: 0 }; // screen start
let nodeStart = { x: 0, y: 0 }; // world start for node drag
let viewStart = { x: 0, y: 0 }; // view start for pan
let toggleTarget = null;
let snapTarget = null;
/* ============================================================
12. POINTER EVENTS
============================================================ */
canvas.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
hideContextMenu();
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
const [wx, wy] = screenToWorld(mouse.x, mouse.y);
dragMoved = false;
dragFrom = { x: e.clientX, y: e.clientY };
const port = hitPort([wx, wy]);
if (port) {
if (port.kind === 'out') {
wireState = { fromId: port.id, fromPort: port.index, rewiring: false, removed: null };
dragKind = 'wire';
canvas.classList.add('wiring');
} else {
const existing = wiresToInput(port.id, port.index);
if (existing) {
wireState = { fromId: existing.from, fromPort: existing.fromPort, rewiring: true, removed: existing };
removeWire(existing);
dragKind = 'wire';
canvas.classList.add('wiring');
} else {
// free input: just select the node (cannot be a wire source)
selected = port.id; hoveredPort = null;
}
}
render();
return;
}
const node = hitNode([wx, wy]);
if (node) {
selected = node.id;
if (TYPES[node.type].toggle) { toggleTarget = node; dragKind = 'toggle'; }
else { dragKind = 'node'; nodeStart = { x: wx, y: wy }; }
hoveredPort = null;
render();
return;
}
selected = null; dragKind = 'pan'; viewStart = { x: view.x, y: view.y }; hoveredPort = null;
render();
});
window.addEventListener('pointermove', (e) => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
const [wx, wy] = screenToWorld(mouse.x, mouse.y);
const dxs = e.clientX - dragFrom.x, dys = e.clientY - dragFrom.y;
if (Math.abs(dxs) + Math.abs(dys) > CFG.panThreshold) dragMoved = true;
if (dragKind === 'wire') { updateWireSnap(); render(); return; }
if (dragKind === 'node') {
const [sx, sy] = worldToScreen(nodeStart.x, nodeStart.y);
const [nx, ny] = screenToWorld(mouse.x + dxs, mouse.y + dys);
const node = findNode(selected);
if (node) { node.x += wx - nodeStart.x; node.y += wy - nodeStart.y; nodeStart = { x: wx, y: wy }; render(); save(); }
return;
}
if (dragKind === 'pan') {
view.x = viewStart.x - dxs / view.zoom;
view.y = viewStart.y - dys / view.zoom;
canvas.classList.toggle('grabbing', dragMoved);
render();
return;
}
// hover (no active drag)
hoveredPort = hitPort([wx, wy]);
canvas.style.cursor = hoveredPort ? 'pointer' : 'grab';
render();
});
window.addEventListener('pointerup', (e) => {
if (dragKind === 'wire') finishWireDrag();
else if (dragKind === 'toggle' && !dragMoved) doToggle(toggleTarget);
if (dragKind === 'pan') canvas.classList.remove('grabbing');
wireState = null; dragKind = null; snapTarget = null; toggleTarget = null;
canvas.classList.remove('wiring');
render(); save();
});
/* ---- Wire snapping ---- */
function updateWireSnap() {
snapTarget = null;
const [wx, wy] = screenToWorld(mouse.x, mouse.y);
const p = nearestPort([wx, wy], CFG.portHitR);
if (!p || p.kind !== 'in') return;
const bad = wouldCreateCycle(wireState.fromId, wireState.fromPort, p.node, p.index);
snapTarget = { node: p.node, index: p.index, bad };
}
function finishWireDrag() {
if (!wireState) return;
if (snapTarget && !snapTarget.bad) {
const { fromId, fromPort } = wireState;
const existing = wireAt(snapTarget.node, snapTarget.index);
if (existing) removeWire(existing); // occupied input replaces old wire
scene.wires.push({ from: fromId, fromPort, to: snapTarget.node, toPort: snapTarget.index });
evaluate();
} else if (wireState.rewiring && wireState.removed) {
// invalid drop — restore the picked-up wire
if (!scene.wires.some(w => w === wireState.removed)) scene.wires.push(wireState.removed);
}
}
/* ---- Toggle ---- */
function doToggle(node) {
if (!node || !TYPES[node.type].toggle) return;
node.state = node.state ? 0 : 1;
evaluate();
save();
}
/* ============================================================
13. DELETE / ADD
============================================================ */
function deleteSelected() {
if (!selected) return;
scene.wires = scene.wires.filter(w => w.from !== selected && w.to !== selected);
scene.nodes = scene.nodes.filter(n => n.id !== selected);
selected = null; evaluate(); save(); render();
}
function addNode(type, x, y) {
const n = makeNode(type, x, y);
scene.nodes.push(n); selected = n.id; evaluate(); save(); render(); return n;
}
/* ============================================================
14. CONTEXT MENU
============================================================ */
const ctxMenu = document.getElementById('context');
const ctxDefs = [
{ type: 'input', label: 'Switch', sw: '#46b8ff' }, { type: 'clock', label: 'Clock', sw: '#ffd24a' },
{ sep: true },
{ type: 'and', label: 'AND', sw: '#a06bff' }, { type: 'or', label: 'OR', sw: '#c05bff' },
{ type: 'not', label: 'NOT', sw: '#e05bff' }, { type: 'xor', label: 'XOR', sw: '#ff6bd0' },
{ type: 'nand', label: 'NAND', sw: '#ff8a4a' }, { type: 'nor', label: 'NOR', sw: '#ffb14a' },
{ sep: true },
{ type: 'lamp', label: 'Lamp', sw: '#4aff9e' }, { type: 'display', label: '7-Segment', sw: '#3fe0c8' },
];
function showContextMenu(sx, sy) {
ctxMenu.innerHTML = '';
for (const d of ctxDefs) {
if (d.sep) { const s = document.createElement('div'); s.className = 'ctx-sep'; ctxMenu.appendChild(s); }
else {
const it = document.createElement('div'); it.className = 'ctx-item';
it.innerHTML = '<span class="sw" style="background:' + d.sw + '"></span>' + d.label;
it.onclick = () => { const [wx, wy] = screenToWorld(sx, sy); addNode(d.type, wx, wy); hideContextMenu(); };
ctxMenu.appendChild(it);
}
}
ctxMenu.style.display = 'block';
ctxMenu.style.left = Math.min(sx, innerWidth - 190) + 'px';
ctxMenu.style.top = Math.min(sy, innerHeight - 300) + 'px';
}
function hideContextMenu() { ctxMenu.style.display = 'none'; }
document.addEventListener('click', (e) => { if (!ctxMenu.contains(e.target)) hideContextMenu(); });
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
const r = canvas.getBoundingClientRect();
showContextMenu(e.clientX - r.left, e.clientY - r.top);
});
/* ============================================================
15. KEYBOARD + DBLCLICK
============================================================ */
window.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelected(); }
});
canvas.addEventListener('dblclick', (e) => {
const r = canvas.getBoundingClientRect();
const [wx, wy] = screenToWorld(e.clientX - r.left, e.clientY - r.top);
const node = hitNode([wx, wy]);
if (node && node.type === 'clock') {
const idx = CFG.clockRates.indexOf(node.rate);
node.rate = CFG.clockRates[(idx + 1) % CFG.clockRates.length];
evaluate(); save(); render();
}
});
/* ============================================================
16. WHEEL ZOOM (toward cursor)
============================================================ */
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const dir = e.deltaY < 0 ? 1 : -1;
const factor = dir > 0 ? CFG.zoomStep : 1 / CFG.zoomStep;
applyZoom(view.zoom * factor);
render();
}, { passive: false });
/* ============================================================
17. TOOLBAR + PALETTE
============================================================ */
document.getElementById('btn-fit').onclick = fitToView;
document.getElementById('btn-zin').onclick = () => { applyZoom(view.zoom * CFG.zoomStep); render(); };
document.getElementById('btn-zout').onclick = () => { applyZoom(view.zoom / CFG.zoomStep); render(); };
document.getElementById('btn-clear').onclick = () => {
if (confirm('Clear the entire circuit?')) { scene = { nodes: [], wires: [] }; selected = null; evaluate(); save(); render(); }
};
document.querySelectorAll('[data-add]').forEach(btn => {
btn.onclick = () => {
const [wx, wy] = screenToWorld(innerWidth / 2, innerHeight / 2);
addNode(btn.dataset.add, wx + (Math.random() - 0.5) * 40, wy + (Math.random() - 0.5) * 40);
};
});
function fitToView() {
if (!scene.nodes.length) { view.x = 0; view.y = 0; view.zoom = 1; updateZoomLabel(); render(); return; }
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of scene.nodes) {
minX = Math.min(minX, n.x - CFG.nodeW / 2); maxX = Math.max(maxX, n.x + CFG.nodeW / 2);
minY = Math.min(minY, n.y - CFG.nodeH / 2); maxY = Math.max(maxY, n.y + CFG.nodeH / 2);
}
const pad = 70, w = maxX - minX + pad * 2, h = maxY - minY + pad * 2;
view.zoom = Math.min(CFG.zoomMax, Math.max(CFG.zoomMin, Math.min(innerWidth / w, innerHeight / h)));
view.x = (minX + maxX) / 2; view.y = (minY + maxY) / 2;
updateZoomLabel(); render();
}
/* ============================================================
18. PERSISTENCE (localStorage)
============================================================ */
const STORE_KEY = 'logicNodeGraph.v2';
function save() {
try {
const data = {
nodes: scene.nodes.map(n => ({ type: n.type, x: n.x, y: n.y, state: n.state, rate: n.rate })),
wires: scene.wires.map(w => ({ from: w.from, fromPort: w.fromPort, to: w.to, toPort: w.toPort })),
view: { x: view.x, y: view.y, zoom: view.zoom },
};
localStorage.setItem(STORE_KEY, JSON.stringify(data));
} catch (e) {}
}
function load() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
if (!data || !data.nodes) return false;
scene = {
nodes: data.nodes.map(n => ({ type: n.type, x: n.x, y: n.y, state: n.state, rate: n.rate || 1 })),
wires: (data.wires || []).map(w => ({ from: w.from, fromPort: w.fromPort, to: w.to, toPort: w.toPort })),
};
pruneDanglingWires();
if (data.view) { view.x = data.view.x; view.y = data.view.y; view.zoom = data.view.zoom || 1; }
return true;
} catch (e) { return false; }
}
/* ============================================================
19. DEMO + MAIN LOOP
============================================================ */
function buildDemo() {
const a = makeNode('input', -240, -50); a.state = 1;
const b = makeNode('input', -240, 70); b.state = 0;
const clk = makeNode('clock', -240, 190); clk.rate = 1;
const and = makeNode('and', 0, 20);
const lamp = makeNode('lamp', 250, 20);
const disp = makeNode('display', 250, 160);
scene.nodes.push(a, b, clk, and, lamp, disp);
scene.wires.push({ from: a.id, fromPort: 0, to: and.id, toPort: 0 });
scene.wires.push({ from: b.id, fromPort: 0, to: and.id, toPort: 1 });
scene.wires.push({ from: and.id, fromPort: 0, to: lamp.id, toPort: 0 });
scene.wires.push({ from: a.id, fromPort: 0, to: disp.id, toPort: 0 });
scene.wires.push({ from: b.id, fromPort: 0, to: disp.id, toPort: 1 });
scene.wires.push({ from: clk.id, fromPort: 0, to: disp.id, toPort: 2 });
scene.wires.push({ from: clk.id, fromPort: 0, to: disp.id, toPort: 3 });
evaluate();
}
function loop(now) {
tickClocks(now);
render();
requestAnimationFrame(loop);
}
/* ============================================================
20. INIT
============================================================ */
function init() {
resize();
if (!load()) buildDemo();
updateZoomLabel();
render();
requestAnimationFrame(loop);
}
init();
/* ============================================================
DEBUG / TEST HOOKS (expose internals; harmless in prod)
============================================================ */
window.logic = {
scene, view, CFG, TYPES, gateOutput, evaluate, addNode, doToggle,
wouldCreateCycle, wiresToInput, wireFromOutput, removeWire, findNode,
applyZoom, worldToScreen, screenToWorld, portLoc, topoOrder, save, load,
deleteSelected, mouse,
hitPort, nearestPort, hitNode, updateWireSnap, finishWireDrag,
wireState, snapTarget, dragKind, selected, hoveredPort,
};
</script>
</body>
</html>
<!-- agent-meta {"model":"bartowski/Ornith-1.5-35B-A3B","provider":"brigid","persona":"full","sessionId":"10aac674-508a-42a2-9a4a-e6472c8c13cb","tokensIn":6350818,"tokensOut":95906,"tokensTotal":6446724,"cost":0,"turns":82,"toolCalls":81,"failedToolCalls":5,"timestamp":"2026-08-24T00:26:50.384Z"} -->