Node Graph Editor
GLM 5.3 Flash · 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 29,978 bytes · SHA-256 fff6f313c85d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Logic Circuit Node Editor</title>
<style>
:root{
--bg:#0b0f14; --panel:#131a23; --panel2:#1a2330; --edge:#2a3646;
--text:#c9d6e3; --dim:#6b7a8c;
--cool:#4fc3f7; --warm:#ff9e64; --hot:#ffd166; --bad:#ff5566;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden;background:var(--bg);font-family:"Segoe UI",system-ui,sans-serif;color:var(--text)}
#wrap{position:relative;width:100%;height:100%}
canvas{display:block;width:100%;height:100%;cursor:default}
/* ---------- palette sidebar ---------- */
#palette{position:absolute;top:12px;left:12px;background:var(--panel);border:1px solid var(--edge);
border-radius:10px;padding:10px;user-select:none;box-shadow:0 6px 24px rgba(0,0,0,.5);z-index:5}
#palette h3{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:2px 2px 8px}
.pal-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.pal-btn{background:var(--panel2);border:1px solid var(--edge);color:var(--text);border-radius:7px;
padding:6px 10px;font-size:12px;cursor:pointer;transition:all .12s}
.pal-btn:hover{border-color:var(--cool);color:#fff;transform:translateY(-1px)}
.pal-btn.out{border-left:3px solid var(--warm)}
.pal-btn.gate{border-left:3px solid var(--cool)}
.pal-btn.in{border-left:3px solid #9ccc65}
#hint{position:absolute;bottom:10px;left:12px;font-size:11px;color:var(--dim);z-index:5;pointer-events:none}
/* ---------- context menu ---------- */
#ctxmenu{position:absolute;display:none;background:var(--panel);border:1px solid var(--edge);border-radius:9px;
padding:6px;min-width:150px;box-shadow:0 8px 28px rgba(0,0,0,.6);z-index:20;user-select:none}
#ctxmenu .mi{padding:6px 12px;border-radius:6px;font-size:12px;cursor:pointer}
#ctxmenu .mi:hover{background:var(--panel2);color:#fff}
#ctxmenu .sep{height:1px;background:var(--edge);margin:5px 4px}
#toast{position:absolute;top:14px;left:50%;transform:translateX(-50%);background:#2a1218;border:1px solid var(--bad);
color:#ff8f9c;padding:7px 16px;border-radius:8px;font-size:12px;display:none;z-index:30;pointer-events:none}
</style>
</head>
<body>
<div id="wrap">
<canvas id="cv"></canvas>
<div id="palette">
<h3>Components</h3>
<div class="pal-grid">
<button class="pal-btn in" data-add="toggle">Toggle</button>
<button class="pal-btn in" data-add="clock">Clock</button>
<button class="pal-btn gate" data-add="AND">AND</button>
<button class="pal-btn gate" data-add="OR">OR</button>
<button class="pal-btn gate" data-add="NOT">NOT</button>
<button class="pal-btn gate" data-add="XOR">XOR</button>
<button class="pal-btn gate" data-add="NAND">NAND</button>
<button class="pal-btn gate" data-add="NOR">NOR</button>
<button class="pal-btn out" data-add="lamp">Lamp</button>
<button class="pal-btn out" data-add="digit">Hex Digit</button>
</div>
</div>
<div id="ctxmenu"></div>
<div id="toast"></div>
<div id="hint">Drag ports to wire · drag body to move · wheel zoom · right-click to add · Del removes selection · dbl-click clock for rate</div>
</div>
<script>
"use strict";
/* ============================================================
TUNABLE CONSTANTS
============================================================ */
const CONST = {
ZOOM_MIN: 0.5, ZOOM_MAX: 2.0, ZOOM_SPEED: 0.0012,
GRID: 48, GRID_DOT: 1.6,
PORT_R: 7, PORT_R_HOVER: 10, PORT_HIT: 12, // PORT_HIT in screen px
NODE_W: 128, NODE_H_MIN: 62, PORT_SPACING: 30, HEADER: 22,
WIRE_HIT: 8, // screen px
WIRE_GLOW: 14, WIRE_WIDTH: 2.4,
COLORS: {
bg:'#0b0f14', grid:'#1b2530',
node:'#1a2330', nodeStroke:'#33445a', nodeHover:'#212d3d', nodeSel:'#4fc3f7',
title:'#c9d6e3', dim:'#6b7a8c',
inPort:'#4fc3f7', outPort:'#ff9e64',
wireOff:'#33445c', wireOn:'#ffd166',
lampOff:'#2a3646', lampOn:'#ffd166',
segOff:'#1c2634', segOn:'#7CFC9A',
bad:'#ff5566'
},
CLOCK_RATES: [2000,1000,500,250,100],
LS_KEY: 'logic-circuit-scene-v1'
};
/* ============================================================
NODE TYPE DEFINITIONS
============================================================ */
const DEFS = {
toggle:{ title:'Toggle', inputs:0, outputs:1, color:'#9ccc65' },
clock :{ title:'Clock', inputs:0, outputs:1, color:'#ce93d8' },
AND :{ title:'AND', inputs:2, outputs:1, color:'#4fc3f7' },
OR :{ title:'OR', inputs:2, outputs:1, color:'#4fc3f7' },
NOT :{ title:'NOT', inputs:1, outputs:1, color:'#4fc3f7' },
XOR :{ title:'XOR', inputs:2, outputs:1, color:'#4fc3f7' },
NAND :{ title:'NAND', inputs:2, outputs:1, color:'#4fc3f7' },
NOR :{ title:'NOR', inputs:2, outputs:1, color:'#4fc3f7' },
lamp :{ title:'Lamp', inputs:1, outputs:0, color:'#ffd166' },
digit :{ title:'Hex', inputs:4, outputs:0, color:'#7CFC9A' }
};
const nodeH = t => Math.max(CONST.NODE_H_MIN, Math.max(DEFS[t].inputs, DEFS[t].outputs,1)*CONST.PORT_SPACING + CONST.HEADER + 14);
/* ============================================================
SCENE STATE (the single source of truth — serializable)
============================================================ */
let nodes = []; // {id,type,x,y,state,value,_iv (clock interval, not serialized)}
let wires = []; // {from,fromPort,to,toPort}
let nextId = 1;
let cam = { x: 0, y: 0, scale: 1 };
let topo = null; // cached topological order of node ids
let selNode = null, selWire = -1;
let hover = { node:null, port:null, wire:-1 };
/* ============================================================
HELPERS
============================================================ */
const nodeById = id => nodes.find(n => n.id === id);
// (proper version below — kept simple & correct)
function outPortPos(n, i){
const h = nodeH(n.type), cnt = DEFS[n.type].outputs || 1;
return { x: n.x + CONST.NODE_W, y: n.y + CONST.HEADER + (h-CONST.HEADER)*(i+1)/(cnt+1) };
}
function inPortPos(n, i){
const h = nodeH(n.type), cnt = DEFS[n.type].inputs || 1;
return { x: n.x, y: n.y + CONST.HEADER + (h-CONST.HEADER)*(i+1)/(cnt+1) };
}
function wirePts(x1,y1,x2,y2){
const dx = Math.max(40, Math.abs(x2-x1)*0.5);
return [x1,y1, x1+dx,y1, x2-dx,y2, x2,y2];
}
function screenToWorld(sx,sy){ return { x:(sx-cam.x)/cam.scale, y:(sy-cam.y)/cam.scale }; }
function toast(msg){
const t = document.getElementById('toast');
t.textContent = msg; t.style.display = 'block';
clearTimeout(t._h); t._h = setTimeout(()=>t.style.display='none', 1200);
}
/* ============================================================
EVALUATION ENGINE — topological, single pass, downstream-only
============================================================ */
function inputValues(n){
const vals = new Array(DEFS[n.type].inputs).fill(0);
for (const w of wires) if (w.to === n.id){
const src = nodeById(w.from);
vals[w.toPort] = src ? (src.value ? 1 : 0) : 0;
}
return vals;
}
function calcOutput(n){
const d = DEFS[n.type];
if (n.type === 'toggle' || n.type === 'clock') return n.state ? 1 : 0;
const v = inputValues(n);
switch(n.type){
case 'AND': return (v[0] && v[1]) ? 1 : 0;
case 'OR': return (v[0] || v[1]) ? 1 : 0;
case 'XOR': return (v[0] ^ v[1]) ? 1 : 0;
case 'NAND': return (v[0] && v[1]) ? 0 : 1;
case 'NOR': return (v[0] || v[1]) ? 0 : 1;
case 'NOT': return v[0] ? 0 : 1;
case 'lamp': return v[0];
case 'digit':return (v[0]<<3)|(v[1]<<2)|(v[2]<<1)|v[3];
}
return 0;
}
function computeTopo(){
const indeg = new Map(nodes.map(n=>[n.id,0]));
const adj = new Map(nodes.map(n=>[n.id,[]]));
for (const w of wires){
if (!nodeById(w.from) || !nodeById(w.to)) continue;
adj.get(w.from).push(w.to);
indeg.set(w.to, indeg.get(w.to)+1);
}
const q = nodes.filter(n=>indeg.get(n.id)===0).map(n=>n.id);
const order = [];
while (q.length){
const id = q.shift(); order.push(id);
for (const m of adj.get(id)){ const d = indeg.get(m) - 1; indeg.set(m, d); if (d === 0) q.push(m); }
}
for (const n of nodes) if (!order.includes(n.id)) order.push(n.id); // safety
return order;
}
// dirty: Set of node ids whose OUTPUT may have changed. null => full evaluation.
function evaluate(dirty){
if (!topo || !dirty) topo = computeTopo();
if (!dirty) dirty = new Set(nodes.map(n=>n.id));
for (const id of topo){
if (!dirty.has(id)) continue;
const n = nodeById(id); if (!n) continue;
const v = calcOutput(n);
if (v !== n.value){
n.value = v;
for (const w of wires) if (w.from === id) dirty.add(w.to);
}
}
}
/* ============================================================
CYCLE DETECTION — adding from->to is a cycle if `from` is
reachable forward from `to`.
============================================================ */
function createsCycle(fromId, toId){
const stack = [toId], seen = new Set();
while (stack.length){
const id = stack.pop();
if (id === fromId) return true;
if (seen.has(id)) continue;
seen.add(id);
for (const w of wires) if (w.from === id) stack.push(w.to);
}
return false;
}
/* ============================================================
CLOCKS
============================================================ */
function startClock(n){
stopClock(n);
n._iv = setInterval(()=>{
n.state = n.state ? 0 : 1;
evaluate(new Set([n.id]));
save();
}, n.rate || 500);
}
function stopClock(n){ if (n._iv){ clearInterval(n._iv); n._iv = null; } }
function cycleRate(n){
const i = CONST.CLOCK_RATES.indexOf(n.rate || 500);
n.rate = CONST.CLOCK_RATES[(i+1) % CONST.CLOCK_RATES.length];
startClock(n); save();
}
/* ============================================================
MUTATIONS
============================================================ */
function addNode(type, x, y){
const n = { id: nextId++, type, x: Math.round(x), y: Math.round(y), state: 0, value: 0 };
if (type === 'clock') n.rate = 500;
nodes.push(n);
if (type === 'clock') startClock(n);
evaluate(new Set([n.id]));
save();
return n;
}
function deleteNode(id){
const n = nodeById(id); if (!n) return;
stopClock(n);
nodes = nodes.filter(x => x.id !== id);
wires = wires.filter(w => w.from !== id && w.to !== id);
topo = null;
evaluate(null); save();
}
function deleteWire(i){
const w = wires[i]; if (!w) return;
wires.splice(i,1); topo = null;
evaluate(new Set([w.to])); save();
}
function connect(from, fromPort, to, toPort){
if (createsCycle(from, to)) return false;
wires = wires.filter(w => !(w.to === to && w.toPort === toPort)); // one wire per input
wires.push({ from, fromPort, to, toPort });
topo = null;
evaluate(new Set([to])); save();
return true;
}
/* ============================================================
PERSISTENCE
============================================================ */
let saveTimer = null;
function save(){
clearTimeout(saveTimer);
saveTimer = setTimeout(()=>{
const data = {
nodes: nodes.map(n => ({ id:n.id, type:n.type, x:n.x, y:n.y, state:n.state, rate:n.rate })),
wires: wires.map(w => ({ from:w.from, fromPort:w.fromPort, to:w.to, toPort:w.toPort })),
nextId, cam
};
try { localStorage.setItem(CONST.LS_KEY, JSON.stringify(data)); } catch(e){}
}, 120);
}
function load(){
let data = null;
try { data = JSON.parse(localStorage.getItem(CONST.LS_KEY)); } catch(e){}
if (!data || !Array.isArray(data.nodes)) return false;
nodes = data.nodes.map(n => ({ ...n, value: 0, _iv: null }));
wires = (data.wires || []).filter(w => nodeById(w.from) && nodeById(w.to));
nextId = data.nextId || (Math.max(0, ...nodes.map(n=>n.id)) + 1);
if (data.cam) cam = data.cam;
for (const n of nodes) if (n.type === 'clock') startClock(n);
topo = null;
evaluate(null);
return true;
}
/* ============================================================
RENDERING
============================================================ */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let dpr = 1;
function resize(){
dpr = window.devicePixelRatio || 1;
cv.width = innerWidth * dpr; cv.height = innerHeight * dpr;
}
addEventListener('resize', resize); resize();
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();
}
function draw(){
const W = innerWidth, H = innerHeight;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.fillStyle = CONST.COLORS.bg; ctx.fillRect(0,0,W,H);
// ---- grid dots (world space) ----
ctx.setTransform(dpr*cam.scale,0,0,dpr*cam.scale,dpr*cam.x,dpr*cam.y);
const wx0 = -cam.x/cam.scale, wy0 = -cam.y/cam.scale;
const wx1 = wx0 + W/cam.scale, wy1 = wy0 + H/cam.scale;
const g = CONST.GRID;
ctx.fillStyle = CONST.COLORS.grid;
const r = CONST.GRID_DOT / cam.scale;
for (let gx = Math.floor(wx0/g)*g; gx <= wx1; gx += g)
for (let gy = Math.floor(wy0/g)*g; gy <= wy1; gy += g){
ctx.beginPath(); ctx.arc(gx,gy,r,0,7); ctx.fill();
}
// ---- wires ----
for (let i=0;i<wires.length;i++){
const w = wires[i];
const a = nodeById(w.from), b = nodeById(w.to);
if (!a || !b) continue;
const p1 = outPortPos(a, w.fromPort), p2 = inPortPos(b, w.toPort);
const on = a.value ? 1 : 0;
const pts = wirePts(p1.x,p1.y,p2.x,p2.y);
ctx.beginPath();
ctx.moveTo(pts[0],pts[1]);
ctx.bezierCurveTo(pts[2],pts[3],pts[4],pts[5],pts[6],pts[7]);
if (i === selWire){ ctx.strokeStyle = '#ffffff'; ctx.lineWidth = CONST.WIRE_WIDTH+1.6; ctx.stroke(); }
ctx.strokeStyle = on ? CONST.COLORS.wireOn : CONST.COLORS.wireOff;
ctx.lineWidth = CONST.WIRE_WIDTH;
ctx.shadowBlur = on ? CONST.WIRE_GLOW : 0;
ctx.shadowColor = CONST.COLORS.wireOn;
ctx.stroke();
ctx.shadowBlur = 0;
}
// ---- wire drag preview ----
if (drag && drag.mode === 'wire'){
let p1;
if (drag.fromKind === 'out') p1 = outPortPos(nodeById(drag.fromId), drag.fromPort);
else p1 = { x: drag.wx, y: drag.wy };
const p2 = drag.fromKind === 'out' ? { x: drag.wx, y: drag.wy }
: inPortPos(nodeById(drag.fromId), drag.fromPort);
const ok = drag.hover && !drag.hover.cycle;
const pts = wirePts(p1.x,p1.y,p2.x,p2.y);
ctx.beginPath();
ctx.moveTo(pts[0],pts[1]);
ctx.bezierCurveTo(pts[2],pts[3],pts[4],pts[5],pts[6],pts[7]);
ctx.strokeStyle = ok ? CONST.COLORS.wireOn
: drag.hover && drag.hover.cycle ? CONST.COLORS.bad
: '#7f95ad';
ctx.lineWidth = CONST.WIRE_WIDTH; ctx.setLineDash([6/cam.scale, 5/cam.scale]);
ctx.stroke(); ctx.setLineDash([]);
}
// ---- rejected-wire red flash ----
for (const f of flashes){
const t = (performance.now() - f.t0) / 450;
if (t >= 1) continue;
const pts = f.pts;
ctx.beginPath(); ctx.moveTo(pts[0],pts[1]);
ctx.bezierCurveTo(pts[2],pts[3],pts[4],pts[5],pts[6],pts[7]);
ctx.strokeStyle = CONST.COLORS.bad;
ctx.globalAlpha = 1 - t;
ctx.lineWidth = CONST.WIRE_WIDTH + 2.5; ctx.stroke();
ctx.globalAlpha = 1;
}
flashes = flashes.filter(f => performance.now() - f.t0 < 450);
// ---- nodes ----
for (const n of nodes){
const d = DEFS[n.type], h = nodeH(n.type), w = CONST.NODE_W;
const isHover = hover.node === n.id, isSel = selNode === n.id;
// lift on hover
const lift = isHover ? -1.5 : 0;
// shadow / depth
ctx.shadowBlur = isHover ? 18 : 8;
ctx.shadowColor = 'rgba(0,0,0,.55)';
ctx.shadowOffsetY = isHover ? 4 : 2;
ctx.fillStyle = isHover ? CONST.COLORS.nodeHover : CONST.COLORS.node;
roundRect(n.x, n.y+lift, w, h, 10); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
// state tint border
const active = n.value ? 1 : 0;
ctx.lineWidth = isSel ? 2.2 : 1.2;
ctx.strokeStyle = isSel ? CONST.COLORS.nodeSel
: active ? d.color : CONST.COLORS.nodeStroke;
if (active){ ctx.shadowBlur = 10; ctx.shadowColor = d.color; }
roundRect(n.x, n.y+lift, w, h, 10); ctx.stroke();
ctx.shadowBlur = 0;
// header
ctx.fillStyle = d.color;
ctx.font = '600 11px "Segoe UI",sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(d.title, n.x + 12, n.y + lift + CONST.HEADER/2 + 1);
// body content per type
drawNodeBody(n, lift);
// ports
drawPorts(n, lift);
}
// restore screen transform for HUD
ctx.setTransform(dpr,0,0,dpr,0,0);
requestAnimationFrame(draw);
}
function drawNodeBody(n, lift){
const d = DEFS[n.type];
ctx.textBaseline = 'middle';
if (n.type === 'toggle'){
// switch track
const tx = n.x + 14, ty = n.y + lift + CONST.HEADER + 12, tw = 44, th = 20;
ctx.fillStyle = n.state ? '#3d6b2f' : '#232f3f';
roundRect(tx,ty,tw,th,10); ctx.fill();
ctx.fillStyle = n.state ? '#9ccc65' : '#5a6b7f';
ctx.beginPath(); ctx.arc(n.state ? tx+tw-11 : tx+11, ty+th/2, 7.5, 0, 7); ctx.fill();
ctx.fillStyle = CONST.COLORS.dim; ctx.font = '11px "Segoe UI",sans-serif';
ctx.fillText(n.state ? 'ON' : 'OFF', tx + tw + 12, ty + th/2);
} else if (n.type === 'clock'){
ctx.fillStyle = CONST.COLORS.dim; ctx.font = '11px "Segoe UI",sans-serif';
ctx.fillText((n.rate||500) + ' ms', n.x + 14, n.y + lift + CONST.HEADER + 20);
// pulse indicator
ctx.fillStyle = n.state ? '#ce93d8' : '#3a2f42';
ctx.beginPath(); ctx.arc(n.x + CONST.NODE_W - 22, n.y + lift + CONST.HEADER + 20, 7, 0, 7); ctx.fill();
} else if (n.type === 'lamp'){
const cx = n.x + CONST.NODE_W/2, cy = n.y + lift + CONST.HEADER + (nodeH('lamp')-CONST.HEADER)/2;
const R = 15;
ctx.beginPath(); ctx.arc(cx,cy,R,0,7);
ctx.fillStyle = n.value ? CONST.COLORS.lampOn : CONST.COLORS.lampOff;
if (n.value){ ctx.shadowBlur = 22; ctx.shadowColor = CONST.COLORS.lampOn; }
ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = '#3a4a5e'; ctx.lineWidth = 1.5; ctx.stroke();
} else if (n.type === 'digit'){
drawSevenSeg(n, lift);
} else {
// gates: show live inputs
const v = inputValues(n);
ctx.font = '11px "Segoe UI",sans-serif'; ctx.fillStyle = CONST.COLORS.dim;
v.forEach((b,i)=>{
const p = inPortPos(n, i);
ctx.fillText(b ? '1' : '0', p.x + 10, p.y);
});
}
}
const SEG = [0b1111110,0b0110000,0b1101101,0b1111001,0b0110011,0b1011011,0b1011111,0b1110000,
0b1111111,0b1111011,0b1110111,0b0011111,0b1001110,0b0111101,0b1001111,0b1000111];
function drawSevenSeg(n, lift){
const val = n.value & 15, bits = SEG[val];
const ox = n.x + 34, oy = n.y + lift + CONST.HEADER + 8;
const W = 26, H = 40, t = 4;
const segs = [
[ox+3, oy, W-6, t], // a
[ox+W-t, oy+3, t, H/2-4], // b
[ox+W-t, oy+H/2+1, t, H/2-4], // c
[ox+3, oy+H-t, W-6, t], // d
[ox, oy+H/2+1, t, H/2-4], // e
[ox, oy+3, t, H/2-4], // f
[ox+3, oy+H/2-t/2, W-6, t] // g
];
segs.forEach((s,i)=>{
ctx.fillStyle = (bits >> (6-i)) & 1 ? CONST.COLORS.segOn : CONST.COLORS.segOff;
if ((bits>>i) & 1) {} // (bit order: seg a = MSB)
ctx.fillRect(s[0],s[1],s[2],s[3]);
});
// port bit labels
ctx.font = '10px "Segoe UI",sans-serif'; ctx.fillStyle = CONST.COLORS.dim;
for (let i=0;i<4;i++){
const p = inPortPos(n, i);
ctx.fillText(String((val >> (3-i)) & 1), p.x + 9, p.y);
}
}
function drawPorts(n, lift){
const d = DEFS[n.type];
for (let i=0;i<d.inputs;i++){
const p = inPortPos(n, i);
const isH = hover.port && hover.port.node === n.id && hover.port.kind === 'in' && hover.port.idx === i;
const r = isH ? CONST.PORT_R_HOVER : CONST.PORT_R;
const wired = wires.some(w => w.to === n.id && w.toPort === i);
ctx.beginPath(); ctx.arc(p.x, p.y + lift, r, 0, 7);
ctx.fillStyle = CONST.COLORS.inPort; ctx.shadowBlur = wired ? 8 : 0;
ctx.shadowColor = CONST.COLORS.inPort; ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = '#0b0f14'; ctx.lineWidth = 2; ctx.stroke();
if (isH){ ctx.beginPath(); ctx.arc(p.x, p.y+lift, r+4, 0, 7); ctx.strokeStyle = CONST.COLORS.inPort; ctx.lineWidth = 1.5; ctx.stroke(); }
}
for (let i=0;i<d.outputs;i++){
const p = outPortPos(n, i);
const isH = hover.port && hover.port.node === n.id && hover.port.kind === 'out' && hover.port.idx === i;
const r = isH ? CONST.PORT_R_HOVER : CONST.PORT_R;
ctx.beginPath(); ctx.arc(p.x, p.y + lift, r, 0, 7);
ctx.fillStyle = CONST.COLORS.outPort; ctx.shadowBlur = n.value ? 10 : 0;
ctx.shadowColor = CONST.COLORS.outPort; ctx.fill(); ctx.shadowBlur = 0;
ctx.strokeStyle = '#0b0f14'; ctx.lineWidth = 2; ctx.stroke();
if (isH){ ctx.beginPath(); ctx.arc(p.x, p.y+lift, r+4, 0, 7); ctx.strokeStyle = CONST.COLORS.outPort; ctx.lineWidth = 1.5; ctx.stroke(); }
}
}
/* ============================================================
HIT TESTING (all in world coords; tolerances screen-constant)
============================================================ */
function hitPort(wx, wy){
const tol = CONST.PORT_HIT / cam.scale;
let best = null, bestD = tol;
for (const n of nodes){
const d = DEFS[n.type];
for (let i=0;i<d.inputs;i++){
const p = inPortPos(n,i), dd = Math.hypot(wx-p.x, wy-p.y);
if (dd < bestD){ bestD = dd; best = { node:n.id, kind:'in', idx:i, x:p.x, y:p.y }; }
}
for (let i=0;i<d.outputs;i++){
const p = outPortPos(n,i), dd = Math.hypot(wx-p.x, wy-p.y);
if (dd < bestD){ bestD = dd; best = { node:n.id, kind:'out', idx:i, x:p.x, y:p.y }; }
}
}
return best;
}
function hitNode(wx, wy){
for (let i = nodes.length-1; i >= 0; i--){
const n = nodes[i];
if (wx >= n.x && wx <= n.x + CONST.NODE_W && wy >= n.y && wy <= n.y + nodeH(n.type)) return n;
}
return null;
}
function hitWire(wx, wy){
const tol = CONST.WIRE_HIT / cam.scale;
for (let i = wires.length-1; i >= 0; i--){
const w = wires[i];
const a = nodeById(w.from), b = nodeById(w.to);
if (!a || !b) continue;
const p1 = outPortPos(a, w.fromPort), p2 = inPortPos(b, w.toPort);
const pts = wirePts(p1.x,p1.y,p2.x,p2.y);
for (let s = 0; s <= 28; s++){
const t = s/28, mt = 1-t;
// cubic bezier point
const bx = mt*mt*mt*pts[0] + 3*mt*mt*t*pts[2] + 3*mt*t*t*pts[4] + t*t*t*pts[6];
const by = mt*mt*mt*pts[1] + 3*mt*mt*t*pts[3] + 3*mt*t*t*pts[5] + t*t*t*pts[7];
if (Math.hypot(wx-bx, wy-by) < tol) return i;
}
}
return -1;
}
/* ============================================================
INTERACTION
============================================================ */
let drag = null; // {mode:'pan'|'node'|'wire', ...}
let mouse = { x:0, y:0, wx:0, wy:0 };
let flashes = [];
cv.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const { x:wx, y:wy } = screenToWorld(e.clientX, e.clientY);
const port = hitPort(wx, wy);
if (port){
if (port.kind === 'out'){
drag = { mode:'wire', fromKind:'out', fromId:port.node, fromPort:port.idx, wx, wy, hover:null };
} else {
// pick up existing wire from occupied input port
const wi = wires.findIndex(w => w.to === port.node && w.toPort === port.idx);
if (wi >= 0){
const w = wires[wi];
wires.splice(wi,1); topo = null;
evaluate(new Set([port.node]));
drag = { mode:'wire', fromKind:'out', fromId:w.from, fromPort:w.fromPort, wx, wy, hover:null, moved:true };
save();
} else {
drag = { mode:'wire', fromKind:'in', fromId:port.node, fromPort:port.idx, wx, wy, hover:null };
}
}
return;
}
const n = hitNode(wx, wy);
if (n){
selNode = n.id; selWire = -1;
drag = { mode:'node', id:n.id, ox:wx-n.x, oy:wy-n.y, sx:e.clientX, sy:e.clientY, moved:false };
return;
}
const wi = hitWire(wx, wy);
if (wi >= 0){ selWire = wi; selNode = null; return; }
selNode = null; selWire = -1;
drag = { mode:'pan', sx:e.clientX, sy:e.clientY, cx:cam.x, cy:cam.y };
});
cv.addEventListener('mousemove', e => {
mouse.x = e.clientX; mouse.y = e.clientY;
const w = screenToWorld(e.clientX, e.clientY);
mouse.wx = w.x; mouse.wy = w.y;
if (drag){
if (drag.mode === 'pan'){
cam.x = drag.cx + (e.clientX - drag.sx);
cam.y = drag.cy + (e.clientY - drag.sy);
} else if (drag.mode === 'node'){
const n = nodeById(drag.id);
if (n){ n.x = Math.round(w.x - drag.ox); n.y = Math.round(w.y - drag.oy); drag.moved = true; }
} else if (drag.mode === 'wire'){
drag.wx = w.x; drag.wy = w.y;
const hp = hitPort(w.x, w.y);
if (hp && hp.kind === 'in' && !(hp.node === drag.fromId && drag.fromKind === 'out' && false)){
const cycle = drag.fromKind === 'out' && createsCycle(drag.fromId, hp.node);
drag.hover = { ...hp, cycle };
} else if (hp && drag.fromKind === 'in' && hp.kind === 'in'){
drag.hover = null; // input->input not allowed
} else {
drag.hover = null;
}
}
} else {
// hover state
const hp = hitPort(w.x, w.y);
const hn = hp ? null : hitNode(w.x, w.y);
hover.port = hp;
hover.node = hn ? hn.id : null;
hover.wire = (!hp && !hn) ? hitWire(w.x, w.y) : -1;
cv.style.cursor = hp ? 'crosshair' : hn ? 'grab' : hover.wire >= 0 ? 'pointer' : 'default';
}
});
addEventListener('mouseup', e => {
if (!drag) return;
if (drag.mode === 'wire'){
const w = screenToWorld(e.clientX, e.clientY);
const hp = hitPort(w.x, w.y);
if (hp && hp.kind === 'in'){
if (drag.fromKind === 'out'){
if (createsCycle(drag.fromId, hp.node)){
// ---- cycle rejected: red flash along attempted wire ----
const a = nodeById(drag.fromId);
const p1 = outPortPos(a, drag.fromPort), p2 = inPortPos(nodeById(hp.node), hp.idx);
flashes.push({ pts: wirePts(p1.x,p1.y,p2.x,p2.y), t0: performance.now() });
toast('⚠ Feedback loop rejected — combinational circuits only');
} else {
connect(drag.fromId, drag.fromPort, hp.node, hp.idx);
}
}
// picking up from an input and dropping on another input is a no-op
}
} else if (drag.mode === 'node'){
if (drag.moved) save();
else {
// click (no drag): toggle switch or cycle clock rate
const n = nodeById(drag.id);
if (n && n.type === 'toggle'){ n.state = n.state ? 0 : 1; evaluate(new Set([n.id])); save(); }
}
}
drag = null;
});
cv.addEventListener('dblclick', e => {
const { x:wx, y:wy } = screenToWorld(e.clientX, e.clientY);
const n = hitNode(wx, wy);
if (n && n.type === 'clock') cycleRate(n);
});
cv.addEventListener('wheel', e => {
e.preventDefault();
const s0 = cam.scale;
const s1 = Math.min(CONST.ZOOM_MAX, Math.max(CONST.ZOOM_MIN, s0 * Math.exp(-e.deltaY * CONST.ZOOM_SPEED)));
// zoom-to-cursor: world point under cursor stays put
const wx = (e.clientX - cam.x) / s0, wy = (e.clientY - cam.y) / s0;
cam.scale = s1;
cam.x = e.clientX - wx * s1;
cam.y = e.clientY - wy * s1;
save();
}, { passive:false });
cv.addEventListener('contextmenu', e => {
e.preventDefault();
const { x:wx, y:wy } = screenToWorld(e.clientX, e.clientY);
// delete node via right-click? No — show add menu
showCtxMenu(e.clientX, e.clientY, wx, wy);
});
addEventListener('keydown', e => {
if (e.key === 'Delete' || e.key === 'Backspace'){
if (selNode != null){ deleteNode(selNode); selNode = null; }
else if (selWire >= 0){ deleteWire(selWire); selWire = -1; }
}
});
/* ---------- context menu ---------- */
const ctxMenu = document.getElementById('ctxmenu');
function showCtxMenu(sx, sy, wx, wy){
ctxMenu.innerHTML = '';
const addTypes = Object.keys(DEFS);
for (const t of addTypes){
const div = document.createElement('div');
div.className = 'mi';
div.textContent = '+ ' + DEFS[t].title;
div.onclick = () => { addNode(t, wx, wy); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(div);
}
const sep = document.createElement('div'); sep.className = 'sep'; ctxMenu.appendChild(sep);
if (selNode != null){
const del = document.createElement('div');
del.className = 'mi'; del.textContent = 'Delete selected';
del.onclick = () => { deleteNode(selNode); selNode = null; ctxMenu.style.display='none'; };
ctxMenu.appendChild(del);
}
ctxMenu.style.left = sx + 'px'; ctxMenu.style.top = sy + 'px';
ctxMenu.style.display = 'block';
}
addEventListener('mousedown', e => { if (!ctxMenu.contains(e.target)) ctxMenu.style.display = 'none'; }, true);
/* ---------- palette buttons ---------- */
let palCount = 0;
document.querySelectorAll('[data-add]').forEach(b => {
b.onclick = () => {
const w = screenToWorld(innerWidth/2 + (palCount%5)*30 - 60, innerHeight/2 + (palCount%4)*30 - 60);
palCount++;
addNode(b.dataset.add, w.x, w.y);
};
});
/* ============================================================
BOOT
============================================================ */
if (!load()){
// starter circuit: Toggle -> AND(toggle2) -> NOT -> Lamp
const t1 = addNode('toggle', 120, 140); t1.state = 1;
const t2 = addNode('toggle', 120, 260);
const g = addNode('AND', 340, 190);
const nt = addNode('NOT', 540, 190);
const lp = addNode('lamp', 720, 190);
connect(t1.id,0,g.id,0); connect(t2.id,0,g.id,1); connect(g.id,0,nt.id,0); connect(nt.id,0,lp.id,0);
evaluate(null);
}
requestAnimationFrame(draw);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"70b9b4f4-4ca8-4c24-93bb-c273a2ebd0f6","tokensIn":494739,"tokensOut":17866,"tokensTotal":512605,"cost":0.018341485000000008,"turns":19,"toolCalls":19,"failedToolCalls":0,"timestamp":"2026-08-31T14:35:17.530Z"} -->