Node Graph Editor
GPT 6 Luna · 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 27,125 bytes · SHA-256 97fe12b8d18b
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Logic Lab — Circuit Graph Editor</title>
<style>
:root{--bg:#0a0e14;--panel:#111720;--panel2:#151e29;--line:#263240;--muted:#8090a2;--text:#e4edf5;--cyan:#49d8e8;--amber:#ffb45b;--green:#54e59a;--red:#ff6174}
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:var(--bg);color:var(--text);font:13px/1.4 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}button{font:inherit;color:inherit}
.app{height:100%;display:grid;grid-template-rows:58px 1fr}.topbar{display:flex;align-items:center;gap:20px;padding:0 20px;background:#0e141c;border-bottom:1px solid #222d39;z-index:5}.brand{display:flex;align-items:center;gap:11px;font-weight:750;letter-spacing:.04em}.brandmark{width:29px;height:29px;border-radius:9px;background:linear-gradient(145deg,#32cbd7,#5176ee);display:grid;place-items:center;box-shadow:0 0 18px #31cad733}.brandmark svg{width:17px}.brand small{display:block;color:#8091a4;font-size:9px;font-weight:650;letter-spacing:.16em;margin-top:1px}.top-actions{margin-left:auto;display:flex;align-items:center;gap:8px}.pill{border:1px solid #293644;background:#141c26;color:#9aabba;padding:7px 11px;border-radius:8px;font-size:11px}.pill strong{color:#dce7ef;font-weight:650}.save-indicator{display:flex;align-items:center;gap:7px;margin-right:8px;color:#8698a9;font-size:11px}.save-dot{width:6px;height:6px;border-radius:50%;background:#4ce1a1;box-shadow:0 0 8px #4ce1a166}
.workspace{min-height:0;display:grid;grid-template-columns:220px minmax(0,1fr)}.sidebar{background:#10161f;border-right:1px solid #222d39;padding:19px 13px 14px;display:flex;flex-direction:column;gap:18px;z-index:2}.section-label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#728296;font-weight:750;padding:0 8px;margin-bottom:8px}.group{display:grid;gap:6px}.add-node{width:100%;border:1px solid transparent;background:transparent;border-radius:9px;padding:9px 9px;display:flex;align-items:center;gap:10px;text-align:left;cursor:pointer;transition:.16s}.add-node:hover{background:#18222e;border-color:#2b3948;transform:translateX(2px)}.icon{width:29px;height:29px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:12px;letter-spacing:-.04em}.icon.input{background:#14383b;color:#62dce3}.icon.gate{background:#25233e;color:#b6a9ff}.icon.output{background:#392a1d;color:#ffc078}.node-label{display:block;font-size:12px;font-weight:650;line-height:1.25}.node-sub{display:block;font-size:10px;color:#7f90a1;margin-top:3px;line-height:1.35}.sidebar-foot{margin-top:auto;padding:12px 9px 0;border-top:1px solid #202b37;color:#718195;font-size:10px;line-height:1.7}.key{border:1px solid #34404c;border-radius:4px;padding:1px 5px;color:#a9b7c4;background:#18212a;font-size:9px}
.stage{position:relative;min-width:0;min-height:0;background:#0b1017;overflow:hidden}.graph{width:100%;height:100%;display:block;touch-action:none;cursor:grab}.graph.panning{cursor:grabbing}.hud{position:absolute;left:18px;top:16px;display:flex;gap:7px;pointer-events:none}.hud-chip{background:#111923dd;border:1px solid #263340;border-radius:7px;padding:6px 9px;color:#8192a3;font-size:10px;backdrop-filter:blur(8px)}.hud-chip b{color:#c4d3df;font-weight:650}.zoom-tools{position:absolute;right:17px;top:15px;display:flex;gap:5px}.tool-btn{height:30px;min-width:31px;border-radius:7px;border:1px solid #293541;background:#121a24;color:#aebbc7;cursor:pointer;transition:.15s}.tool-btn:hover{background:#202b37;color:white;border-color:#3b4a58}.help{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);padding:7px 12px;background:#101720d9;border:1px solid #26323e;border-radius:8px;color:#728397;font-size:10px;white-space:nowrap;pointer-events:none}.help b{color:#acbac8;font-weight:600}.toast{position:absolute;left:50%;top:64px;transform:translate(-50%,-8px);opacity:0;background:#361a23;border:1px solid #843b4a;color:#ff9ba6;padding:8px 13px;border-radius:8px;font-size:11px;transition:.18s;pointer-events:none;z-index:4}.toast.show{opacity:1;transform:translate(-50%,0)}
svg text{font-family:Inter,ui-sans-serif,system-ui,sans-serif;user-select:none}.wire{fill:none;stroke:#43505e;stroke-width:2.4;opacity:.67;transition:stroke .15s,opacity .15s}.wire.live{stroke:#55e2a1;opacity:.95;filter:url(#glowGreen)}.wire-hit{fill:none;stroke:transparent;stroke-width:17;cursor:pointer}.wire.selected{stroke:#e5c978;opacity:1}.preview{fill:none;stroke:#53d7e4;stroke-width:2.4;stroke-dasharray:7 5;opacity:.9;pointer-events:none}.preview.bad{stroke:#ff6374;filter:url(#glowRed)}.node-card{fill:url(#cardGrad);stroke:#303d4b;stroke-width:1.2;filter:url(#shadow);transition:stroke .13s}.node-wrap:hover .node-card{stroke:#536477}.node-wrap.selected .node-card{stroke:#e5bf69;stroke-width:1.5}.node-wrap{cursor:move}.node-wrap:hover{filter:url(#lift)}.node-title{fill:#d8e2eb;font-size:12px;font-weight:700;letter-spacing:.01em}.node-kind{fill:#758698;font-size:9px;letter-spacing:.12em;font-weight:700}.state-pill{fill:#1a242e;stroke:#34414d;stroke-width:.8}.state-text{fill:#8393a2;font-size:8px;font-weight:750;letter-spacing:.09em}.state-pill.on{fill:#15352e;stroke:#286a53}.state-text.on{fill:#6aefac}.input-port{fill:#152e34;stroke:#55d5e1;stroke-width:2}.output-port{fill:#3a2b1c;stroke:#ffbd6c;stroke-width:2}.port-core.in{fill:#70e2ea}.port-core.out{fill:#ffd18b}.port-hit{fill:transparent;cursor:crosshair}.port-group:hover .input-port,.port-group:hover .output-port,.port-group.hot .input-port,.port-group.hot .output-port{r:7.8;filter:url(#glowCyan)}.port-label{fill:#687b8d;font-size:8px}.switch-track{fill:#27333e;stroke:#3d4d5b;stroke-width:1}.switch-track.on{fill:#1d795d;stroke:#36c88d}.switch-knob{fill:#a9b8c5;filter:url(#smallGlow)}.switch-knob.on{fill:#e5fff1}.rate-chip{fill:#17242c;stroke:#34505a;stroke-width:1;cursor:pointer}.rate-text{fill:#77dce1;font-size:9px;font-weight:700}.lamp{fill:#242b32;stroke:#58636d;stroke-width:1.4}.lamp.on{fill:#ffd267;stroke:#ffe8a1;filter:url(#glowGold)}.digit{fill:#29313a;stroke:#3a4651;stroke-width:.5}.digit.on{fill:#ffbe62;filter:url(#glowGold)}.empty-title{font-size:18px;fill:#bdc9d5;font-weight:650}.empty-copy{font-size:12px;fill:#718193}
@media(max-width:700px){.workspace{grid-template-columns:160px minmax(0,1fr)}.sidebar{padding:14px 8px}.add-node{padding:8px 5px;gap:7px}.icon{width:25px;height:25px}.node-sub{font-size:9px}.topbar{padding:0 12px}.pill.hide-mobile{display:none}.brand small{display:none}.help{font-size:9px;max-width:90%;white-space:normal;text-align:center}}
</style>
</head>
<body>
<div class="app">
<header class="topbar"><div class="brand"><div class="brandmark"><svg viewBox="0 0 20 20" fill="none"><path d="M3 5h5l3 5-3 5H3M11 10h6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17" cy="10" r="2" fill="white"/></svg></div><div>LOGIC LAB<small>CIRCUIT WORKSPACE</small></div></div><div class="top-actions"><div class="save-indicator"><i class="save-dot"></i><span id="saveText">All changes saved</span></div><div class="pill hide-mobile"><strong id="nodeCount">0</strong> nodes</div><div class="pill"><strong id="zoomText">100%</strong></div></div></header>
<div class="workspace"><aside class="sidebar"><section><div class="section-label">Inputs</div><div class="group"><button class="add-node" data-add="toggle"><span class="icon input">I/O</span><span><span class="node-label">Toggle</span><span class="node-sub">Manual signal source</span></span></button><button class="add-node" data-add="clock"><span class="icon input">◷</span><span><span class="node-label">Clock</span><span class="node-sub">Pulsing signal source</span></span></button></div></section><section><div class="section-label">Logic gates</div><div class="group"><button class="add-node" data-add="and"><span class="icon gate">&</span><span><span class="node-label">AND</span><span class="node-sub">All inputs high</span></span></button><button class="add-node" data-add="or"><span class="icon gate">≥1</span><span><span class="node-label">OR</span><span class="node-sub">Any input high</span></span></button><button class="add-node" data-add="not"><span class="icon gate">¬</span><span><span class="node-label">NOT</span><span class="node-sub">Invert signal</span></span></button><button class="add-node" data-add="xor"><span class="icon gate">⊕</span><span><span class="node-label">XOR</span><span class="node-sub">Exclusive OR</span></span></button><button class="add-node" data-add="nand"><span class="icon gate">&̅</span><span><span class="node-label">NAND</span><span class="node-sub">Inverted AND</span></span></button><button class="add-node" data-add="nor"><span class="icon gate">≥1̅</span><span><span class="node-label">NOR</span><span class="node-sub">Inverted OR</span></span></button></div></section><section><div class="section-label">Outputs</div><div class="group"><button class="add-node" data-add="lamp"><span class="icon output">●</span><span><span class="node-label">Lamp</span><span class="node-sub">Single signal indicator</span></span></button><button class="add-node" data-add="display"><span class="icon output">8</span><span><span class="node-label">Hex display</span><span class="node-sub">4-bit hexadecimal</span></span></button></div></section><div class="sidebar-foot">Drag from an <span style="color:#ffbd6c">output</span> to an <span style="color:#62dce3">input</span> port to connect.<br><span class="key">Space</span> + drag to pan · wheel to zoom</div></aside>
<main class="stage" id="stage"><svg id="graph" class="graph" aria-label="Logic circuit canvas"><defs><pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="#293541" opacity=".7"/></pattern><linearGradient id="cardGrad" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#1a232e"/><stop offset="1" stop-color="#141c26"/></linearGradient><filter id="shadow" x="-30%" y="-30%" width="160%" height="170%"><feDropShadow dx="0" dy="5" stdDeviation="6" flood-color="#000" flood-opacity=".27"/></filter><filter id="lift" x="-30%" y="-30%" width="160%" height="170%"><feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#000" flood-opacity=".26"/></filter><filter id="glowGreen" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter><filter id="glowGold" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter><filter id="glowRed" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter><filter id="glowCyan" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter><filter id="smallGlow" x="-80%" y="-80%" width="260%" height="260%"><feGaussianBlur stdDeviation="1.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g id="world"></g></svg><div class="hud"><div class="hud-chip"><b id="hudNodes">0</b> components</div><div class="hud-chip"><b id="hudWires">0</b> connections</div></div><div class="zoom-tools"><button class="tool-btn" id="zoomOut" title="Zoom out">−</button><button class="tool-btn" id="zoomFit" title="Fit circuit">⌖</button><button class="tool-btn" id="zoomIn" title="Zoom in">+</button></div><div class="toast" id="toast">Feedback loop rejected — combinational circuits must be acyclic.</div><div class="help"><b>Drag</b> empty canvas to pan · <b>Wheel</b> zoom · <b>Click</b> a toggle to change signal · <b>Del</b> remove selected</div></main></div></div>
<script>
(()=>{'use strict';
// Tunable constants
const C={MIN_ZOOM:.5,MAX_ZOOM:2,PORT_R:5.5,PORT_HIT:17,WIRE_HIT:10,GRID:28,CLOCK_RATES:[.5,1,2],SAVE_KEY:'logic-lab-scene-v1'};
const META={toggle:{title:'Toggle',kind:'INPUT',ins:0,w:154,h:88},clock:{title:'Clock',kind:'INPUT',ins:0,w:154,h:88},and:{title:'AND',kind:'GATE',ins:2,w:154,h:96},or:{title:'OR',kind:'GATE',ins:2,w:154,h:96},not:{title:'NOT',kind:'GATE',ins:1,w:154,h:88},xor:{title:'XOR',kind:'GATE',ins:2,w:154,h:96},nand:{title:'NAND',kind:'GATE',ins:2,w:154,h:96},nor:{title:'NOR',kind:'GATE',ins:2,w:154,h:96},lamp:{title:'Lamp',kind:'OUTPUT',ins:1,w:154,h:88},display:{title:'Hex display',kind:'OUTPUT',ins:4,w:154,h:132}};
const svg=document.getElementById('graph'),world=document.getElementById('world'),stage=document.getElementById('stage');let scene={nodes:[],wires:[]},pan={x:0,y:0},zoom=1,selected=null,gesture=null,preview=null,spaceDown=false,toastTimer=null,saveTimer=null,dirty=false;
const rates=C.CLOCK_RATES;let uid=1;
// Scene model and persistence
function loadScene(){try{const raw=localStorage.getItem(C.SAVE_KEY);if(raw){const d=JSON.parse(raw);if(Array.isArray(d.nodes)&&Array.isArray(d.wires)){scene.nodes=d.nodes.filter(n=>META[n.type]).map(n=>({...n}));scene.wires=d.wires.filter(w=>scene.nodes.some(n=>n.id===w.from)&&scene.nodes.some(n=>n.id===w.to)).map(w=>({...w}));uid=scene.nodes.reduce((m,n)=>Math.max(m,Number(String(n.id).replace(/\D/g,''))||0),0)+1;}}}catch(e){console.warn('Could not restore scene',e)} evaluate();}
function save(){clearTimeout(saveTimer);dirty=true;document.getElementById('saveText').textContent='Saving…';saveTimer=setTimeout(()=>{try{localStorage.setItem(C.SAVE_KEY,JSON.stringify(scene));dirty=false;document.getElementById('saveText').textContent='All changes saved'}catch(e){document.getElementById('saveText').textContent='Save unavailable'}},140)}
function nodeBy(id){return scene.nodes.find(n=>n.id===id)}function meta(n){return META[n.type]}function inY(n,i){const m=meta(n);return m.h/2+(i-(m.ins-1)/2)*19}function outPoint(n){return{x:n.x+meta(n).w,y:n.y+meta(n).h/2}}function inPoint(n,i){return{x:n.x,y:n.y+inY(n,i)}}
function addNode(type,x,y){const n={id:'n'+uid++,type,x,y,state:type==='toggle'?false:false,rate:1};scene.nodes.push(n);selected=n.id;evaluate();save();render();return n}
function removeSelected(){if(!selected)return;scene.nodes=scene.nodes.filter(n=>n.id!==selected);scene.wires=scene.wires.filter(w=>w.from!==selected&&w.to!==selected);selected=null;evaluate();save();render()}
function sourceValue(id){const n=nodeBy(id);return !!(n&&n.state)}
function inputValue(n,i){const w=scene.wires.find(q=>q.to===n.id&&q.toPort===i);return w?sourceValue(w.from):false}
// A single topological pass computes every combinational node; DAG edges are maintained by cycle checks.
function evaluate(){const indeg=new Map(scene.nodes.map(n=>[n.id,0])),adj=new Map(scene.nodes.map(n=>[n.id,[]]));for(const w of scene.wires){if(indeg.has(w.to)&&adj.has(w.from)){indeg.set(w.to,indeg.get(w.to)+1);adj.get(w.from).push(w.to)}}const q=scene.nodes.filter(n=>indeg.get(n.id)===0),order=[];while(q.length){const n=q.shift();order.push(n);for(const id of adj.get(n.id)||[]){indeg.set(id,indeg.get(id)-1);if(indeg.get(id)===0)q.push(nodeBy(id))}}for(const n of order){if(n.type==='toggle'||n.type==='clock')continue;const a=Array.from({length:meta(n).ins},(_,i)=>inputValue(n,i));switch(n.type){case'and':n.state=a.every(Boolean);break;case'or':n.state=a.some(Boolean);break;case'not':n.state=!a[0];break;case'xor':n.state=a.filter(Boolean).length%2===1;break;case'nand':n.state=!a.every(Boolean);break;case'nor':n.state=!a.some(Boolean);break;case'lamp':n.state=!!a[0];break;case'display':n.value=a.reduce((v,b,i)=>v+(b?(1<<(3-i)):0),0);n.state=n.value!==0;break}}}
function reachable(start,target,ignored){const seen=new Set(),stack=[start];while(stack.length){const id=stack.pop();if(id===target)return true;if(seen.has(id))continue;seen.add(id);for(const w of scene.wires)if(w!==ignored&&w.from===id)stack.push(w.to)}return false}
function connect(from,to,toPort){if(!from||!to||from===to)return false;const replaced=scene.wires.find(w=>w.to===to&&w.toPort===toPort);if(reachable(to,from,replaced))return false;scene.wires=scene.wires.filter(w=>!(w.to===to&&w.toPort===toPort));scene.wires.push({from,fromPort:0,to,toPort});evaluate();save();render();return true}
// Screen/world transforms and responsive rendering
function screenPoint(e){const r=svg.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}}
function toWorld(p){return{x:(p.x-pan.x)/zoom,y:(p.y-pan.y)/zoom}}
function curve(a,b){const dx=Math.max(38,Math.abs(b.x-a.x)*.48);return`M ${a.x} ${a.y} C ${a.x+dx} ${a.y}, ${b.x-dx} ${b.y}, ${b.x} ${b.y}`}
function esc(v){return String(v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
function render(){const r=svg.getBoundingClientRect();svg.setAttribute('viewBox',`0 0 ${r.width} ${r.height}`);let html=`<g transform="translate(${pan.x} ${pan.y}) scale(${zoom})"><rect x="-12000" y="-12000" width="24000" height="24000" fill="url(#dots)"/>`;
for(const w of scene.wires){const a=outPoint(nodeBy(w.from)),b=inPoint(nodeBy(w.to),w.toPort),live=sourceValue(w.from);html+=`<path class="wire ${live?'live':''}" d="${curve(a,b)}"/><path class="wire-hit" data-wire="${esc(w.to)}:${w.toPort}" d="${curve(a,b)}"/>`}
if(preview){const a=outPoint(nodeBy(preview.from)),b=preview.target?inPoint(nodeBy(preview.target.id),preview.target.port):preview.pos;html+=`<path class="preview ${preview.bad?'bad':''}" d="${curve(a,b)}"/>`}
for(const n of scene.nodes)html+=drawNode(n);
html+='</g>';world.innerHTML=html;document.getElementById('nodeCount').textContent=scene.nodes.length;document.getElementById('hudNodes').textContent=scene.nodes.length;document.getElementById('hudWires').textContent=scene.wires.length;document.getElementById('zoomText').textContent=Math.round(zoom*100)+'%'}
function drawNode(n){const m=meta(n),on=!!n.state,x=n.x,y=n.y;let s=`<g class="node-wrap ${selected===n.id?'selected':''}" data-node="${esc(n.id)}"><rect class="node-card" x="${x}" y="${y}" width="${m.w}" height="${m.h}" rx="12"/>`;
s+=`<text class="node-kind" x="${x+13}" y="${y+19}">${m.kind}</text><text class="node-title" x="${x+13}" y="${y+39}">${m.title}</text>`;
if(n.type==='toggle'){s+=`<text class="state-text ${on?'on':''}" x="${x+13}" y="${y+67}">${on?'HIGH':'LOW'}</text><g data-action="toggle" data-id="${esc(n.id)}"><rect class="switch-track ${on?'on':''}" x="${x+108}" y="${y+52}" width="32" height="18" rx="9"/><circle class="switch-knob ${on?'on':''}" cx="${x+(on?130:118)}" cy="${y+61}" r="6"/></g>`}
if(n.type==='clock'){const rate=n.rate||1;s+=`<text class="state-text ${on?'on':''}" x="${x+13}" y="${y+67}">${on?'PULSE HIGH':'PULSE LOW'}</text><g data-action="rate" data-id="${esc(n.id)}"><rect class="rate-chip" x="${x+103}" y="${y+51}" width="39" height="20" rx="6"/><text class="rate-text" x="${x+122.5}" y="${y+64.5}" text-anchor="middle">${rate} Hz</text></g>`}
if(['and','or','not','xor','nand','nor'].includes(n.type)){s+=`<rect class="state-pill ${on?'on':''}" x="${x+106}" y="${y+59}" width="34" height="17" rx="8"/><text class="state-text ${on?'on':''}" x="${x+123}" y="${y+70.5}" text-anchor="middle">${on?'ON':'OFF'}</text>`}
if(n.type==='lamp'){s+=`<text class="state-text" x="${x+13}" y="${y+68}">OUTPUT</text><circle class="lamp ${on?'on':''}" cx="${x+124}" cy="${y+59}" r="11"/>`}
if(n.type==='display'){const val=n.value||0;s+=`<text class="state-text" x="${x+13}" y="${y+121}">HEX OUTPUT</text>${sevenSeg(x+105,y+43,val)}`}
for(let i=0;i<m.ins;i++){const py=y+inY(n,i);s+=`<g class="port-group" data-port="in" data-node="${esc(n.id)}" data-index="${i}"><circle class="port-hit" cx="${x}" cy="${py}" r="${C.PORT_HIT}"/><circle class="input-port" cx="${x}" cy="${py}" r="${C.PORT_R}"/><circle class="port-core in" cx="${x}" cy="${py}" r="2"/></g>`}
if(!['lamp','display'].includes(n.type)){const p=outPoint(n);s+=`<g class="port-group" data-port="out" data-node="${esc(n.id)}"><circle class="port-hit" cx="${p.x}" cy="${p.y}" r="${C.PORT_HIT}"/><circle class="output-port" cx="${p.x}" cy="${p.y}" r="${C.PORT_R}"/><circle class="port-core out" cx="${p.x}" cy="${p.y}" r="2"/></g>`}
return s+'</g>'}
function sevenSeg(x,y,v){const bits=[[5,0,15,3],[20,4,3,13],[20,21,3,13],[5,35,15,3],[2,21,3,13],[2,4,3,13],[5,17,15,3]],onMap=[0x3f,0x06,0x5b,0x4f,0x66,0x6d,0x7d,0x07,0x7f,0x6f,0x77,0x7c,0x39,0x5e,0x79,0x71][v&15];return`<g>${bits.map((b,i)=>`<rect class="digit ${(onMap>>i)&1?'on':''}" x="${x+b[0]}" y="${y+b[1]}" width="${b[2]}" height="${b[3]}" rx="1.5"/>`).join('')}<text x="${x+31}" y="${y+34}" fill="#aebcc8" font-size="12" font-weight="700">${(v&15).toString(16).toUpperCase()}</text></g>`}
function findPort(pos,kind){let best=null,dist=Infinity;for(const n of scene.nodes){if(kind==='in'){for(let i=0;i<meta(n).ins;i++){const p=inPoint(n,i),d=Math.hypot(pos.x-p.x,pos.y-p.y);if(d<C.PORT_HIT/zoom&&d<dist){best={id:n.id,port:i};dist=d}}}else if(!['lamp','display'].includes(n.type)){const p=outPoint(n),d=Math.hypot(pos.x-p.x,pos.y-p.y);if(d<C.PORT_HIT/zoom&&d<dist){best={id:n.id,port:0};dist=d}}}return best}
function flashCycle(){const t=document.getElementById('toast');t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),1500)}
// Pointer interactions: port dragging, node movement, and canvas pan
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const s=screenPoint(e),p=toWorld(s),portEl=e.target.closest('[data-port]'),actionEl=e.target.closest('[data-action]'),nodeEl=e.target.closest('[data-node]');svg.setPointerCapture(e.pointerId);
if(portEl&&portEl.dataset.port==='out'){const id=portEl.dataset.node;preview={from:id,pos:p};gesture={kind:'wire',pointer:e.pointerId};render();e.preventDefault();return}
if(portEl&&portEl.dataset.port==='in'){const id=portEl.dataset.node,idx=Number(portEl.dataset.index),old=scene.wires.find(w=>w.to===id&&w.toPort===idx);if(old){scene.wires=scene.wires.filter(w=>w!==old);preview={from:old.from,pos:p,origin:{...old}};gesture={kind:'wire',pointer:e.pointerId,old};evaluate();render();e.preventDefault();return}}
if(actionEl){const n=nodeBy(actionEl.dataset.id);if(actionEl.dataset.action==='toggle'){n.state=!n.state;evaluate();save();render();gesture={kind:'click',pointer:e.pointerId};e.preventDefault();return}if(actionEl.dataset.action==='rate'){const i=rates.indexOf(n.rate||1);n.rate=rates[(i+1)%rates.length];save();render();e.preventDefault();return}}
if(nodeEl){const n=nodeBy(nodeEl.dataset.node);selected=n.id;gesture={kind:'node',id:n.id,pointer:e.pointerId,start:p,orig:{x:n.x,y:n.y},moved:false};render();e.preventDefault();return}
selected=null;if(spaceDown||e.button===0){gesture={kind:'pan',pointer:e.pointerId,last:s};svg.classList.add('panning')}render();});
svg.addEventListener('pointermove',e=>{if(!gesture||gesture.pointer!==e.pointerId)return;const s=screenPoint(e),p=toWorld(s);if(gesture.kind==='pan'){pan.x+=s.x-gesture.last.x;pan.y+=s.y-gesture.last.y;gesture.last=s;render();return}if(gesture.kind==='node'){const n=nodeBy(gesture.id),dx=p.x-gesture.start.x,dy=p.y-gesture.start.y;if(Math.abs(dx)+Math.abs(dy)>1)gesture.moved=true;n.x=gesture.orig.x+dx;n.y=gesture.orig.y+dy;render();return}if(gesture.kind==='wire'){const target=findPort(p,'in');preview.pos=p;preview.target=target;preview.bad=!!(target&&(target.id===preview.from||reachable(target.id,preview.from)));render()}});
svg.addEventListener('pointerup',e=>{if(!gesture||gesture.pointer!==e.pointerId)return;const g=gesture,s=screenPoint(e),p=toWorld(s);if(g.kind==='wire'){const target=findPort(p,'in');let ok=false;if(target)ok=connect(preview.from,target.id,target.port);if(!ok&&g.old){scene.wires.push(g.old);evaluate();save()}if(target&&!ok&&(target.id===preview.from||reachable(target.id,preview.from)))flashCycle();preview=null;render()}else if(g.kind==='node'){if(g.moved)save()}else if(g.kind==='pan')svg.classList.remove('panning');gesture=null;});
svg.addEventListener('pointercancel',e=>{if(gesture?.kind==='wire'&&gesture.old){scene.wires.push(gesture.old);evaluate();save()}preview=null;gesture=null;svg.classList.remove('panning');render()});
// Palette, keyboard, viewport controls
for(const b of document.querySelectorAll('[data-add]'))b.addEventListener('click',()=>{const r=svg.getBoundingClientRect(),p=toWorld({x:r.width/2,y:r.height/2});const offset=scene.nodes.length%7*24;addNode(b.dataset.add,p.x-77+offset,p.y-45+offset)});
document.addEventListener('keydown',e=>{if(e.code==='Space'){spaceDown=true;if(e.target===document.body)e.preventDefault()}if((e.key==='Delete'||e.key==='Backspace')&&!['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){removeSelected();e.preventDefault()}if(e.key==='Escape'&&preview){if(gesture?.old)scene.wires.push(gesture.old);preview=null;gesture=null;evaluate();save();render()}});document.addEventListener('keyup',e=>{if(e.code==='Space')spaceDown=false});
function zoomAt(factor,s){const old=zoom,nz=Math.max(C.MIN_ZOOM,Math.min(C.MAX_ZOOM,old*factor));if(nz===old)return;const w={x:(s.x-pan.x)/old,y:(s.y-pan.y)/old};zoom=nz;pan.x=s.x-w.x*nz;pan.y=s.y-w.y*nz;render()}
svg.addEventListener('wheel',e=>{e.preventDefault();zoomAt(e.deltaY<0?1.12:1/1.12,screenPoint(e))},{passive:false});document.getElementById('zoomIn').onclick=()=>{const r=svg.getBoundingClientRect();zoomAt(1.2,{x:r.width/2,y:r.height/2})};document.getElementById('zoomOut').onclick=()=>{const r=svg.getBoundingClientRect();zoomAt(1/1.2,{x:r.width/2,y:r.height/2})};document.getElementById('zoomFit').onclick=()=>fitView();
function fitView(){if(!scene.nodes.length){zoom=1;pan={x:0,y:0};render();return}const r=svg.getBoundingClientRect(),minX=Math.min(...scene.nodes.map(n=>n.x)),minY=Math.min(...scene.nodes.map(n=>n.y)),maxX=Math.max(...scene.nodes.map(n=>n.x+meta(n).w)),maxY=Math.max(...scene.nodes.map(n=>n.y+meta(n).h));zoom=Math.max(C.MIN_ZOOM,Math.min(1.2,Math.min((r.width-100)/(maxX-minX+100),(r.height-100)/(maxY-minY+100))));pan.x=(r.width-(maxX+minX)*zoom)/2;pan.y=(r.height-(maxY+minY)*zoom)/2;render()}
// Clock sources pulse independently and trigger the same topological evaluator.
setInterval(()=>{let changed=false;for(const n of scene.nodes)if(n.type==='clock'){const period=1000/(n.rate||1);if(!n._next)n._next=Date.now()+period;if(Date.now()>=n._next){n.state=!n.state;n._next=Date.now()+period;changed=true}}if(changed){evaluate();render()}},40);
window.addEventListener('resize',render);loadScene();render();
// Test hooks kept intentionally small and tied to the real scene engine.
window.logicLab={scene,get zoom(){return zoom},get pan(){return pan},evaluate,addNode,connect,removeSelected,fitView,screenToWorld:toWorld};
})();
</script></body></html>
<!-- agent-meta {"model":"openai/gpt-6-luna","provider":"openrouter","persona":"full","sessionId":"0aee13de-d059-4c4c-abf1-300a5cc206b0","tokensIn":127018,"tokensOut":15142,"tokensTotal":142160,"cost":0.010693270000000001,"turns":9,"toolCalls":8,"failedToolCalls":0,"timestamp":"2026-09-23T00:49:36.853Z"} -->