Node Graph Editor
muse spark 1.2 · basic

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 47,841 bytes · SHA-256 ba4528bc4afd
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Logic Node Graph — Instrument Panel</title>
<style>
:root{
--bg:#070b13;
--panel:#0f1623;
--panel2:#131e32;
--card:#1a2640;
--card2:#1e2e4a;
--border:#24344f;
--border2:#2b3e5e;
--text:#e6edf8;
--muted:#8a9bb8;
--accent:#f59e0b;
--accent2:#38bdf8;
--glow-amber:0 0 10px rgba(245,158,11,.7),0 0 22px rgba(245,158,11,.35);
--glow-blue:0 0 10px rgba(56,189,248,.6);
--radius:14px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;overflow:hidden}
#app{display:grid;grid-template-columns:260px 1fr 300px;grid-template-rows:56px 1fr;height:100vh}
header{grid-column:1/4;display:flex;align-items:center;gap:16px;padding:0 16px;background:linear-gradient(180deg,#0f1a2e 0%,#0c1426 100%);border-bottom:1px solid var(--border);z-index:5}
header h1{font-size:15px;letter-spacing:.12em;font-weight:800;margin:0;display:flex;align-items:center;gap:10px}
header h1 i{width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#f59e0b,#ef4444);display:grid;place-items:center;font-style:normal;font-size:14px;box-shadow:var(--glow-amber)}
.hdr-actions{margin-left:auto;display:flex;gap:8px;align-items:center}
.btn{appearance:none;border:1px solid var(--border);background:#16233d;color:var(--text);padding:7px 12px;border-radius:10px;font-size:12px;font-weight:600;cursor:pointer;transition:.15s}
.btn:hover{transform:translateY(-1px);border-color:var(--border2);background:#1a2a4a}
.btn.primary{background:linear-gradient(180deg,#f59e0b,#d97706);border-color:#b45309;color:#1a1000;box-shadow:0 4px 14px rgba(245,158,11,.3)}
.btn.ghost{background:transparent}
.zoom-group{display:flex;align-items:center;gap:6px;background:#0e1a30;border:1px solid var(--border);padding:4px;border-radius:999px}
.zoom-group button{width:28px;height:28px;border-radius:999px;border:1px solid var(--border);background:#16233d;color:var(--text);cursor:pointer}
.zoom-group span{font-size:12px;min-width:52px;text-align:center;color:var(--muted);font-variant-numeric:tabular-nums}
/* left palette */
#palette{background:var(--panel);border-right:1px solid var(--border);padding:14px 12px;overflow:auto}
.group{margin-bottom:18px}
.group h3{font-size:10px;letter-spacing:.14em;color:var(--muted);margin:0 0 8px 4px;text-transform:uppercase}
.pal-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.pal-btn{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 6px;background:linear-gradient(180deg,var(--card) 0%,#15223a 100%);border:1px solid var(--border);border-radius:12px;cursor:pointer;transition:.15s;color:var(--text)}
.pal-btn:hover{transform:translateY(-2px);border-color:#3a4f74;box-shadow:0 6px 18px rgba(0,0,0,.35)}
.pal-btn b{font-size:11px;letter-spacing:.04em}
.pal-btn small{font-size:9px;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
.pal-icon{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:13px;border:1px solid rgba(255,255,255,.08)}
.pal-icon.in{background:rgba(56,189,248,.14);color:#7dd3fc;border-color:rgba(56,189,248,.3)}
.pal-icon.gate{background:rgba(148,163,184,.12);color:#e2e8f0}
.pal-icon.out{background:rgba(245,158,11,.15);color:#fcd34d;border-color:rgba(245,158,11,.3)}
.hint{font-size:11px;color:var(--muted);line-height:1.5;background:#0e1a30;border:1px solid var(--border);padding:10px;border-radius:10px;margin-top:8px}
.hint kbd{background:#1a2a48;border:1px solid #2a3c5e;border-bottom-width:2px;padding:1px 6px;border-radius:6px;font-size:10px;color:#cbd5e1}
/* viewport */
#viewport{position:relative;overflow:hidden;background:#070b13;cursor:grab}
#viewport.panning{cursor:grabbing}
#viewport.grab-wire{cursor:crosshair}
#grid{position:absolute;inset:0;pointer-events:none;opacity:.9}
#world{position:absolute;left:0;top:0;width:0;height:0;transform-origin:0 0;will-change:transform}
#wire-layer{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
#node-layer{position:absolute;left:0;top:0}
/* nodes */
.node{position:absolute;width:148px;background:linear-gradient(180deg,var(--card2) 0%,#16233d 100%);border:1px solid var(--border);border-radius:14px;box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);user-select:none;transition:box-shadow .15s, transform .15s, border-color .15s}
.node:hover{transform:translateY(-1px);box-shadow:0 12px 30px rgba(0,0,0,.55);border-color:#324867}
.node.selected{border-color:#f59e0b;box-shadow:0 0 0 1px rgba(245,158,11,.5),0 12px 30px rgba(0,0,0,.6), var(--glow-amber)}
.node.live{border-color:rgba(245,158,11,.35)}
.node-head{display:flex;align-items:center;gap:8px;padding:10px 10px 8px;cursor:grab;border-radius:14px 14px 0 0}
.node-head:active{cursor:grabbing}
.node-type{width:28px;height:28px;border-radius:8px;display:grid;place-items:center;font-size:11px;font-weight:900;letter-spacing:.02em;flex-shrink:0;border:1px solid rgba(255,255,255,.08)}
.node-type.t-in{background:rgba(56,189,248,.18);color:#7dd3fc;border-color:rgba(56,189,248,.35)}
.node-type.t-gate{background:#0f1d33;color:#cbd5e1}
.node-type.t-out{background:rgba(245,158,11,.18);color:#fcd34d;border-color:rgba(245,158,11,.35)}
.node-title{flex:1;min-width:0}
.node-title strong{display:block;font-size:11px;letter-spacing:.06em;text-transform:uppercase;line-height:1}
.node-title span{font-size:10px;color:var(--muted)}
.led{width:10px;height:10px;border-radius:50%;background:#1f2937;border:1px solid #334155;box-shadow:inset 0 1px 2px rgba(0,0,0,.5);transition:.2s}
.led.on{background:#f59e0b;border-color:#fbbf24;box-shadow:0 0 8px #f59e0b,0 0 18px rgba(245,158,11,.6),inset 0 1px 2px rgba(255,255,255,.6)}
.led.blue.on{background:#38bdf8;border-color:#7dd3fc;box-shadow:0 0 8px #38bdf8,0 0 18px rgba(56,189,248,.6)}
.node-body{padding:8px 10px 10px}
.toggle-btn{width:100%;padding:8px;border-radius:10px;border:1px solid var(--border);background:#0e1a30;color:var(--text);font-weight:800;letter-spacing:.08em;font-size:11px;cursor:pointer;transition:.15s;display:flex;align-items:center;justify-content:center;gap:8px}
.toggle-btn.on{background:linear-gradient(180deg,#f59e0b,#d97706);color:#1a1000;border-color:#92400e;box-shadow:var(--glow-amber)}
.toggle-btn.off{background:#0e1a30}
.clock-row{display:flex;gap:6px;align-items:center}
.clock-row select{flex:1;background:#0e1a30;border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px;font-size:11px}
.seg{display:grid;place-items:center;background:#020617;border:1px solid #1e293b;border-radius:10px;padding:8px 0}
.seg-char{font-family:ui-monospace,Menlo,Monaco,monospace;font-size:28px;font-weight:900;letter-spacing:.06em;line-height:1}
.seg-char.on{color:#f59e0b;text-shadow:0 0 10px rgba(245,158,11,.8),0 0 20px rgba(245,158,11,.4)}
.seg-char.off{color:#1e293b}
.seg-bits{font-size:9px;color:var(--muted);letter-spacing:.08em;margin-top:4px}
.lamp{height:38px;border-radius:10px;display:grid;place-items:center;background:radial-gradient(120px 60px at 50% 30%, rgba(255,255,255,.08), transparent 60%), #020617;border:1px solid #1e293b;position:relative;overflow:hidden}
.lamp.on{background:radial-gradient(180px 90px at 50% 30%, rgba(245,158,11,.55), transparent 65%), radial-gradient(120px 80px at 50% 50%, rgba(245,158,11,.9), rgba(120,60,0,.9));border-color:#f59e0b;box-shadow:0 0 18px rgba(245,158,11,.7), inset 0 0 20px rgba(255,255,255,.25)}
.lamp-dot{width:18px;height:18px;border-radius:50%;background:#0f172a;border:1px solid #334155}
.lamp.on .lamp-dot{background:#fff;box-shadow:0 0 10px #fff,0 0 18px #f59e0b}
.gate-label{font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8;text-align:center;padding:4px 0 2px;letter-spacing:.12em}
.io-row{display:flex;justify-content:space-between;align-items:center;gap:6px;margin-top:2px}
.port{width:14px;height:14px;border-radius:50%;border:2px solid #334155;background:#0f172a;position:absolute;cursor:pointer;transition:transform .15s, box-shadow .15s, border-color .15s;z-index:2}
.port:hover{transform:scale(1.35)}
.port.input{left:-7px;background:#0b1e2e;border-color:#38bdf8;box-shadow:0 0 0 3px rgba(56,189,248,.12)}
.port.output{right:-7px;background:#2a1a06;border-color:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.12)}
.port.input.highlight{border-color:#7dd3fc;box-shadow:0 0 0 6px rgba(56,189,248,.25),0 0 12px rgba(56,189,248,.6);transform:scale(1.45)}
.port.output.highlight{border-color:#fcd34d;box-shadow:0 0 0 6px rgba(245,158,11,.25),0 0 12px rgba(245,158,11,.6);transform:scale(1.45)}
.port.occupied::after{content:"";position:absolute;inset:3px;border-radius:50%;background:currentColor;opacity:.9}
.port.input{color:#38bdf8}
.port.output{color:#f59e0b}
.port-label{position:absolute;top:50%;transform:translateY(-50%);font-size:8px;letter-spacing:.08em;color:var(--muted);pointer-events:none;white-space:nowrap}
.port.input + .port-label{left:14px}
.port.output + .port-label{right:14px}
/* right panel */
#inspector{background:var(--panel);border-left:1px solid var(--border);padding:14px;overflow:auto}
#inspector h2{font-size:11px;letter-spacing:.14em;color:var(--muted);margin:0 0 12px;text-transform:uppercase}
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px}
.stat{background:#0e1a30;border:1px solid var(--border);border-radius:10px;padding:10px;text-align:center}
.stat strong{display:block;font-size:18px;line-height:1}
.stat span{font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}
.sel-card{background:linear-gradient(180deg,#13203a,#0e1a30);border:1px solid var(--border);border-radius:12px;padding:12px}
.sel-card h3{margin:0 0 6px;font-size:12px}
.sel-card p{margin:0;color:var(--muted);font-size:11px;line-height:1.5}
.wire-legend{display:flex;gap:10px;align-items:center;margin:10px 0;font-size:11px;color:var(--muted)}
.wire-legend i{width:26px;height:4px;border-radius:99px;display:inline-block}
/* toast */
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(80px);background:#1a2338;border:1px solid #334155;color:var(--text);padding:10px 14px;border-radius:999px;font-size:12px;font-weight:600;box-shadow:0 10px 30px rgba(0,0,0,.5);transition:.35s;z-index:50;opacity:0;pointer-events:none}
#toast.show{transform:translateX(-50%) translateY(0);opacity:1}
/* responsive */
@media(max-width:980px){#app{grid-template-columns:1fr;grid-template-rows:56px auto 1fr auto} #palette{grid-column:1} #inspector{grid-column:1} #viewport{height:520px}}
</style>
</head>
<body>
<div id="app">
<header>
<h1><i>◈</i> LOGIC NODE-GRAPH</h1>
<div style="font-size:11px;color:var(--muted);letter-spacing:.06em">DRAG SPACE TO PAN • WHEEL TO ZOOM • DRAG PORTS TO WIRE</div>
<div class="hdr-actions">
<div class="zoom-group">
<button id="zoomOut">−</button>
<span id="zoomLabel">100%</span>
<button id="zoomIn">+</button>
<button id="zoomFit" title="Center view" style="width:auto;padding:0 8px;font-size:11px">Fit</button>
</div>
<button class="btn ghost" id="btnClear">Clear</button>
<button class="btn primary" id="btnDemo">Load Demo</button>
</div>
</header>
<aside id="palette">
<div class="group">
<h3>Inputs</h3>
<div class="pal-grid">
<button class="pal-btn" data-add="TOGGLE"><span class="pal-icon in">◯</span><b>Toggle</b><small>Click to flip</small></button>
<button class="pal-btn" data-add="CLOCK"><span class="pal-icon in">◷</span><b>Clock</b><small>Pulse</small></button>
</div>
</div>
<div class="group">
<h3>Gates</h3>
<div class="pal-grid">
<button class="pal-btn" data-add="AND"><span class="pal-icon gate">∧</span><b>AND</b><small>A·B</small></button>
<button class="pal-btn" data-add="OR"><span class="pal-icon gate">∨</span><b>OR</b><small>A+B</small></button>
<button class="pal-btn" data-add="NOT"><span class="pal-icon gate">¬</span><b>NOT</b><small>!A</small></button>
<button class="pal-btn" data-add="XOR"><span class="pal-icon gate">⊕</span><b>XOR</b><small>A⊕B</small></button>
<button class="pal-btn" data-add="NAND"><span class="pal-icon gate">⊼</span><b>NAND</b><small>!(A·B)</small></button>
<button class="pal-btn" data-add="NOR"><span class="pal-icon gate">⊽</span><b>NOR</b><small>!(A+B)</small></button>
</div>
</div>
<div class="group">
<h3>Outputs</h3>
<div class="pal-grid">
<button class="pal-btn" data-add="LAMP"><span class="pal-icon out">●</span><b>Lamp</b><small>Glows on 1</small></button>
<button class="pal-btn" data-add="HEX"><span class="pal-icon out">#</span><b>Hex</b><small>4-bit → 0-F</small></button>
</div>
</div>
<div class="hint">
<b style="color:var(--text)">Wiring</b><br>
Drag <span style="color:#f59e0b">warm output</span> → <span style="color:#38bdf8">cool input</span>. One wire per input (replaces old). Output fans out.<br><br>
<b style="color:var(--text)">Cycle rejection</b> — feedback loops flash red and are refused (combinational only).<br><br>
<kbd>Delete</kbd> removes selected node & its wires. Drag node body to move.
</div>
</aside>
<main id="viewport">
<div id="grid"></div>
<div id="world">
<svg id="wire-layer"></svg>
<div id="node-layer"></div>
</div>
</main>
<aside id="inspector">
<h2>Instrument</h2>
<div class="stat-grid">
<div class="stat"><strong id="stNodes">0</strong><span>Nodes</span></div>
<div class="stat"><strong id="stWires">0</strong><span>Wires</span></div>
<div class="stat"><strong id="stZoom">100%</strong><span>Zoom</span></div>
<div class="stat"><strong id="stEval">0 ms</strong><span>Eval</span></div>
</div>
<div class="sel-card" id="selCard">
<h3>No selection</h3>
<p>Click a node to select it. Press Delete to remove. Drag ports to wire.</p>
<div class="wire-legend"><i style="background:#f59e0b;box-shadow:0 0 8px #f59e0b"></i> Energized (1) <i style="background:#334155"></i> Low (0)</div>
<p style="margin-top:8px">Tip: drag from an occupied input to re-route its wire.</p>
</div>
<div style="margin-top:12px;background:#0e1a30;border:1px solid var(--border);border-radius:10px;padding:10px">
<div style="font-size:11px;font-weight:800;letter-spacing:.08em;margin-bottom:6px">Truth snapshot</div>
<div id="truthLog" style="font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8;line-height:1.6;min-height:54px"></div>
</div>
</aside>
</div>
<div id="toast"></div>
<script>
/* ================= CONFIG ================= */
const CONFIG = {
ZOOM_MIN: 0.5,
ZOOM_MAX: 2.0,
ZOOM_STEP: 0.12,
PORT_RADIUS: 7, // visual radius
HIT_TOLERANCE_PX: 22, // snap distance in screen px
GRID_GAP: 28,
GRID_DOT: 1.6,
COLORS: {
wireOff: '#334155',
wireOn: '#f59e0b',
wireErr: '#ef4444',
bg: '#070b13',
grid: 'rgba(148,163,184,.18)'
},
CLOCK_RATES: [
{label:'0.5 Hz', ms:2000},
{label:'1 Hz', ms:1000},
{label:'2 Hz', ms:500},
{label:'4 Hz', ms:250},
]
};
/* ================= DATA MODEL ================= */
const NODE_DEFS = {
TOGGLE:{label:'Toggle', ports:{in:0,out:1}, tag:'in'},
CLOCK:{label:'Clock', ports:{in:0,out:1}, tag:'in'},
AND:{label:'AND', ports:{in:2,out:1}, tag:'gate'},
OR:{label:'OR', ports:{in:2,out:1}, tag:'gate'},
NOT:{label:'NOT', ports:{in:1,out:1}, tag:'gate'},
XOR:{label:'XOR', ports:{in:2,out:1}, tag:'gate'},
NAND:{label:'NAND', ports:{in:2,out:1}, tag:'gate'},
NOR:{label:'NOR', ports:{in:2,out:1}, tag:'gate'},
LAMP:{label:'Lamp', ports:{in:1,out:0}, tag:'out'},
HEX:{label:'Hex Display', ports:{in:4,out:0}, tag:'out'},
};
let state = {
nodes: [],
wires: [],
nextId: 1,
pan:{x: 60, y: 80},
zoom: 1,
selectedId: null,
draggingNode: null, // {id, dx, dy}
panning: null, // {sx, sy, ox, oy}
wiring: null, // {fromId, fromPort, curX, curY, tempWireId?}
flashErr: null, // {from,to,until}
};
const LS_KEY = 'logic-circuit-scene-v2';
/* ================= HELPERS ================= */
const $ = s=>document.querySelector(s);
const viewport = $('#viewport');
const world = $('#world');
const wireLayer = $('#wire-layer');
const nodeLayer = $('#node-layer');
const gridEl = $('#grid');
function uid(){ return 'n'+(state.nextId++); }
function defOf(type){ return NODE_DEFS[type]; }
function screenToWorld(sx, sy){
const r = viewport.getBoundingClientRect();
const x = sx - r.left;
const y = sy - r.top;
return { x: (x - state.pan.x)/state.zoom, y: (y - state.pan.y)/state.zoom };
}
function worldToScreen(wx, wy){
return { x: wx*state.zoom + state.pan.x, y: wy*state.zoom + state.pan.y };
}
function applyTransform(){
world.style.transform = `translate(${state.pan.x}px, ${state.pan.y}px) scale(${state.zoom})`;
// grid that pans/zooms with world: use background position/size
const gap = CONFIG.GRID_GAP * state.zoom;
gridEl.style.backgroundImage = `radial-gradient(circle at 1px 1px, ${CONFIG.COLORS.grid} 1.2px, transparent 0)`;
gridEl.style.backgroundSize = `${gap}px ${gap}px`;
gridEl.style.backgroundPosition = `${state.pan.x}px ${state.pan.y}px`;
$('#zoomLabel').textContent = Math.round(state.zoom*100)+'%';
$('#stZoom').textContent = Math.round(state.zoom*100)+'%';
renderWires(); // keep crisp
}
/* ================= PERSISTENCE ================= */
function save(){
const payload = {
nodes: state.nodes,
wires: state.wires,
nextId: state.nextId,
pan: state.pan,
zoom: state.zoom
};
localStorage.setItem(LS_KEY, JSON.stringify(payload));
}
function load(){
try{
const raw = localStorage.getItem(LS_KEY);
if(!raw) return false;
const data = JSON.parse(raw);
if(!data.nodes) return false;
state.nodes = data.nodes;
state.wires = data.wires || [];
state.nextId = data.nextId || (Math.max(0,...state.nodes.map(n=>parseInt(n.id.slice(1))||0))+1);
state.pan = data.pan || state.pan;
state.zoom = data.zoom || 1;
return true;
}catch(e){ return false; }
}
/* ================= EVALUATION ENGINE ================= */
function evaluate(){
const t0 = performance.now();
// build maps
const byId = new Map(state.nodes.map(n=>[n.id,n]));
// incoming wires per node port
const incoming = new Map(); // key `${toId}:${toPort}` -> wire
state.wires.forEach(w=>{
incoming.set(w.to+':'+w.toPort, w);
});
// adjacency for topo
const adj = new Map(); // fromId -> [toId]
const indeg = new Map();
state.nodes.forEach(n=>{ adj.set(n.id,[]); indeg.set(n.id,0); });
state.wires.forEach(w=>{
adj.get(w.from).push(w.to);
indeg.set(w.to, (indeg.get(w.to)||0)+1);
});
// Kahn
const q = [];
indeg.forEach((d,id)=>{ if(d===0) q.push(id); });
const topo=[];
while(q.length){
const id=q.shift(); topo.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 somehow present, fall back to nodes order
const order = topo.length===state.nodes.length ? topo : state.nodes.map(n=>n.id);
// values: node output boolean (for gates/inputs), for outputs store input capture
const outVal = new Map(); // id -> boolean (output)
const inputVals = new Map(); // id -> array of booleans per input port
// helper to get input bool for a node/port
function getInput(nodeId, portIdx){
const w = incoming.get(nodeId+':'+portIdx);
if(!w) return false;
const src = byId.get(w.from);
if(!src) return false;
// source output is outVal if computed, otherwise src.state
if(outVal.has(w.from)) return !!outVal.get(w.from);
// for order not yet computed (should not happen) fallback to false
return !!src.state;
}
for(const id of order){
const n = byId.get(id);
const def = defOf(n.type);
const ins = [];
for(let i=0;i<def.ports.in;i++) ins.push( getInput(id,i) );
inputVals.set(id, ins);
let out = false;
switch(n.type){
case 'TOGGLE': out = !!n.state; break;
case 'CLOCK': out = !!n.state; break;
case 'AND': out = (ins[0] && ins[1]); break;
case 'OR': out = (ins[0] || ins[1]); break;
case 'NOT': out = !ins[0]; break;
case 'XOR': out = (!!ins[0] !== !!ins[1]); break;
case 'NAND': out = !(ins[0] && ins[1]); break;
case 'NOR': out = !(ins[0] || ins[1]); break;
case 'LAMP': n._in = ins[0]; out = ins[0]; break;
case 'HEX': n._ins = ins.slice(); // 4 bits
// value computed for display
out = false; break;
default: out=false;
}
if(def.ports.out>0) outVal.set(id, out);
// stash for rendering
n._out = out;
n._ins = ins;
}
// wire energized
state.wires.forEach(w=>{
w._on = !!outVal.get(w.from);
});
const dt = performance.now()-t0;
$('#stEval').textContent = dt.toFixed(1)+' ms';
// truth log for demo
updateTruthLog(outVal, inputVals);
return outVal;
}
function updateTruthLog(outVal, inputVals){
const el = $('#truthLog');
if(!el) return;
// show last lamp/hex states
const lines=[];
state.nodes.filter(n=>n.type==='LAMP' || n.type==='HEX').forEach(n=>{
if(n.type==='LAMP') lines.push(`${n.name}: ${n._in? '● ON':'○ OFF'}`);
if(n.type==='HEX'){
const bits = (n._ins||[]).map(b=>b?1:0);
// bits[0]=LSB? we treat port0 = LSB (2^0) at bottom? Use port0 = 1, port3=8
const v = (bits[0]?1:0)|(bits[1]?2:0)|(bits[2]?4:0)|(bits[3]?8:0);
lines.push(`${n.name}: ${v.toString(16).toUpperCase()} (0b${bits.slice().reverse().join('')})`);
}
});
if(lines.length===0) lines.push('Add a Lamp or Hex to see values.');
el.innerHTML = lines.join('<br>');
}
/* ================= CYCLE DETECTION ================= */
function wouldCreateCycle(fromId, toId){
if(fromId===toId) return true;
// does there exist path from toId -> fromId ?
const adj = new Map();
state.nodes.forEach(n=>adj.set(n.id,[]));
state.wires.forEach(w=> adj.get(w.from).push(w.to));
// DFS
const stack=[toId], seen=new Set([toId]);
while(stack.length){
const cur=stack.pop();
if(cur===fromId) return true;
for(const nb of adj.get(cur)||[]){
if(!seen.has(nb)){ seen.add(nb); stack.push(nb); }
}
}
return false;
}
/* ================= RENDERING ================= */
function portWorldPos(node, dir, idx){
// node x,y is top-left of card in world coords. Port positions: left edge middle + offset per port
// Inputs distributed vertically, outputs single centered unless multiple? All nodes have 1 out max.
const W=148, H = cardHeight(node);
if(dir==='input'){
const n = defOf(node.type).ports.in;
const start = 38; // below header
const step = n>1 ? 22 : 0;
const cy = n===1 ? H/2 + 8 : start + idx*step + 8;
return {x: node.x, y: node.y + cy};
} else {
// output on right middle
return {x: node.x+W, y: node.y + H/2 + 8};
}
}
function cardHeight(node){
// approximate based on type
if(node.type==='TOGGLE' || node.type==='CLOCK') return 96;
if(node.type==='HEX') return 118;
if(node.type==='LAMP') return 96;
return 86;
}
function renderNodes(){
nodeLayer.innerHTML='';
// sort selected on top
const sorted = [...state.nodes].sort((a,b)=> (a.id===state.selectedId)-(b.id===state.selectedId));
for(const n of sorted){
const def = defOf(n.type);
const el = document.createElement('div');
el.className = 'node'+(state.selectedId===n.id?' selected':'') + (n._out? ' live':'');
el.style.left = n.x+'px';
el.style.top = n.y+'px';
el.dataset.id = n.id;
const tagCls = def.tag==='in'?'t-in':def.tag==='out'?'t-out':'t-gate';
const icon = n.type==='AND'?'∧':n.type==='OR'?'∨':n.type==='NOT'?'¬':n.type==='XOR'?'⊕':n.type==='NAND'?'⊼':n.type==='NOR'?'⊽':n.type==='TOGGLE'?'◯':n.type==='CLOCK'?'◷':n.type==='LAMP'?'●':'#';
const ledOn = n.type==='LAMP' ? !!n._in : (def.ports.out? !!n._out : !!n.state);
const ledCls = n.type==='TOGGLE' || n.type==='CLOCK' ? 'blue' : '';
el.innerHTML = `
<div class="node-head">
<div class="node-type ${tagCls}">${icon}</div>
<div class="node-title"><strong>${n.name}</strong><span>${n.type}</span></div>
<div class="led ${ledCls} ${ledOn?'on':''}"></div>
</div>
<div class="node-body">
${n.type==='TOGGLE' ? `
<button class="toggle-btn ${n.state?'on':'off'}" data-toggle="${n.id}">${n.state?'● ON':'○ OFF'}</button>
`:''}
${n.type==='CLOCK' ? `
<div class="clock-row">
<select data-clock="${n.id}">
${CONFIG.CLOCK_RATES.map(r=>`<option value="${r.ms}" ${r.ms===n.rate?'selected':''}>${r.label}</option>`).join('')}
</select>
<span style="font-size:11px;color:var(--muted)">${n.state?'◉':'○'}</span>
</div>
`:''}
${def.tag==='gate' ? `<div class="gate-label">${gateFormula(n.type)}</div>`:''}
${n.type==='LAMP' ? `
<div class="lamp ${n._in?'on':''}"><div class="lamp-dot"></div></div>
`:''}
${n.type==='HEX' ? `
<div class="seg">
<div class="seg-char ${hexVal(n)>=0?'on':''}">${hexChar(hexVal(n))}</div>
<div class="seg-bits">${(n._ins||[0,0,0,0]).slice().reverse().map(b=>b?'1':'0').join(' ')} • ${hexVal(n).toString(16).toUpperCase()}</div>
</div>
`:''}
</div>
`;
// ports
for(let i=0;i<def.ports.in;i++){
const p = document.createElement('div');
p.className='port input'+(hasIncoming(n.id,i)?' occupied':'');
const pos = portWorldPos(n,'input',i);
// position absolute relative to node element: left -7, top = pos.y - n.y
p.style.top = (pos.y - n.y - 7)+'px';
p.dataset.node = n.id; p.dataset.dir='input'; p.dataset.port=i;
el.appendChild(p);
// label
const lab = document.createElement('div');
lab.className='port-label';
lab.style.top = (pos.y - n.y)+'px';
lab.style.left='10px';
lab.textContent = n.type==='HEX' ? ['1','2','4','8'][i] : ['A','B','C','D'][i];
el.appendChild(lab);
}
for(let i=0;i<def.ports.out;i++){
const p=document.createElement('div');
p.className='port output';
const pos = portWorldPos(n,'output',i);
p.style.top = (pos.y - n.y -7)+'px';
p.dataset.node=n.id; p.dataset.dir='output'; p.dataset.port=i;
el.appendChild(p);
const lab=document.createElement('div');
lab.className='port-label';
lab.style.top=(pos.y - n.y)+'px';
lab.style.right='10px';
lab.style.left='auto';
lab.textContent='OUT';
el.appendChild(lab);
}
nodeLayer.appendChild(el);
}
$('#stNodes').textContent = state.nodes.length;
}
function hasIncoming(toId, port){ return state.wires.some(w=>w.to===toId && w.toPort===port); }
function gateFormula(t){
return t==='AND'?'A · B':t==='OR'?'A + B':t==='NOT'?'¬A':t==='XOR'?'A ⊕ B':t==='NAND'?'¬(A·B)':t==='NOR'?'¬(A+B)':'';
}
function hexVal(node){
const ins = node._ins||[false,false,false,false];
return (ins[0]?1:0)|(ins[1]?2:0)|(ins[2]?4:0)|(ins[3]?8:0);
}
function hexChar(v){ return v.toString(16).toUpperCase(); }
function bezierPath(x1,y1,x2,y2){
const dx = Math.abs(x2-x1);
const cx = Math.max(60, dx*0.55);
return `M ${x1} ${y1} C ${x1+cx} ${y1}, ${x2-cx} ${y2}, ${x2} ${y2}`;
}
function renderWires(){
// size svg to cover large world area: use bounding box of nodes + viewport
// Simpler: set svg size huge and draw in world coords (world transform will scale)
// We'll set svg attributes to cover 4000x4000 around center
wireLayer.setAttribute('width', 6000);
wireLayer.setAttribute('height', 6000);
wireLayer.style.left = '-3000px';
wireLayer.style.top = '-3000px';
wireLayer.setAttribute('viewBox','-3000 -3000 6000 6000');
// Actually easier: draw with world coords directly with offset 3000
// We'll just draw paths with coordinates shifted by +3000
const off=3000;
wireLayer.innerHTML='';
const defs = document.createElementNS('http://www.w3.org/2000/svg','defs');
defs.innerHTML = `
<filter id="glowOn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="#f59e0b" flood-opacity="0.8"/></filter>
<filter id="glowOff"><feDropShadow dx="0" dy="0" stdDeviation="0" flood-color="#000" flood-opacity="0"/></filter>
`;
wireLayer.appendChild(defs);
for(const w of state.wires){
const fromNode = state.nodes.find(n=>n.id===w.from);
const toNode = state.nodes.find(n=>n.id===w.to);
if(!fromNode||!toNode) continue;
const p1 = portWorldPos(fromNode,'output',w.fromPort);
const p2 = portWorldPos(toNode,'input',w.toPort);
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
// check flash error
const isErr = state.flashErr && state.flashErr.from===w.from && state.flashErr.to===w.to && Date.now()<state.flashErr.until;
path.setAttribute('d', bezierPath(p1.x+off, p1.y+off, p2.x+off, p2.y+off));
path.setAttribute('fill','none');
path.setAttribute('stroke', isErr? CONFIG.COLORS.wireErr : (w._on? CONFIG.COLORS.wireOn: CONFIG.COLORS.wireOff));
path.setAttribute('stroke-width', w._on? '3.2':'2.4');
path.setAttribute('stroke-linecap','round');
path.setAttribute('opacity', w._on? '1':'0.95');
if(w._on) path.setAttribute('filter','url(#glowOn)');
// hit area wider transparent path
wireLayer.appendChild(path);
// small dots at ends
const c1=document.createElementNS('http://www.w3.org/2000/svg','circle');
c1.setAttribute('cx',p1.x+off); c1.setAttribute('cy',p1.y+off); c1.setAttribute('r', w._on? '3.2':'2.2');
c1.setAttribute('fill', w._on? '#f59e0b':'#475569');
wireLayer.appendChild(c1);
}
// preview wire
if(state.wiring){
const fromNode = state.nodes.find(n=>n.id===state.wiring.fromId);
if(fromNode){
const p1 = portWorldPos(fromNode,'output',state.wiring.fromPort);
const p2 = {x: state.wiring.curX, y: state.wiring.curY};
const isOverValid = state.wiring.overPort && state.wiring.overPort.dir==='input';
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
path.setAttribute('d', bezierPath(p1.x+off,p1.y+off,p2.x+off,p2.y+off));
path.setAttribute('fill','none');
path.setAttribute('stroke', isOverValid? '#38bdf8' : '#94a3b8');
path.setAttribute('stroke-width','2.6');
path.setAttribute('stroke-dasharray','8 6');
path.setAttribute('opacity','0.9');
path.setAttribute('stroke-linecap','round');
wireLayer.appendChild(path);
// target highlight circle
if(state.wiring.overPort){
const tn = state.nodes.find(n=>n.id===state.wiring.overPort.nodeId);
if(tn){
const tp = portWorldPos(tn, 'input', state.wiring.overPort.port);
const cc=document.createElementNS('http://www.w3.org/2000/svg','circle');
cc.setAttribute('cx',tp.x+off); cc.setAttribute('cy',tp.y+off); cc.setAttribute('r','10');
cc.setAttribute('fill','none'); cc.setAttribute('stroke','#38bdf8'); cc.setAttribute('stroke-width','1.6'); cc.setAttribute('opacity','0.9');
wireLayer.appendChild(cc);
}
}
}
}
// error flash wire preview
if(state.flashErr && Date.now()<state.flashErr.until){
// find nodes
const fN = state.nodes.find(n=>n.id===state.flashErr.from);
const tN = state.nodes.find(n=>n.id===state.flashErr.to);
if(fN&&tN){
const p1=portWorldPos(fN,'output',0), p2=portWorldPos(tN,'input',state.flashErr.toPort||0);
const path=document.createElementNS('http://www.w3.org/2000/svg','path');
path.setAttribute('d', bezierPath(p1.x+off,p1.y+off,p2.x+off,p2.y+off));
path.setAttribute('fill','none'); path.setAttribute('stroke','#ef4444'); path.setAttribute('stroke-width','4'); path.setAttribute('opacity','0.95');
path.setAttribute('stroke-linecap','round');
wireLayer.appendChild(path);
}
}
$('#stWires').textContent = state.wires.length;
}
/* ================= NODE CREATION ================= */
function createNode(type, x, y){
const id=uid();
const baseName = type==='TOGGLE'?'Toggle':type==='CLOCK'?'Clock':type==='LAMP'?'Lamp':type==='HEX'?'Hex':type;
const name = baseName+' '+(state.nodes.filter(n=>n.type===type).length+1);
const node={id, type, x: Math.round(x), y: Math.round(y), name, state:false, rate:1000, _out:false};
if(type==='TOGGLE') node.state=false;
if(type==='CLOCK') node.state=false;
state.nodes.push(node);
state.selectedId=id;
if(type==='CLOCK') startClock(node);
evaluate(); renderNodes(); renderWires(); save();
}
function addNodeAtCenter(type){
const center = screenToWorld(viewport.clientWidth/2 + viewport.getBoundingClientRect().left, viewport.clientHeight/2 + viewport.getBoundingClientRect().top);
// But screenToWorld expects client coords. So compute directly:
const wcx = (viewport.clientWidth/2 - state.pan.x)/state.zoom;
const wcy = (viewport.clientHeight/2 - state.pan.y)/state.zoom;
createNode(type, wcx-74, wcy-40);
}
/* ================= CLOCKS ================= */
const clockTimers = new Map();
function startClock(node){
stopClock(node.id);
const ms = node.rate||1000;
const id = setInterval(()=>{
node.state = !node.state;
evaluate(); renderNodes(); renderWires(); save();
}, ms);
clockTimers.set(node.id, id);
}
function stopClock(id){
if(clockTimers.has(id)){ clearInterval(clockTimers.get(id)); clockTimers.delete(id); }
}
function restartAllClocks(){
clockTimers.forEach((v,k)=>clearInterval(v)); clockTimers.clear();
state.nodes.filter(n=>n.type==='CLOCK').forEach(n=>startClock(n));
}
/* ================= INTERACTIONS ================= */
let lastMouseWorld={x:0,y:0};
viewport.addEventListener('mousedown', e=>{
const target = e.target;
const portEl = target.closest('.port');
const nodeEl = target.closest('.node');
const isPalette = target.closest('#palette');
if(isPalette) return;
if(portEl){
e.preventDefault(); e.stopPropagation();
const nodeId = portEl.dataset.node;
const dir = portEl.dataset.dir;
const port = parseInt(portEl.dataset.port);
if(dir==='output'){
// start wiring from output
const wp = screenToWorld(e.clientX, e.clientY);
state.wiring = {fromId:nodeId, fromPort:port, curX:wp.x, curY:wp.y, overPort:null};
viewport.classList.add('grab-wire');
} else if(dir==='input'){
// if occupied, pick up existing wire to re-route
const existing = state.wires.find(w=>w.to===nodeId && w.toPort===port);
if(existing){
// remove wire, start from its source
const fromId = existing.from, fromPort=existing.fromPort;
state.wires = state.wires.filter(w=>!(w.to===nodeId && w.toPort===port));
evaluate(); renderWires(); save();
const wp = screenToWorld(e.clientX, e.clientY);
state.wiring = {fromId, fromPort, curX:wp.x, curY:wp.y, overPort:null};
viewport.classList.add('grab-wire');
} else {
// could allow drag from input? ignore
}
}
return;
}
if(nodeEl){
// check if toggle/clock controls clicked
if(target.closest('[data-toggle]') || target.closest('[data-clock]')) return;
const id = nodeEl.dataset.id;
state.selectedId=id;
renderNodes(); updateInspector();
// start dragging node
const n = state.nodes.find(nn=>nn.id===id);
const wp = screenToWorld(e.clientX, e.clientY);
state.draggingNode = {id, dx: wp.x - n.x, dy: wp.y - n.y};
e.preventDefault();
return;
}
// empty space -> pan
state.panning = {sx:e.clientX, sy:e.clientY, ox:state.pan.x, oy:state.pan.y};
viewport.classList.add('panning');
});
window.addEventListener('mousemove', e=>{
const wp = screenToWorld(e.clientX, e.clientY);
lastMouseWorld = wp;
if(state.draggingNode){
const n = state.nodes.find(nn=>nn.id===state.draggingNode.id);
if(n){
n.x = Math.round(wp.x - state.draggingNode.dx);
n.y = Math.round(wp.y - state.draggingNode.dy);
renderNodes(); renderWires();
}
return;
}
if(state.panning){
state.pan.x = state.panning.ox + (e.clientX - state.panning.sx);
state.pan.y = state.panning.oy + (e.clientY - state.panning.sy);
applyTransform();
return;
}
if(state.wiring){
state.wiring.curX = wp.x;
state.wiring.curY = wp.y;
// find port under cursor within tolerance
const over = findPortNear(e.clientX, e.clientY);
// only allow input ports as target, not same node
if(over && over.dir==='input' && over.nodeId!==state.wiring.fromId){
state.wiring.overPort = over;
// highlight
document.querySelectorAll('.port').forEach(p=>p.classList.remove('highlight'));
const sel = document.querySelector(`.port[data-node="${over.nodeId}"][data-dir="input"][data-port="${over.port}"]`);
if(sel) sel.classList.add('highlight');
} else {
state.wiring.overPort=null;
document.querySelectorAll('.port').forEach(p=>p.classList.remove('highlight'));
}
renderWires();
}
});
window.addEventListener('mouseup', e=>{
document.querySelectorAll('.port').forEach(p=>p.classList.remove('highlight'));
if(state.draggingNode){
state.draggingNode=null;
save();
}
if(state.panning){
state.panning=null;
viewport.classList.remove('panning');
save();
}
if(state.wiring){
viewport.classList.remove('grab-wire');
const over = state.wiring.overPort;
if(over && over.dir==='input'){
// validate and create wire
const fromId = state.wiring.fromId;
const toId = over.nodeId;
const toPort = over.port;
// type check: from has output
if(wouldCreateCycle(fromId, toId)){
// reject with red flash
state.flashErr = {from:fromId, to:toId, toPort, until: Date.now()+900};
toast('⛔ Cycle rejected — combinational only');
renderWires();
setTimeout(()=>{ state.flashErr=null; renderWires(); }, 920);
} else {
// remove existing wire on that input
state.wires = state.wires.filter(w=>!(w.to===toId && w.toPort===toPort));
state.wires.push({from:fromId, fromPort:state.wiring.fromPort, to:toId, toPort});
evaluate(); renderNodes(); renderWires(); save();
}
}
state.wiring=null;
renderWires();
}
});
// wheel zoom to cursor
viewport.addEventListener('wheel', e=>{
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const worldX = (mx - state.pan.x)/state.zoom;
const worldY = (my - state.pan.y)/state.zoom;
let newZoom = state.zoom * (e.deltaY < 0 ? 1.08 : 0.92);
newZoom = Math.min(CONFIG.ZOOM_MAX, Math.max(CONFIG.ZOOM_MIN, newZoom));
// snap to 0.1 steps? keep smooth
newZoom = Math.round(newZoom*100)/100;
state.pan.x = mx - worldX*newZoom;
state.pan.y = my - worldY*newZoom;
state.zoom = newZoom;
applyTransform();
save();
},{passive:false});
function findPortNear(clientX, clientY){
// check all ports screen distance
let best=null, bestDist=CONFIG.HIT_TOLERANCE_PX;
for(const n of state.nodes){
const def = defOf(n.type);
for(let i=0;i<def.ports.in;i++){
const wp = portWorldPos(n,'input',i);
const sp = worldToScreen(wp.x, wp.y);
const rect = viewport.getBoundingClientRect();
const sx = rect.left + sp.x;
const sy = rect.top + sp.y;
const d = Math.hypot(sx - clientX, sy - clientY);
if(d < bestDist){ bestDist=d; best={nodeId:n.id, dir:'input', port:i}; }
}
// outputs not needed as targets, but for completeness
}
return best;
}
/* node body interactions (toggle, clock select) */
nodeLayer.addEventListener('click', e=>{
const t = e.target.closest('[data-toggle]');
if(t){
const id=t.dataset.toggle;
const n=state.nodes.find(nn=>nn.id===id);
if(n){ n.state=!n.state; evaluate(); renderNodes(); renderWires(); save(); }
return;
}
});
nodeLayer.addEventListener('change', e=>{
const s=e.target.closest('[data-clock]');
if(s){
const id=s.dataset.clock;
const n=state.nodes.find(nn=>nn.id===id);
if(n){ n.rate=parseInt(s.value); startClock(n); save(); }
}
});
nodeLayer.addEventListener('mousedown', e=>{
const nodeEl=e.target.closest('.node');
if(nodeEl && !e.target.closest('.port')){
// select
state.selectedId=nodeEl.dataset.id;
renderNodes(); updateInspector();
}
});
/* keyboard */
window.addEventListener('keydown', e=>{
if((e.key==='Delete' || e.key==='Backspace') && state.selectedId){
// avoid deleting when typing in select
if(e.target.tagName==='SELECT' || e.target.tagName==='INPUT') return;
const id=state.selectedId;
stopClock(id);
state.nodes = state.nodes.filter(n=>n.id!==id);
state.wires = state.wires.filter(w=>w.from!==id && w.to!==id);
state.selectedId=null;
evaluate(); renderNodes(); renderWires(); updateInspector(); save();
}
});
/* palette */
document.querySelectorAll('[data-add]').forEach(b=>{
b.addEventListener('click', ()=> addNodeAtCenter(b.dataset.add));
});
/* zoom buttons */
$('#zoomIn').addEventListener('click', ()=>{
const mx=viewport.clientWidth/2, my=viewport.clientHeight/2;
const wx=(mx - state.pan.x)/state.zoom, wy=(my - state.pan.y)/state.zoom;
let nz=Math.min(CONFIG.ZOOM_MAX, Math.round((state.zoom*1.2)*100)/100);
state.pan.x=mx - wx*nz; state.pan.y=my - wy*nz; state.zoom=nz; applyTransform(); save();
});
$('#zoomOut').addEventListener('click', ()=>{
const mx=viewport.clientWidth/2, my=viewport.clientHeight/2;
const wx=(mx - state.pan.x)/state.zoom, wy=(my - state.pan.y)/state.zoom;
let nz=Math.max(CONFIG.ZOOM_MIN, Math.round((state.zoom/1.2)*100)/100);
state.pan.x=mx - wx*nz; state.pan.y=my - wy*nz; state.zoom=nz; applyTransform(); save();
});
$('#zoomFit').addEventListener('click', ()=>{
if(state.nodes.length===0){ state.pan={x:60,y:80}; state.zoom=1; applyTransform(); save(); return; }
let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
state.nodes.forEach(n=>{ minX=Math.min(minX,n.x); minY=Math.min(minY,n.y); maxX=Math.max(maxX,n.x+148); maxY=Math.max(maxY,n.y+cardHeight(n)); });
const pad=80;
const vw=viewport.clientWidth - pad*2, vh=viewport.clientHeight - pad*2;
const bw=maxX-minX, bh=maxY-minY;
let nz=Math.min(CONFIG.ZOOM_MAX, Math.max(CONFIG.ZOOM_MIN, Math.min(vw/bw, vh/bh)));
nz=Math.round(nz*100)/100;
state.zoom=nz;
state.pan.x = (viewport.clientWidth - bw*nz)/2 - minX*nz;
state.pan.y = (viewport.clientHeight - bh*nz)/2 - minY*nz;
applyTransform(); save();
});
$('#btnClear').addEventListener('click', ()=>{
if(!confirm('Clear entire circuit?')) return;
state.nodes.forEach(n=>stopClock(n.id));
state.nodes=[]; state.wires=[]; state.selectedId=null; state.nextId=1;
evaluate(); renderNodes(); renderWires(); updateInspector(); save();
});
$('#btnDemo').addEventListener('click', ()=> loadDemo(true));
function updateInspector(){
const el=$('#selCard');
if(!state.selectedId){ el.innerHTML=`<h3>No selection</h3><p>Click a node to inspect. Drag ports to wire.</p><div class="wire-legend"><i style="background:#f59e0b;box-shadow:0 0 8px #f59e0b"></i> ON <i style="background:#334155"></i> OFF</div>`; return; }
const n=state.nodes.find(x=>x.id===state.selectedId);
if(!n) return;
const ins = (n._ins||[]).map(b=>b?'1':'0').join(' ');
const out = n._out? '1':'0';
el.innerHTML = `
<h3>${n.name} <span style="color:var(--muted);font-weight:600">— ${n.type}</span></h3>
<p>Position: ${n.x}, ${n.y} • Inputs: [${ins||'—'}] • Out: ${out}</p>
<div style="margin-top:8px;display:flex;gap:6px">
<button class="btn" id="dupBtn">Duplicate</button>
<button class="btn" id="delBtn" style="background:#450a0a;border-color:#7f1d1d">Delete</button>
</div>
`;
el.querySelector('#delBtn').addEventListener('click', ()=>{
stopClock(n.id);
state.nodes=state.nodes.filter(x=>x.id!==n.id);
state.wires=state.wires.filter(w=>w.from!==n.id && w.to!==n.id);
state.selectedId=null; evaluate(); renderNodes(); renderWires(); updateInspector(); save();
});
el.querySelector('#dupBtn').addEventListener('click', ()=>{
createNode(n.type, n.x+24, n.y+24);
// copy state/rate
const nn=state.nodes[state.nodes.length-1];
nn.state=n.state; nn.rate=n.rate;
if(nn.type==='CLOCK') startClock(nn);
evaluate(); renderNodes(); renderWires(); save();
});
}
function toast(msg){
const t=$('#toast'); t.textContent=msg; t.classList.add('show');
clearTimeout(t._id); t._id=setTimeout(()=>t.classList.remove('show'), 1800);
}
/* ================= DEMO CIRCUIT ================= */
function loadDemo(force=false){
if(!force && state.nodes.length>0) return;
// clear existing clocks
state.nodes.forEach(n=>stopClock(n.id));
state.nodes=[]; state.wires=[]; state.nextId=1;
// Build: Toggle → AND(1) with Toggle → NOT → Lamp ; plus extra chain to test 3-gate
// Positions
createNode('TOGGLE', 60, 80); // n1
createNode('TOGGLE', 60, 220); // n2
createNode('AND', 260, 120); // n3
createNode('NOT', 460, 130); // n4
createNode('LAMP', 640, 130); // n5
createNode('OR', 260, 300); // n6
createNode('HEX', 460, 300); // n7
createNode('CLOCK', 60, 380); // n8
// set states
state.nodes[0].state=true;
state.nodes[1].state=false;
// wires: T1->AND A, T2->AND B, AND->NOT, NOT->LAMP, T1->OR A, T2->OR B, OR->HEX bit0?, clock->hex bit1 etc
// simpler: OR -> HEX bit0, AND -> HEX bit1, NOT->HEX bit2, CLOCK->HEX bit3
const ids = state.nodes.map(n=>n.id);
const [t1,t2,and,not,lamp,or,hex,clk]=ids;
state.wires=[
{from:t1, fromPort:0, to:and, toPort:0},
{from:t2, fromPort:0, to:and, toPort:1},
{from:and, fromPort:0, to:not, toPort:0},
{from:not, fromPort:0, to:lamp, toPort:0},
{from:t1, fromPort:0, to:or, toPort:0},
{from:t2, fromPort:0, to:or, toPort:1},
{from:or, fromPort:0, to:hex, toPort:0},
{from:and, fromPort:0, to:hex, toPort:1},
{from:not, fromPort:0, to:hex, toPort:2},
{from:clk, fromPort:0, to:hex, toPort:3},
];
state.pan={x:40,y:30}; state.zoom=1;
evaluate(); renderNodes(); renderWires(); updateInspector(); save();
// fit
setTimeout(()=> $('#zoomFit').click(), 50);
}
/* ================= INIT ================= */
function init(){
const had = load();
if(!had){
loadDemo(false);
} else {
evaluate();
restartAllClocks();
}
renderNodes(); renderWires(); applyTransform(); updateInspector();
// handle resize
window.addEventListener('resize', applyTransform);
// click on viewport background to deselect
viewport.addEventListener('mousedown', e=>{
if(e.target===viewport || e.target===world || e.target===gridEl){
state.selectedId=null; renderNodes(); updateInspector();
}
});
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"a1a0135c-5a65-4a92-811a-af4239820e77","tokensIn":1144,"tokensOut":17454,"tokensTotal":18598,"cost":0.0756095,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:21:07.039Z"} -->