Node Graph Editor
muse spark 1.2 contributor · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 45,190 bytes · SHA-256 a8f9ac001c5d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logic Node Graph — Instrument Panel</title>
<style>
:root{
--bg:#080c14;
--panel:#111827;
--panel2:#1a2332;
--border:#1e2e4a;
--text:#cbd5e1;
--muted:#64748b;
--accent:#38bdf8;
--warm:#f59e0b;
--cool:#38bdf8;
--glow-on:#22d3ee;
--glow-off:#334155;
--danger:#ef4444;
--success:#10b981;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font-family: ui-sans-system, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background:var(--bg);
color:var(--text);
overflow:hidden;
height:100vh;
display:flex;
flex-direction:column;
user-select:none;
}
/* Header */
header{
height:48px;
background:linear-gradient(180deg,#0f172a 0%,#0b1220 100%);
border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
padding:0 16px;
flex-shrink:0;
}
.brand{display:flex;align-items:center;gap:10px}
.logo{
width:32px;height:32px;border-radius:8px;
background:radial-gradient(120% 120% at 30% 20%,#38bdf8 0%,#0ea5e9 40%,#1e3a5f 100%);
display:grid;place-items:center;
box-shadow:0 0 12px rgba(56,189,248,.4), inset 0 1px 0 rgba(255,255,255,.3);
font-weight:800;color:#fff;font-size:14px;letter-spacing:-.5px;
}
.brand h1{font-size:14px;font-weight:700;letter-spacing:.04em}
.brand p{font-size:11px;color:var(--muted);margin-top:1px}
.hdr-actions{display:flex;gap:8px;align-items:center}
.btn{
background:var(--panel2);border:1px solid var(--border);color:var(--text);
padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;
transition:.15s;
}
.btn:hover{background:#1e2d4a;border-color:#2a3f66;transform:translateY(-1px)}
.btn.primary{background:linear-gradient(180deg,#0ea5e9,#0284c7);border-color:#0ea5e9;color:#fff;box-shadow:0 2px 10px rgba(14,165,233,.3)}
.btn.danger{background:#1a0f14;border-color:#3a1a1a;color:#f87171}
.btn.small{padding:4px 8px;font-size:11px}
.zoom-read{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:52px;text-align:center}
/* Layout */
.main{flex:1;display:flex;overflow:hidden}
/* Sidebar */
.sidebar{
width:220px;background:linear-gradient(180deg,#0f172a 0%,#0c1426 100%);
border-right:1px solid var(--border);
display:flex;flex-direction:column;
flex-shrink:0;overflow:hidden;
}
.side-section{padding:14px 12px;border-bottom:1px solid #0f1e33}
.side-title{font-size:10px;letter-spacing:.12em;color:var(--muted);font-weight:700;margin-bottom:10px;text-transform:uppercase}
.palette-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.pal-btn{
background:linear-gradient(180deg,#16233a,#0f1d33);
border:1px solid #1e3355;border-radius:10px;
padding:8px 6px;cursor:pointer;text-align:center;
transition:.15s;
}
.pal-btn:hover{border-color:#38bdf8;transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.4)}
.pal-btn .ico{font-size:16px;line-height:1}
.pal-btn .lbl{font-size:10px;font-weight:700;margin-top:4px;color:#cbd5e1}
.pal-btn .sub{font-size:9px;color:var(--muted)}
.pal-btn.toggle{border-color:#0ea5e933}
.pal-btn.clock{border-color:#f59e0b33}
.pal-btn.gate{border-color:#8b5cf633}
.pal-btn.out{border-color:#10b98133}
.help{padding:12px;font-size:11px;line-height:1.5;color:var(--muted)}
.help kbd{background:#1e2d4a;border:1px solid #2a3f66;border-bottom-width:2px;padding:1px 5px;border-radius:4px;font-size:10px;color:#94a3b8}
.status-bar{
margin-top:auto;padding:10px 12px;background:#0a1220;border-top:1px solid var(--border);
display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted)
}
.dot{width:8px;height:8px;border-radius:50%;background:var(--success);box-shadow:0 0 6px var(--success)}
.dot.off{background:var(--muted);box-shadow:none}
/* Canvas area */
.canvas-wrap{flex:1;position:relative;overflow:hidden;background:var(--bg)}
canvas{display:block;width:100%;height:100%;cursor:grab}
canvas.panning{cursor:grabbing}
canvas.wiring{cursor:crosshair}
.overlay{
position:absolute;inset:0;pointer-events:none;
}
.toast{
position:absolute;top:16px;left:50%;transform:translateX(-50%) translateY(-20px);
background:#1a0f14;border:1px solid #ef4444;color:#fecaca;
padding:8px 14px;border-radius:10px;font-size:12px;font-weight:600;
opacity:0;transition:.3s;pointer-events:none;
box-shadow:0 8px 24px rgba(239,68,68,.3);
}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.legend{
position:absolute;bottom:12px;left:12px;
background:rgba(15,23,42,.85);backdrop-filter:blur(8px);
border:1px solid var(--border);border-radius:10px;padding:8px 10px;
font-size:10px;color:var(--muted);line-height:1.6;
pointer-events:none;
}
.legend b{color:var(--text)}
.zoom-controls{
position:absolute;bottom:12px;right:12px;display:flex;flex-direction:column;gap:6px;
}
.zbtn{
width:36px;height:36px;border-radius:10px;background:rgba(15,23,42,.9);
border:1px solid var(--border);color:var(--text);font-size:18px;cursor:pointer;
display:grid;place-items:center;backdrop-filter:blur(8px);
}
.zbtn:hover{background:#1e2d4a}
/* Node tooltip */
.hint{
position:absolute;top:12px;right:12px;
background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:10px;
padding:10px 12px;font-size:11px;color:var(--muted);max-width:220px;line-height:1.5;
pointer-events:none;
}
.hint b{color:#e2e8f0}
/* Responsive */
@media(max-width:800px){
.sidebar{width:160px}
.palette-grid{grid-template-columns:1fr}
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">◈</div>
<div>
<h1>LOGIC FORGE</h1>
<p>Combinational Node Graph — Live Evaluation</p>
</div>
</div>
<div class="hdr-actions">
<span class="zoom-read" id="zoomRead">100%</span>
<button class="btn small" id="btnFit">◎ Fit</button>
<button class="btn small" id="btnExample">Load Demo</button>
<button class="btn small danger" id="btnClear">Clear</button>
<button class="btn primary small" id="btnAddToggle">+ Toggle</button>
</div>
</header>
<div class="main">
<aside class="sidebar">
<div class="side-section">
<div class="side-title">Inputs</div>
<div class="palette-grid">
<div class="pal-btn toggle" data-type="TOGGLE"><div class="ico">◐</div><div class="lbl">TOGGLE</div><div class="sub">Click to flip</div></div>
<div class="pal-btn clock" data-type="CLOCK"><div class="ico">◷</div><div class="lbl">CLOCK</div><div class="sub">Pulse generator</div></div>
</div>
</div>
<div class="side-section">
<div class="side-title">Gates</div>
<div class="palette-grid">
<div class="pal-btn gate" data-type="AND"><div class="ico">∧</div><div class="lbl">AND</div><div class="sub">A·B</div></div>
<div class="pal-btn gate" data-type="OR"><div class="ico">∨</div><div class="lbl">OR</div><div class="sub">A+B</div></div>
<div class="pal-btn gate" data-type="NOT"><div class="ico">¬</div><div class="lbl">NOT</div><div class="sub">¬A</div></div>
<div class="pal-btn gate" data-type="XOR"><div class="ico">⊕</div><div class="lbl">XOR</div><div class="sub">A⊕B</div></div>
<div class="pal-btn gate" data-type="NAND"><div class="ico">⊼</div><div class="lbl">NAND</div><div class="sub">¬(A·B)</div></div>
<div class="pal-btn gate" data-type="NOR"><div class="ico">⊽</div><div class="lbl">NOR</div><div class="sub">¬(A+B)</div></div>
</div>
</div>
<div class="side-section">
<div class="side-title">Outputs</div>
<div class="palette-grid">
<div class="pal-btn out" data-type="LAMP"><div class="ico">●</div><div class="lbl">LAMP</div><div class="sub">Glows on 1</div></div>
<div class="pal-btn out" data-type="HEX"><div class="ico">⬢</div><div class="lbl">HEX</div><div class="sub">4-bit → 0-F</div></div>
</div>
</div>
<div class="help">
<div style="font-weight:700;color:#94a3b8;margin-bottom:6px">Controls</div>
<div><kbd>Drag</kbd> empty space to pan</div>
<div><kbd>Wheel</kbd> zoom to cursor (50–200%)</div>
<div><kbd>Drag</kbd> node body to move</div>
<div><kbd>Drag</kbd> port → port to wire</div>
<div><kbd>Click</kbd> Toggle / Lamp body</div>
<div><kbd>Delete</kbd> remove selected</div>
<div style="margin-top:8px;color:#475569">One wire per input — re-wire replaces. Cycles are blocked (red flash).</div>
</div>
<div class="status-bar">
<div class="dot" id="liveDot"></div>
<span id="statusText">Live — topological evaluation</span>
</div>
</aside>
<div class="canvas-wrap" id="wrap">
<canvas id="c"></canvas>
<div class="toast" id="toast">⚠ Cycle rejected — combinational only</div>
<div class="legend">
<div><b style="color:#38bdf8">●</b> Input port <b style="color:#f59e0b">●</b> Output port <b style="color:#22d3ee">━━</b> Energized <b style="color:#334155">━━</b> Low</div>
<div>Drag from output → input. Drag occupied input to re-route.</div>
</div>
<div class="zoom-controls">
<button class="zbtn" id="zIn">+</button>
<button class="zbtn" id="zOut">−</button>
</div>
</div>
</div>
<script>
// ============================================================
// LOGIC FORGE — Single-file Node Graph Editor
// Sections: 1 Constants 2 Data Model 3 Viewport 4 Geometry
// 5 Evaluation 6 Persistence 7 Rendering 8 Interaction 9 Clock & Init
// ============================================================
/* ===== 1. TUNABLE CONSTANTS ===== */
const CFG = {
// viewport
ZOOM_MIN: 0.5, ZOOM_MAX: 2.0, ZOOM_STEP: 0.1,
// visuals
GRID_SPACING: 24, GRID_DOT_RADIUS: 1.2,
NODE_W: 110, NODE_H: 64, NODE_RADIUS: 12,
PORT_R: 7, PORT_HIT_R: 14, PORT_GAP: 8,
WIRE_WIDTH: 3, WIRE_HIT_TOL: 8,
COLORS: {
bg: '#080c14',
grid: 'rgba(148,163,184,0.12)',
nodeBg: '#111c2e',
nodeBgHover: '#162845',
nodeBorder: '#1e3a5f',
nodeBorderSel: '#38bdf8',
text: '#e2e8f0',
muted: '#64748b',
inputPort: '#38bdf8', inputPortGlow: 'rgba(56,189,248,0.5)',
outputPort: '#f59e0b', outputPortGlow: 'rgba(245,158,11,0.5)',
wireOff: '#334155', wireOn: '#22d3ee', wireOnGlow: 'rgba(34,211,238,0.6)',
wireCycle: '#ef4444',
lampOn: '#fde047', lampOff: '#1e293b',
hexOn: '#22d3ee', hexOff: '#1e293b'
},
CLOCK_RATES: [0.5, 1, 2, 4], // Hz
NODE_TYPES: {
TOGGLE:{inputs:0,outputs:1,label:'TOGGLE', color:'#0ea5e9'},
CLOCK:{inputs:0,outputs:1,label:'CLOCK', color:'#f59e0b'},
AND:{inputs:2,outputs:1,label:'AND'},
OR:{inputs:2,outputs:1,label:'OR'},
NOT:{inputs:1,outputs:1,label:'NOT'},
XOR:{inputs:2,outputs:1,label:'XOR'},
NAND:{inputs:2,outputs:1,label:'NAND'},
NOR:{inputs:2,outputs:1,label:'NOR'},
LAMP:{inputs:1,outputs:0,label:'LAMP'},
HEX:{inputs:4,outputs:0,label:'HEX'}
}
};
/* ===== 2. DATA MODEL ===== */
let nodes = []; // {id,type,x,y,state,clockRate,clockOn}
let wires = []; // {id, from, fromPort, to, toPort}
let nextId = 1;
function uid(){ return 'n'+(nextId++); }
function wid(){ return 'w'+(nextId++); }
// node output cache after evaluation: Map id -> boolean or boolean[]
let nodeOut = new Map();
let nodeInVals = new Map(); // id -> array of input values (bool or null)
/* ===== 3. VIEWPORT ===== */
const viewport = { x: 0, y: 0, scale: 1 };
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('wrap');
function resizeCanvas(){
const dpr = window.devicePixelRatio || 1;
const rect = wrap.getBoundingClientRect();
if(rect.width===0||rect.height===0) return;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
canvas.style.width = rect.width+'px';
canvas.style.height = rect.height+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
render();
}
window.addEventListener('resize', resizeCanvas);
function screenToWorld(sx, sy){
const rect = canvas.getBoundingClientRect();
const x = sx - rect.left;
const y = sy - rect.top;
return { x: (x - viewport.x)/viewport.scale, y: (y - viewport.y)/viewport.scale };
}
function worldToScreen(wx, wy){
return { x: wx*viewport.scale + viewport.x, y: wy*viewport.scale + viewport.y };
}
function setZoom(newScale, cx, cy){
newScale = Math.max(CFG.ZOOM_MIN, Math.min(CFG.ZOOM_MAX, newScale));
// zoom to cursor: keep world point under cursor fixed
const rect = canvas.getBoundingClientRect();
const sx = cx - rect.left, sy = cy - rect.top;
const wx = (sx - viewport.x)/viewport.scale;
const wy = (sy - viewport.y)/viewport.scale;
viewport.scale = newScale;
viewport.x = sx - wx*newScale;
viewport.y = sy - wy*newScale;
document.getElementById('zoomRead').textContent = Math.round(viewport.scale*100)+'%';
render();
}
/* ===== 4. GEOMETRY & HIT TESTING ===== */
function getPortPos(node, isInput, idx){
const totalIn = CFG.NODE_TYPES[node.type].inputs;
const totalOut = CFG.NODE_TYPES[node.type].outputs;
const w = CFG.NODE_W, h = CFG.NODE_H;
if(isInput){
const spacing = h / (totalIn + 1);
return { x: node.x, y: node.y + spacing*(idx+1) };
} else {
const spacing = h / (totalOut + 1);
return { x: node.x + w, y: node.y + spacing*(idx+1) };
}
}
function hitPort(world){
for(const n of nodes){
const ti = CFG.NODE_TYPES[n.type];
for(let i=0;i<ti.inputs;i++){
const p = getPortPos(n,true,i);
const dx = world.x - p.x, dy = world.y - p.y;
if(Math.hypot(dx,dy) <= CFG.PORT_HIT_R / viewport.scale) return {node:n, isInput:true, idx:i, pos:p};
}
for(let i=0;i<ti.outputs;i++){
const p = getPortPos(n,false,i);
const dx = world.x - p.x, dy = world.y - p.y;
if(Math.hypot(dx,dy) <= CFG.PORT_HIT_R / viewport.scale) return {node:n, isInput:false, idx:i, pos:p};
}
}
return null;
}
function hitNode(world){
// topmost first
for(let i=nodes.length-1;i>=0;i--){
const n = nodes[i];
if(world.x>=n.x && world.x<=n.x+CFG.NODE_W && world.y>=n.y && world.y<=n.y+CFG.NODE_H) return n;
}
return null;
}
function hitWire(world){
const tol = CFG.WIRE_HIT_TOL / viewport.scale;
for(const w of wires){
const a = nodes.find(n=>n.id===w.from), b = nodes.find(n=>n.id===w.to);
if(!a||!b) continue;
const p0 = getPortPos(a,false,w.fromPort);
const p1 = getPortPos(b,true,w.toPort);
// sample bezier
let closest = Infinity;
for(let t=0;t<=1;t+=0.05){
const x = bezier(p0.x,p1.x,t), y = bezierY(p0.y,p1.y,t);
const d = Math.hypot(world.x-x, world.y-y);
if(d<closest) closest=d;
}
if(closest<=tol) return w;
}
return null;
}
function bezier(x0,x1,t){
const dx = Math.abs(x1-x0)*0.5 + 40;
const c0x = x0+dx, c1x = x1-dx;
const mt=1-t;
return mt*mt*mt*x0 + 3*mt*mt*t*c0x + 3*mt*t*t*c1x + t*t*t*x1;
}
function bezierY(y0,y1,t){
// cubic with same x control but y constant
const mt=1-t;
return mt*mt*mt*y0 + 3*mt*mt*t*y0 + 3*mt*t*t*y1 + t*t*t*y1;
}
/* ===== 5. EVALUATION ENGINE ===== */
function evaluate(){
// Build adjacency for topological sort
// Map nodeId -> outputs
nodeOut.clear(); nodeInVals.clear();
// init input vals as null
for(const n of nodes){
const ti = CFG.NODE_TYPES[n.type];
nodeInVals.set(n.id, Array(ti.inputs).fill(null));
}
// Build graph edges
const adj = new Map(); // from -> [to]
const indeg = new Map();
for(const n of nodes){ adj.set(n.id,[]); indeg.set(n.id,0); }
for(const w of wires){
if(!adj.has(w.from) || !adj.has(w.to)) continue;
adj.get(w.from).push(w.to);
indeg.set(w.to, indeg.get(w.to)+1);
}
// Kahn topological order
const q = [];
for(const [id,d] of indeg) if(d===0) q.push(id);
const order = [];
while(q.length){
const id = q.shift();
order.push(id);
for(const nb of adj.get(id)){
indeg.set(nb, indeg.get(nb)-1);
if(indeg.get(nb)===0) q.push(nb);
}
}
// If cycle exists, order will be incomplete — but we prevent cycles on wire creation
// Evaluate in order
// First, collect wire-driven input values iteratively as we go? Need to propagate.
// We'll maintain a map wireValue: for each wire, value = nodeOut[from][fromPort]
// Instead, as we evaluate nodes in order, we fill downstream input slots.
// Helper to get node output value(s)
function computeNode(n, inputs){
switch(n.type){
case 'TOGGLE': return !!n.state;
case 'CLOCK': return !!n.state;
case 'AND': return !!(inputs[0] && inputs[1]);
case 'OR': return !!(inputs[0] || inputs[1]);
case 'NOT': return !inputs[0];
case 'XOR': return !!(inputs[0] ^ inputs[1]);
case 'NAND': return !(inputs[0] && inputs[1]);
case 'NOR': return !(inputs[0] || inputs[1]);
case 'LAMP': return !!inputs[0];
case 'HEX': return inputs.slice(0,4); // array of 4 bools
default: return false;
}
}
// We need to propagate wires: after evaluating a node, push its output to target inputs
// For simplicity, do a pass: for each node in order, gather its inputs from wires that feed it (using already computed sources)
const outVals = new Map(); // id -> value (bool or array)
for(const id of order){
const n = nodes.find(x=>x.id===id);
if(!n) continue;
const ti = CFG.NODE_TYPES[n.type];
let ins = [];
if(ti.inputs>0){
// collect from wires
const curIns = nodeInVals.get(id).slice();
// For each wire to this node, fill slot with source output
for(const w of wires){
if(w.to===id){
const srcVal = outVals.get(w.from);
// srcVal is boolean for most; for nodes with single output it's bool
// handle array? only HEX has no output, so ignore
let v = srcVal;
if(Array.isArray(v)) v = v[0]; // shouldn't happen
// treat null/undefined as false (unconnected = 0)
if(v===undefined) v=false;
curIns[w.toPort]=!!v;
}
}
// unconnected inputs default to false
for(let i=0;i<curIns.length;i++) if(curIns[i]===null) curIns[i]=false;
nodeInVals.set(id, curIns);
ins = curIns;
}
const out = computeNode(n, ins);
outVals.set(id, out);
}
// For nodes not in order (if cycle somehow), keep old
nodeOut = outVals;
// Update lamp/hex visual state
for(const n of nodes){
if(n.type==='LAMP' || n.type==='HEX'){
n._eval = outVals.get(n.id);
} else if(CFG.NODE_TYPES[n.type].outputs>0){
n._outVal = outVals.get(n.id);
}
}
}
function wouldCreateCycle(fromId, toId){
if(fromId===toId) return true;
// Build adjacency including proposed edge
const adj = new Map();
for(const n of nodes) adj.set(n.id, []);
for(const w of wires) adj.get(w.from).push(w.to);
adj.get(fromId).push(toId);
// DFS from toId to see if can reach fromId
const visited = new Set();
const stack = [toId];
while(stack.length){
const cur = stack.pop();
if(cur===fromId) return true;
if(visited.has(cur)) continue;
visited.add(cur);
for(const nb of (adj.get(cur)||[])) if(!visited.has(nb)) stack.push(nb);
}
return false;
}
/* ===== 6. PERSISTENCE ===== */
const LS_KEY = 'logicForge_v7';
function serialize(){
return {
nodes: nodes.map(n=>({id:n.id,type:n.type,x:n.x,y:n.y,state:n.state,clockRate:n.clockRate})),
wires: wires.map(w=>({id:w.id,from:w.from,fromPort:w.fromPort,to:w.to,toPort:w.toPort})),
viewport:{...viewport},
nextId
};
}
function save(){
localStorage.setItem(LS_KEY, JSON.stringify(serialize()));
}
function load(){
try{
const raw = localStorage.getItem(LS_KEY);
if(!raw) return false;
const data = JSON.parse(raw);
nodes = data.nodes || [];
wires = data.wires || [];
nextId = data.nextId || (nodes.length+10);
if(data.viewport){ viewport.x=data.viewport.x; viewport.y=data.viewport.y; viewport.scale=data.viewport.scale; }
// ensure defaults
for(const n of nodes){
if(n.type==='CLOCK' && !n.clockRate) n.clockRate=1;
if(n.state===undefined) n.state=false;
}
document.getElementById('zoomRead').textContent = Math.round(viewport.scale*100)+'%';
return true;
}catch(e){ return false; }
}
/* ===== 7. RENDERING ===== */
let hoverPort = null;
let selectedId = null;
let dragState = null; // {type:'pan'|'node'|'wire', ...}
let wirePreview = null; // {from, fromPort, x,y, targetPort}
let cycleFlash = null; // {wire, time}
function drawGrid(){
const spacing = CFG.GRID_SPACING * viewport.scale;
const dotR = CFG.GRID_DOT_RADIUS * Math.min(1.5, viewport.scale);
const offX = ((viewport.x % spacing)+spacing)%spacing;
const offY = ((viewport.y % spacing)+spacing)%spacing;
ctx.fillStyle = CFG.COLORS.grid;
const w = canvas.getBoundingClientRect().width;
const h = canvas.getBoundingClientRect().height;
for(let x=offX; x<w; x+=spacing){
for(let y=offY; y<h; y+=spacing){
ctx.beginPath();
ctx.arc(x,y,dotR,0,Math.PI*2);
ctx.fill();
}
}
}
function drawWireCurve(p0,p1, energized, isFlash){
const dx = Math.abs(p1.x-p0.x)*0.5 + 40;
const c0x = p0.x+dx, c1x = p1.x-dx;
ctx.beginPath();
ctx.moveTo(p0.x,p0.y);
ctx.bezierCurveTo(c0x,p0.y,c1x,p1.y,p1.x,p1.y);
if(isFlash){
ctx.strokeStyle = CFG.COLORS.wireCycle;
ctx.lineWidth = CFG.WIRE_WIDTH+1;
ctx.shadowColor = 'rgba(239,68,68,0.6)';
ctx.shadowBlur = 12;
} else if(energized){
ctx.strokeStyle = CFG.COLORS.wireOn;
ctx.lineWidth = CFG.WIRE_WIDTH;
ctx.shadowColor = CFG.COLORS.wireOnGlow;
ctx.shadowBlur = 10;
} else {
ctx.strokeStyle = CFG.COLORS.wireOff;
ctx.lineWidth = CFG.WIRE_WIDTH;
ctx.shadowBlur = 0;
}
ctx.lineCap='round';
ctx.stroke();
ctx.shadowBlur=0;
// inner highlight for energized
if(energized && !isFlash){
ctx.beginPath();
ctx.moveTo(p0.x,p0.y);
ctx.bezierCurveTo(c0x,p0.y,c1x,p1.y,p1.x,p1.y);
ctx.strokeStyle='rgba(255,255,255,0.25)';
ctx.lineWidth=1;
ctx.stroke();
}
}
function drawNode(n){
const isSelected = n.id===selectedId;
const isHover = hoverNodeId===n.id;
const ti = CFG.NODE_TYPES[n.type];
const sPos = worldToScreen(n.x,n.y);
const w = CFG.NODE_W*viewport.scale, h = CFG.NODE_H*viewport.scale;
const r = CFG.NODE_RADIUS*viewport.scale;
// shadow
ctx.save();
ctx.shadowColor='rgba(0,0,0,0.35)';
ctx.shadowBlur=8*viewport.scale;
ctx.shadowOffsetY=2*viewport.scale;
// card bg
const bg = isSelected? '#162845' : (isHover? CFG.COLORS.nodeBgHover : CFG.COLORS.nodeBg);
ctx.fillStyle = bg;
ctx.strokeStyle = isSelected? CFG.COLORS.nodeBorderSel : CFG.COLORS.nodeBorder;
ctx.lineWidth = isSelected? 2 : 1.2;
roundRect(sPos.x,sPos.y,w,h,r);
ctx.fill();
ctx.stroke();
ctx.restore();
// top accent line — thin clipped strip
ctx.save();
ctx.beginPath();
// top edge only: rounded top corners, flat bottom
const aw = 2.5*viewport.scale;
ctx.moveTo(sPos.x+r, sPos.y);
ctx.arcTo(sPos.x+w, sPos.y, sPos.x+w, sPos.y+aw, r);
ctx.lineTo(sPos.x+w, sPos.y+aw);
ctx.lineTo(sPos.x, sPos.y+aw);
ctx.arcTo(sPos.x, sPos.y, sPos.x+r, sPos.y, r);
ctx.closePath();
ctx.fillStyle = ti.color || (n._outVal||n.state||n._eval ? '#22d3ee' : '#334155');
ctx.fill();
ctx.restore();
// label
ctx.fillStyle = CFG.COLORS.text;
ctx.font = `700 ${10*viewport.scale}px ui-sans-system, sans-serif`;
ctx.textAlign='center';
ctx.fillText(ti.label, sPos.x+w/2, sPos.y+18*viewport.scale);
// type-specific body
const cx = sPos.x+w/2, cy = sPos.y+h/2+6*viewport.scale;
if(n.type==='TOGGLE'){
const on = !!n.state;
// switch track
const swW=36*viewport.scale, swH=18*viewport.scale;
const sx=cx-swW/2, sy=cy-swH/2;
ctx.fillStyle = on? 'rgba(34,211,238,0.25)' : 'rgba(51,65,85,0.8)';
ctx.strokeStyle = on? '#22d3ee' : '#334155';
ctx.lineWidth=1.2;
roundRect(sx,sy,swW,swH,swH/2);
ctx.fill(); ctx.stroke();
// knob
const kx = on? sx+swW-swH/2-2*viewport.scale : sx+swH/2+2*viewport.scale;
ctx.beginPath();
ctx.arc(kx,sy+swH/2,7*viewport.scale,0,Math.PI*2);
ctx.fillStyle = on? '#22d3ee' : '#94a3b8';
ctx.shadowColor = on? 'rgba(34,211,238,0.6)':'transparent';
ctx.shadowBlur=8;
ctx.fill();
ctx.shadowBlur=0;
ctx.fillStyle = on? '#fff':'#0f172a';
ctx.font=`700 ${8*viewport.scale}px sans-serif`;
ctx.fillText(on?'ON':'OFF', cx, sy+swH+10*viewport.scale);
} else if(n.type==='CLOCK'){
const on = !!n.state;
ctx.beginPath();
ctx.arc(cx,cy-2*viewport.scale,14*viewport.scale,0,Math.PI*2);
ctx.fillStyle = on? 'rgba(245,158,11,0.2)' : 'rgba(30,41,59,0.8)';
ctx.strokeStyle = on? '#f59e0b' : '#334155';
ctx.lineWidth=1.5;
ctx.fill(); ctx.stroke();
// pulse indicator
ctx.fillStyle = on? '#f59e0b' : '#475569';
ctx.beginPath();
ctx.arc(cx,cy-2*viewport.scale,5*viewport.scale,0,Math.PI*2);
ctx.fill();
if(on){ ctx.shadowColor='#f59e0b'; ctx.shadowBlur=10; ctx.fill(); ctx.shadowBlur=0; }
ctx.fillStyle=CFG.COLORS.muted;
ctx.font=`600 ${8*viewport.scale}px sans-serif`;
ctx.fillText(n.clockRate+' Hz', cx, cy+16*viewport.scale);
} else if(n.type==='LAMP'){
const on = !!n._eval;
ctx.beginPath();
ctx.arc(cx,cy,16*viewport.scale,0,Math.PI*2);
ctx.fillStyle = on? '#fde047' : CFG.COLORS.lampOff;
ctx.strokeStyle = on? '#facc15' : '#334155';
ctx.lineWidth=1.5;
ctx.fill(); ctx.stroke();
if(on){
ctx.shadowColor='#fde047'; ctx.shadowBlur=18;
ctx.fill(); ctx.shadowBlur=0;
ctx.beginPath(); ctx.arc(cx-4*viewport.scale,cy-4*viewport.scale,4*viewport.scale,0,Math.PI*2);
ctx.fillStyle='rgba(255,255,255,0.7)'; ctx.fill();
}
ctx.fillStyle = on? '#a16207' : '#475569';
ctx.font=`700 ${7*viewport.scale}px sans-serif`;
ctx.textAlign='center';
ctx.fillText(on?'ON':'OFF', cx, cy+26*viewport.scale);
} else if(n.type==='HEX'){
const bits = Array.isArray(n._eval)? n._eval : [false,false,false,false];
let val=0; for(let i=0;i<4;i++) if(bits[i]) val|=(1<<i);
const hex = val.toString(16).toUpperCase();
// 7-seg style
ctx.fillStyle='#0f172a';
roundRect(cx-22*viewport.scale, cy-14*viewport.scale,44*viewport.scale,28*viewport.scale,6*viewport.scale);
ctx.fill();
ctx.strokeStyle='#1e293b'; ctx.lineWidth=1; ctx.stroke();
ctx.fillStyle = '#22d3ee';
ctx.shadowColor='rgba(34,211,238,0.5)'; ctx.shadowBlur=6;
ctx.font=`700 ${18*viewport.scale}px ui-monospace, monospace`;
ctx.fillText(hex, cx, cy+6*viewport.scale);
ctx.shadowBlur=0;
ctx.fillStyle=CFG.COLORS.muted;
ctx.font=`600 ${7*viewport.scale}px sans-serif`;
let bin=''; for(let i=3;i>=0;i--) bin+=(bits[i]?'1':'0');
ctx.fillText(bin, cx, cy+22*viewport.scale);
} else {
// gate symbol
ctx.fillStyle = (n._outVal? '#22d3ee' : '#475569');
ctx.font=`700 ${13*viewport.scale}px sans-serif`;
const sym = {AND:'&',OR:'≥1',NOT:'1',XOR:'=1',NAND:'&○',NOR:'≥1○'}[n.type]||n.type;
ctx.fillText(sym, cx, cy+4*viewport.scale);
// small dot for output state
ctx.beginPath();
ctx.arc(sPos.x+w-10*viewport.scale, sPos.y+10*viewport.scale,4*viewport.scale,0,Math.PI*2);
ctx.fillStyle = n._outVal? '#22d3ee':'#334155';
if(n._outVal){ctx.shadowColor='#22d3ee';ctx.shadowBlur=8;ctx.fill();ctx.shadowBlur=0;} else ctx.fill();
}
// ports
const portScale = viewport.scale;
const ti2 = CFG.NODE_TYPES[n.type];
for(let i=0;i<ti2.inputs;i++){
const p = worldToScreen(...Object.values(getPortPos(n,true,i)));
const hovered = hoverPort && hoverPort.node.id===n.id && hoverPort.isInput && hoverPort.idx===i;
const hasWire = wires.some(w=>w.to===n.id && w.toPort===i);
const energized = (()=>{ const v=nodeInVals.get(n.id); return v? !!v[i] : false; })();
drawPort(p.x,p.y,true,hovered,energized,hasWire,portScale);
}
for(let i=0;i<ti2.outputs;i++){
const p = worldToScreen(...Object.values(getPortPos(n,false,i)));
const hovered = hoverPort && hoverPort.node.id===n.id && !hoverPort.isInput && hoverPort.idx===i;
let energized = !!n._outVal || !!n.state;
if(n.type==='TOGGLE'||n.type==='CLOCK') energized = !!n.state;
drawPort(p.x,p.y,false,hovered,energized,false,portScale);
}
}
function drawPort(x,y,isInput,hovered,energized,occupied,scale){
const r = (hovered? 9 : CFG.PORT_R)*scale;
ctx.beginPath();
ctx.arc(x,y,r,0,Math.PI*2);
const base = isInput? CFG.COLORS.inputPort : CFG.COLORS.outputPort;
ctx.fillStyle = energized? base : '#1e293b';
ctx.strokeStyle = energized? base : '#334155';
ctx.lineWidth = hovered? 2.5 : 1.5;
ctx.fill();
ctx.stroke();
if(energized){
ctx.shadowColor = isInput? CFG.COLORS.inputPortGlow : CFG.COLORS.outputPortGlow;
ctx.shadowBlur=10; ctx.fill(); ctx.shadowBlur=0;
}
// inner dot
ctx.beginPath();
ctx.arc(x,y,3*scale,0,Math.PI*2);
ctx.fillStyle = energized? '#fff' : (occupied? '#475569' : '#0f172a');
ctx.fill();
if(hovered){
ctx.beginPath(); ctx.arc(x,y,(r+4*scale),0,Math.PI*2);
ctx.strokeStyle='rgba(56,189,248,0.25)'; ctx.lineWidth=1; ctx.stroke();
}
}
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();
}
let hoverNodeId = null;
function render(){
const rect = canvas.getBoundingClientRect();
const w = rect.width, h = rect.height;
// clear
ctx.clearRect(0,0,w,h);
ctx.fillStyle = CFG.COLORS.bg;
ctx.fillRect(0,0,w,h);
drawGrid();
// wires
for(const wire of wires){
const a = nodes.find(n=>n.id===wire.from), b = nodes.find(n=>n.id===wire.to);
if(!a||!b) continue;
const p0 = worldToScreen(...Object.values(getPortPos(a,false,wire.fromPort)));
const p1 = worldToScreen(...Object.values(getPortPos(b,true,wire.toPort)));
const energized = !!nodeOut.get(a.id);
// handle array? not needed
const isFlash = cycleFlash && cycleFlash.id===wire.id && Date.now()-cycleFlash.time<600;
drawWireCurve(p0,p1,energized,isFlash);
}
// preview wire
if(wirePreview){
const p0 = worldToScreen(...Object.values(getPortPos(wirePreview.fromNode,false,wirePreview.fromPort)));
const p1 = worldToScreen(wirePreview.x, wirePreview.y);
// if hovering valid target, snap
let target = hoverPort && hoverPort.isInput ? hoverPort : null;
let end = target ? worldToScreen(...Object.values(target.pos)) : p1;
const isValid = target && canConnect(wirePreview.fromNode.id, wirePreview.fromPort, target.node.id, target.idx);
const isCycle = target && wouldCreateCycle(wirePreview.fromNode.id, target.node.id);
if(target){
if(isCycle){
drawWireCurve(p0,end,false,true);
} else if(isValid){
drawWireCurve(p0,end,true,false);
// highlight target port done in node draw
} else {
drawWireCurve(p0,end,false,false);
}
} else {
// dashed preview
ctx.save();
ctx.setLineDash([6,6]);
drawWireCurve(p0,p1,false,false);
ctx.restore();
}
}
// nodes (sorted by y for slight depth? keep order)
for(const n of nodes) drawNode(n);
// selection rect hint
if(selectedId){
// already highlighted via border
}
}
function canConnect(fromId, fromPort, toId, toPort){
if(fromId===toId) return false;
const fromNode = nodes.find(n=>n.id===fromId);
const toNode = nodes.find(n=>n.id===toId);
if(!fromNode||!toNode) return false;
// to port must be input, from must be output — caller ensures
// one wire per input: allowed (will replace)
return true;
}
/* ===== 8. INTERACTION ===== */
let isPanning=false, panStart={x:0,y:0}, vpStart={x:0,y:0};
let draggedNode=null, dragOffset={x:0,y:0};
canvas.addEventListener('mousedown', e=>{
const world = screenToWorld(e.clientX,e.clientY);
const port = hitPort(world);
if(port){
// start wiring
if(!port.isInput){
// from output
wirePreview = {fromNode:port.node, fromPort:port.idx, x:world.x, y:world.y};
dragState={type:'wire'};
canvas.classList.add('wiring');
} else {
// from input — pick up existing wire if any
const existing = wires.find(w=>w.to===port.node.id && w.toPort===port.idx);
if(existing){
const src = nodes.find(n=>n.id===existing.from);
// remove old wire, start preview from its source
wires.splice(wires.indexOf(existing),1);
evaluate(); save(); render();
wirePreview = {fromNode:src, fromPort:existing.fromPort, x:world.x, y:world.y};
dragState={type:'wire'};
canvas.classList.add('wiring');
} else {
// cannot start from input without wire — ignore
return;
}
}
e.preventDefault();
return;
}
const node = hitNode(world);
if(node){
selectedId = node.id;
// check if click on toggle/clock body to flip
if(node.type==='TOGGLE' || node.type==='CLOCK'){
// will handle on mouseup if not dragged
}
draggedNode=node;
dragOffset={x:world.x-node.x, y:world.y-node.y};
dragState={type:'node', moved:false};
render();
return;
}
// pan
isPanning=true;
panStart={x:e.clientX,y:e.clientY};
vpStart={x:viewport.x,y:viewport.y};
canvas.classList.add('panning');
selectedId=null;
render();
});
canvas.addEventListener('mousemove', e=>{
const world = screenToWorld(e.clientX,e.clientY);
// hover detection
const prevHover = hoverPort;
hoverPort = hitPort(world);
const prevHoverNode = hoverNodeId;
hoverNodeId = hitNode(world)?.id || null;
if(hoverPort!==prevHover || hoverNodeId!==prevHoverNode) render();
if(wirePreview){
wirePreview.x=world.x; wirePreview.y=world.y;
render();
return;
}
if(draggedNode && dragState?.type==='node'){
const dx = world.x - dragOffset.x - draggedNode.x;
const dy = world.y - dragOffset.y - draggedNode.y;
if(Math.hypot(dx,dy)>1) dragState.moved=true;
draggedNode.x = world.x - dragOffset.x;
draggedNode.y = world.y - dragOffset.y;
render();
return;
}
if(isPanning){
viewport.x = vpStart.x + (e.clientX - panStart.x);
viewport.y = vpStart.y + (e.clientY - panStart.y);
render();
}
});
canvas.addEventListener('mouseup', e=>{
const world = screenToWorld(e.clientX,e.clientY);
if(wirePreview){
const target = hitPort(world);
if(target && target.isInput){
const fromId = wirePreview.fromNode.id, toId = target.node.id;
if(wouldCreateCycle(fromId,toId)){
// flash
showToast('Cycle rejected — combinational only');
// red flash on preview
cycleFlash={id:'preview',time:Date.now()};
// also flash existing wires if any? just toast
setTimeout(render, 650);
} else {
// remove existing wire on that input
const idx = wires.findIndex(w=>w.to===toId && w.toPort===target.idx);
if(idx!==-1) wires.splice(idx,1);
wires.push({id:wid(), from:fromId, fromPort:wirePreview.fromPort, to:toId, toPort:target.idx});
evaluate(); save();
}
}
wirePreview=null;
dragState=null;
canvas.classList.remove('wiring');
render();
return;
}
if(draggedNode){
if(!dragState?.moved){
// click — toggle if applicable
if(draggedNode.type==='TOGGLE'){
draggedNode.state = !draggedNode.state;
evaluate(); save();
} else if(draggedNode.type==='CLOCK'){
// cycle rate on click? click toggles pause? We'll make click cycles rate
// Actually click toggles enable? We'll make it cycle rate
const rates = CFG.CLOCK_RATES;
let idx = rates.indexOf(draggedNode.clockRate);
// if ctrl pressed, toggle pause? simple: shift+click pauses
if(e.shiftKey){
draggedNode.paused = !draggedNode.paused;
} else {
idx = (idx+1)%rates.length;
draggedNode.clockRate = rates[idx];
}
save();
} else if(draggedNode.type==='LAMP' || draggedNode.type==='HEX'){
// no action
} else {
// select only
}
render();
} else {
save();
}
draggedNode=null;
dragState=null;
render();
return;
}
if(isPanning){
isPanning=false;
canvas.classList.remove('panning');
save();
}
});
canvas.addEventListener('wheel', e=>{
e.preventDefault();
const delta = -e.deltaY;
const factor = delta>0? 1.08 : 0.92;
setZoom(viewport.scale*factor, e.clientX, e.clientY);
save();
},{passive:false});
canvas.addEventListener('dblclick', e=>{
const world = screenToWorld(e.clientX,e.clientY);
const n = hitNode(world);
if(n && n.type==='CLOCK'){
const rates = CFG.CLOCK_RATES;
let idx = rates.indexOf(n.clockRate);
idx=(idx+1)%rates.length;
n.clockRate=rates[idx];
save(); render();
}
});
// Delete
window.addEventListener('keydown', e=>{
if((e.key==='Delete' || e.key==='Backspace') && selectedId){
// avoid deleting while typing? no input
const idx = nodes.findIndex(n=>n.id===selectedId);
if(idx!==-1){
const id = nodes[idx].id;
nodes.splice(idx,1);
wires = wires.filter(w=>w.from!==id && w.to!==id);
selectedId=null;
evaluate(); save(); render();
}
}
if(e.key==='Escape'){
wirePreview=null; dragState=null; canvas.classList.remove('wiring'); render();
}
});
// Palette
document.querySelectorAll('.pal-btn').forEach(btn=>{
btn.addEventListener('click', ()=>{
const type = btn.dataset.type;
addNode(type);
});
});
function addNode(type){
const center = screenToWorld(window.innerWidth/2, window.innerHeight/2);
// offset a bit random
const x = center.x - CFG.NODE_W/2 + (Math.random()*40-20);
const y = center.y - CFG.NODE_H/2 + (Math.random()*40-20);
const n = {id:uid(), type, x, y, state:false};
if(type==='CLOCK'){ n.clockRate=1; n.state=false; }
if(type==='TOGGLE'){ n.state=false; }
nodes.push(n);
selectedId=n.id;
evaluate(); save(); render();
// slight pan to show?
}
// Header buttons
document.getElementById('btnClear').addEventListener('click', ()=>{
if(confirm('Clear entire circuit?')){
nodes=[]; wires=[]; selectedId=null; evaluate(); save(); render();
}
});
document.getElementById('btnFit').addEventListener('click', fitView);
function fitView(){
if(nodes.length===0){ viewport.x=0; viewport.y=0; viewport.scale=1; render(); return; }
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
for(const n of nodes){
minX=Math.min(minX,n.x); minY=Math.min(minY,n.y);
maxX=Math.max(maxX,n.x+CFG.NODE_W); maxY=Math.max(maxY,n.y+CFG.NODE_H);
}
const pad=80;
const rect = wrap.getBoundingClientRect();
const w = rect.width - pad*2, h = rect.height - pad*2;
const bw = maxX-minX, bh = maxY-minY;
const scale = Math.max(CFG.ZOOM_MIN, Math.min(CFG.ZOOM_MAX, Math.min(w/bw, h/bh, 1.2)));
viewport.scale=scale;
viewport.x = (rect.width - bw*scale)/2 - minX*scale;
viewport.y = (rect.height - bh*scale)/2 - minY*scale;
document.getElementById('zoomRead').textContent=Math.round(scale*100)+'%';
save(); render();
}
document.getElementById('zIn').addEventListener('click', ()=>{
const r=wrap.getBoundingClientRect();
setZoom(viewport.scale*1.2, r.left+r.width/2, r.top+r.height/2); save();
});
document.getElementById('zOut').addEventListener('click', ()=>{
const r=wrap.getBoundingClientRect();
setZoom(viewport.scale/1.2, r.left+r.width/2, r.top+r.height/2); save();
});
document.getElementById('btnAddToggle').addEventListener('click', ()=>addNode('TOGGLE'));
function showToast(msg){
const t=document.getElementById('toast');
t.textContent='⚠ '+msg;
t.classList.add('show');
// flash wires red
cycleFlash={id:'preview',time:Date.now()};
render();
setTimeout(()=>t.classList.remove('show'),2200);
}
// Demo circuit: Toggle -> AND(1) with Toggle -> NOT -> Lamp ; plus HEX demo
function loadDemo(){
nodes=[]; wires=[]; nextId=1;
const t1={id:uid(),type:'TOGGLE',x:80,y:100,state:true};
const t2={id:uid(),type:'TOGGLE',x:80,y:200,state:false};
const and={id:uid(),type:'AND',x:270,y:135};
const not={id:uid(),type:'NOT',x:440,y:145};
const lamp={id:uid(),type:'LAMP',x:610,y:145};
const clk={id:uid(),type:'CLOCK',x:80,y:620,state:false,clockRate:1};
const xor={id:uid(),type:'XOR',x:270,y:620};
const lamp2={id:uid(),type:'LAMP',x:480,y:620};
const hex={id:uid(),type:'HEX',x:610,y:360};
// 4 toggles feeding HEX — vertical bus, well clear of bottom row
const h1={id:uid(),type:'TOGGLE',x:430,y:230,state:true};
const h2={id:uid(),type:'TOGGLE',x:430,y:310,state:false};
const h3={id:uid(),type:'TOGGLE',x:430,y:390,state:true};
const h4={id:uid(),type:'TOGGLE',x:430,y:470,state:false};
nodes.push(t1,t2,and,not,lamp,clk,xor,lamp2,hex,h1,h2,h3,h4);
wires.push({id:wid(),from:t1.id,fromPort:0,to:and.id,toPort:0});
wires.push({id:wid(),from:t2.id,fromPort:0,to:and.id,toPort:1});
wires.push({id:wid(),from:and.id,fromPort:0,to:not.id,toPort:0});
wires.push({id:wid(),from:not.id,fromPort:0,to:lamp.id,toPort:0});
wires.push({id:wid(),from:clk.id,fromPort:0,to:xor.id,toPort:0});
wires.push({id:wid(),from:t1.id,fromPort:0,to:xor.id,toPort:1});
wires.push({id:wid(),from:xor.id,fromPort:0,to:lamp2.id,toPort:0});
wires.push({id:wid(),from:h1.id,fromPort:0,to:hex.id,toPort:0});
wires.push({id:wid(),from:h2.id,fromPort:0,to:hex.id,toPort:1});
wires.push({id:wid(),from:h3.id,fromPort:0,to:hex.id,toPort:2});
wires.push({id:wid(),from:h4.id,fromPort:0,to:hex.id,toPort:3});
selectedId=null;
evaluate(); save(); fitView();
}
document.getElementById('btnExample').addEventListener('click', loadDemo);
// Right-click menu to add node at cursor
wrap.addEventListener('contextmenu', e=>{
e.preventDefault();
const world = screenToWorld(e.clientX,e.clientY);
// simple prompt: cycle through types? Instead show quick add at cursor
const types = ['TOGGLE','AND','OR','NOT','XOR','NAND','NOR','LAMP','HEX','CLOCK'];
// create floating menu
let menu = document.getElementById('ctxMenu');
if(menu) menu.remove();
menu = document.createElement('div');
menu.id='ctxMenu';
menu.style.cssText=`position:fixed;left:${e.clientX}px;top:${e.clientY}px;background:#0f172a;border:1px solid #1e3a5f;border-radius:10px;padding:6px;z-index:99;box-shadow:0 12px 32px rgba(0,0,0,0.5);display:grid;grid-template-columns:1fr 1fr;gap:4px;min-width:160px`;
types.forEach(t=>{
const b=document.createElement('button');
b.textContent=t;
b.style.cssText='background:#16233a;border:1px solid #1e3355;color:#cbd5e1;padding:6px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer';
b.onmouseenter=()=>b.style.borderColor='#38bdf8';
b.onmouseleave=()=>b.style.borderColor='#1e3355';
b.onclick=()=>{
const n={id:uid(),type:t,x:world.x-CFG.NODE_W/2,y:world.y-CFG.NODE_H/2,state:false};
if(t==='CLOCK'){n.clockRate=1;}
nodes.push(n); selectedId=n.id; evaluate(); save(); render();
menu.remove();
};
menu.appendChild(b);
});
document.body.appendChild(menu);
const close = (ev)=>{ if(!menu.contains(ev.target)) {menu.remove(); document.removeEventListener('mousedown',close);} };
setTimeout(()=>document.addEventListener('mousedown',close),0);
});
/* ===== 9. CLOCK & INIT ===== */
let lastClockTick = performance.now();
function clockLoop(now){
requestAnimationFrame(clockLoop);
let changed=false;
for(const n of nodes){
if(n.type==='CLOCK' && !n.paused){
const period = 1000 / n.clockRate;
if(!n._last) n._last = now;
if(now - n._last >= period/2){
// toggle every half period? Actually clock pulses: we toggle state each half period for square wave
n._last = now;
n.state = !n.state;
changed=true;
}
}
}
if(changed){ evaluate(); render(); }
// also update live dot
const dot=document.getElementById('liveDot');
if(changed) { dot.style.boxShadow='0 0 10px #10b981'; setTimeout(()=>dot.style.boxShadow='0 0 6px #10b981',200); }
}
requestAnimationFrame(clockLoop);
// Init
const hadSave = load();
if(!hadSave){
resizeCanvas();
loadDemo();
} else {
resizeCanvas();
evaluate();
render();
}
// Expose for probe tests
window._graph = { get nodes(){return nodes}, get wires(){return wires}, evaluate, wouldCreateCycle, CFG, viewport, addNode, loadDemo };
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"7fa7fd35-7add-40c2-bcb4-9c0f41437363","tokensIn":3961223,"tokensOut":42977,"tokensTotal":4004200,"cost":0.042233438,"turns":56,"toolCalls":55,"failedToolCalls":0,"timestamp":"2026-08-23T02:23:31.590Z"} -->