Node Graph Editor
Qwen 3.6 27b MTP · typescript

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 71,377 bytes · SHA-256 fd0f2718bcdb
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logic Circuit Node-Graph Editor</title>
<style>
/* ── css: layout ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #1a1d23;
color: #d0d4dc;
}
body {
display: flex;
}
/* ─── Sidebar ─────────────────────────────────────────────── */
#sidebar {
width: 200px;
min-width: 200px;
background: #1e2128;
border-right: 1px solid #2a2d35;
display: flex;
flex-direction: column;
padding: 12px;
overflow-y: auto;
z-index: 10;
}
#sidebar-title {
font-size: 14px;
font-weight: 700;
color: #8a9ab5;
text-transform: uppercase;
letter-spacing: 1.5px;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #2a2d35;
}
.section-label {
font-size: 10px;
font-weight: 600;
color: #5b6a80;
text-transform: uppercase;
letter-spacing: 1.2px;
margin-top: 14px;
margin-bottom: 6px;
}
.node-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
margin-bottom: 3px;
background: #252830;
border: 1px solid #3a3d47;
border-radius: 6px;
color: #c0c4cc;
font-size: 12px;
cursor: grab;
transition: all 0.15s ease;
user-select: none;
}
.node-btn:hover {
background: #2e3140;
border-color: #4a9eff;
color: #fff;
transform: translateX(2px);
}
.node-btn:active {
cursor: grabbing;
transform: scale(0.97);
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #1a1d23;
border-radius: 4px;
font-size: 10px;
color: #6b7080;
}
#sidebar-help {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #2a2d35;
font-size: 10px;
color: #4a5060;
line-height: 1.6;
}
#sidebar-help p {
margin-bottom: 4px;
color: #5b6a80;
}
#sidebar-help ul {
list-style: none;
padding-left: 0;
}
#sidebar-help li::before {
content: '› ';
color: #3a3d47;
}
/* ─── Canvas Container ────────────────────────────────────── */
#canvas-container {
flex: 1;
position: relative;
overflow: hidden;
min-width: 0;
}
#graph-canvas {
display: block;
width: 100%;
height: 100%;
}
/* ─── Zoom Indicator ──────────────────────────────────────── */
#zoom-indicator {
position: absolute;
bottom: 12px;
right: 16px;
background: rgba(30, 33, 40, 0.85);
border: 1px solid #2a2d35;
border-radius: 6px;
padding: 4px 10px;
font-size: 11px;
color: #5b6a80;
pointer-events: none;
transition: opacity 0.3s ease;
}
/* ─── Scrollbar Styling ───────────────────────────────────── */
#sidebar::-webkit-scrollbar {
width: 4px;
}
#sidebar::-webkit-scrollbar-track {
background: transparent;
}
#sidebar::-webkit-scrollbar-thumb {
background: #3a3d47;
border-radius: 2px;
}
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-title">Logic Circuit Editor</div>
<div class="section-label">Inputs</div>
<button class="node-btn" data-type="toggle"><span class="btn-icon">⏻</span> Toggle Switch</button>
<button class="node-btn" data-type="clock"><span class="btn-icon">◷</span> Clock</button>
<div class="section-label">Gates</div>
<button class="node-btn" data-type="and"><span class="btn-icon">&</span> AND</button>
<button class="node-btn" data-type="or"><span class="btn-icon">≥1</span> OR</button>
<button class="node-btn" data-type="not"><span class="btn-icon">!</span> NOT</button>
<button class="node-btn" data-type="xor"><span class="btn-icon">=1</span> XOR</button>
<button class="node-btn" data-type="nand"><span class="btn-icon">&!</span> NAND</button>
<button class="node-btn" data-type="nor"><span class="btn-icon">≥! </span> NOR</button>
<div class="section-label">Outputs</div>
<button class="node-btn" data-type="lamp"><span class="btn-icon">💡</span> Lamp</button>
<button class="node-btn" data-type="hex"><span class="btn-icon">HEX</span> Hex Display</button>
<div id="sidebar-help">
<p><strong>Controls:</strong></p>
<ul>
<li>Click sidebar items to add nodes</li>
<li>Drag sidebar items onto canvas</li>
<li>Click toggle nodes to flip state</li>
<li>Drag ports to create wires</li>
<li>Delete key removes selected node</li>
<li>Arrow keys move selected node</li>
<li>Scroll to zoom, drag bg to pan</li>
</ul>
</div>
</div>
<div id="canvas-container">
<canvas id="graph-canvas"></canvas>
<div id="zoom-indicator">100%</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./types":"src/types.ts","./state":"src/state.ts","./rendering":"src/rendering.ts","./interaction":"src/interaction.ts","./persistence":"src/persistence.ts","./evaluation":"src/evaluation.ts"},"src/state.ts":{"./types":"src/types.ts"},"src/evaluation.ts":{"./types":"src/types.ts","./state":"src/state.ts"},"src/rendering.ts":{"./types":"src/types.ts","./state":"src/state.ts"},"src/interaction.ts":{"./types":"src/types.ts","./state":"src/state.ts","./rendering":"src/rendering.ts","./evaluation":"src/evaluation.ts"},"src/persistence.ts":{"./types":"src/types.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const state_1 = require("./state");
const rendering_1 = require("./rendering");
const interaction_1 = require("./interaction");
const persistence_1 = require("./persistence");
const evaluation_1 = require("./evaluation");
// ─── Initialization ──────────────────────────────────────────
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
const zoomIndicator = document.getElementById('zoom-indicator');
// Load or create scene
let scene;
const saved = (0, persistence_1.loadScene)();
if (saved) {
scene = saved;
}
else {
scene = (0, state_1.createScene)();
}
// View transform
const view = { panX: 0, panY: 0, zoom: 1 };
// Create interaction manager
const interaction = new interaction_1.InteractionManager(canvas, scene, view);
// ─── Canvas Sizing ───────────────────────────────────────────
function resizeCanvas() {
const container = canvas.parentElement;
const dpr = window.devicePixelRatio || 1;
canvas.width = container.clientWidth * dpr;
canvas.height = container.clientHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Store logical size for rendering
canvas._logicalWidth = container.clientWidth;
canvas._logicalHeight = container.clientHeight;
}
window.addEventListener('resize', () => {
resizeCanvas();
render();
});
// ─── Render Loop ─────────────────────────────────────────────
function render() {
const w = canvas._logicalWidth || canvas.parentElement.clientWidth;
const h = canvas._logicalHeight || canvas.parentElement.clientHeight;
// Update hex displays before rendering
interaction.updateHexDisplays();
(0, rendering_1.renderScene)(ctx, scene, view, interaction.getSelectedNodeId(), interaction.getHoveredNodeId());
// Draw wire preview if dragging
interaction.drawWirePreview(ctx);
// Update zoom indicator
const pct = Math.round(view.zoom * 100);
zoomIndicator.textContent = `${pct}%`;
}
// Set up render callback
interaction.onRender = () => requestAnimationFrame(render);
// ─── Clock Ticking System ────────────────────────────────────
const clockTimers = new Map();
function startClockTicking() {
// Clear existing timers
for (const [, timerId] of clockTimers) {
clearInterval(timerId);
}
clockTimers.clear();
// Set up individual timers per clock node
for (const node of scene.nodes) {
if (node.type === 'clock') {
const rateIdx = node.clockRateIdx ?? 1;
const rates = [200, 500, 1000, 2000];
const rate = rates[rateIdx] || 500;
const timerId = setInterval(() => {
// Toggle clock state and re-evaluate downstream
node.state = !node.state;
interaction.updateHexDisplays();
render();
autoSave();
}, rate);
clockTimers.set(node.id, timerId);
}
}
}
// ─── Auto-Save ───────────────────────────────────────────────
let saveTimeout = null;
function autoSave() {
if (saveTimeout)
clearTimeout(saveTimeout);
saveTimeout = window.setTimeout(() => {
(0, persistence_1.saveScene)(scene);
}, 500);
}
// ─── Sidebar Drag-and-Drop ──────────────────────────────────
const nodeButtons = document.querySelectorAll('.node-btn');
let dragNodeType = null;
for (const btn of nodeButtons) {
const type = btn.getAttribute('data-type');
// Click to add at center of viewport
btn.addEventListener('click', () => {
const container = canvas.parentElement;
const cx = container.clientWidth / 2;
const cy = container.clientHeight / 2;
// Convert screen center to world coords, offset by pan/zoom
const [wx, wy] = [(cx - view.panX) / view.zoom, (cy - view.panY) / view.zoom];
// Center the node on the cursor position
const nw = 130;
const nh = 50;
interaction.addNode(type, wx - nw / 2, wy - nh / 2);
autoSave();
});
// Drag from sidebar to canvas
btn.addEventListener('mousedown', (e) => {
dragNodeType = type;
e.preventDefault();
});
}
// Handle drop on canvas from sidebar
canvas.addEventListener('mouseup', (e) => {
if (!dragNodeType)
return;
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
// Convert to world coords
const [wx, wy] = [(sx - view.panX) / view.zoom, (sy - view.panY) / view.zoom];
// Center the node on the drop position
const nw = 130;
const nh = 50;
interaction.addNode(dragNodeType, wx - nw / 2, wy - nh / 2);
dragNodeType = null;
autoSave();
});
// Also handle mouseup on document to catch drops outside canvas
document.addEventListener('mouseup', () => {
dragNodeType = null;
});
// ─── Toggle Node Click Handling (double-click on header) ─────
canvas.addEventListener('dblclick', (e) => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
// Check if double-clicking on a toggle node's body
const [wx, wy] = [(sx - view.panX) / view.zoom, (sy - view.panY) / view.zoom];
for (let i = scene.nodes.length - 1; i >= 0; i--) {
const node = scene.nodes[i];
if (node.type !== 'toggle')
continue;
// Check bounds (entire node body, not just header)
const h = (0, state_1.getNodeHeight)(node.type);
if (wx >= node.x && wx <= node.x + 130 && wy >= node.y && wy <= node.y + h) {
// Double-clicked toggle - flip state
node.state = !node.state;
interaction.updateHexDisplays();
render();
autoSave();
return;
}
}
// Check clock double-click to cycle rate
for (let i = scene.nodes.length - 1; i >= 0; i--) {
const node = scene.nodes[i];
if (node.type !== 'clock')
continue;
const h = (0, state_1.getNodeHeight)(node.type);
if (wx >= node.x && wx <= node.x + 130 && wy >= node.y && wy <= node.y + h) {
// Double-clicked clock - cycle rate
const rates = [200, 500, 1000, 2000];
node.clockRateIdx = ((node.clockRateIdx || 0) + 1) % rates.length;
// Restart clock timers since rate changed
startClockTicking();
render();
autoSave();
return;
}
}
});
// ─── Initial Setup ──────────────────────────────────────────
resizeCanvas();
// Evaluate the scene on load (in case it was restored)
(0, evaluation_1.evaluateScene)(scene);
interaction.updateHexDisplays();
autoSave(); // Save evaluated states back to localStorage
// Start clock ticking for any loaded clocks
startClockTicking();
// Initial render
render();
// Save initial state if new
if (!saved) {
autoSave();
}
console.log('Logic Circuit Editor initialized');
console.log(`Loaded ${scene.nodes.length} nodes, ${scene.wires.length} wires`);
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.HEX_SEGMENTS = exports.GATE_FNS = exports.NODE_DEFS = exports.CONSTANTS = void 0;
// ─── Tunable Constants ────────────────────────────────────────────────
exports.CONSTANTS = {
// Zoom
ZOOM_MIN: 0.5,
ZOOM_MAX: 2.0,
ZOOM_STEP: 1.08,
// Ports
PORT_RADIUS: 7,
PORT_HIT_TOLERANCE: 14, // in world coords; scaled by zoom for screen hit-testing
// Nodes
NODE_WIDTH: 130,
NODE_HEIGHT: 50,
NODE_HEADER_HEIGHT: 26,
NODE_CORNER_RADIUS: 8,
PORT_SPACING: 24,
// Colors
COLOR_BG: '#1a1d23',
COLOR_GRID_DOT: 'rgba(255,255,255,0.06)',
COLOR_NODE_BG: '#252830',
COLOR_NODE_BORDER: '#3a3d47',
COLOR_NODE_HEADER: '#2e3140',
COLOR_NODE_SELECTED: '#4a9eff',
COLOR_PORT_INPUT: '#5b8def',
COLOR_PORT_OUTPUT: '#ef6c5b',
COLOR_WIRE_OFF: 'rgba(100,120,160,0.35)',
COLOR_WIRE_ON: '#4aeaff',
COLOR_WIRE_GLOW: 'rgba(74,234,255,0.35)',
COLOR_TEXT: '#d0d4dc',
COLOR_TEXT_DIM: '#6b7080',
COLOR_LAMP_OFF: '#3a3d47',
COLOR_LAMP_ON: '#ffcc33',
COLOR_LAMP_GLOW: 'rgba(255,204,51,0.4)',
COLOR_CLOCK_PULSE: '#aa66ff',
// Wire bezier control arm length (world coords)
WIRE_ARM_LENGTH: 40,
// Clock rates (ms per tick)
CLOCK_RATES: [200, 500, 1000, 2000],
// Animation
ANIM_DURATION: 150, // ms for wire glow transitions
};
// ─── Node Definitions (ports, labels) ────────────────────────────────
exports.NODE_DEFS = {
toggle: {
label: 'Toggle',
ports: [{ name: 'out', direction: 'out' }],
},
clock: {
label: 'Clock',
ports: [{ name: 'clk', direction: 'out' }],
},
and: {
label: 'AND',
ports: [
{ name: 'a', direction: 'in' },
{ name: 'b', direction: 'in' },
{ name: 'out', direction: 'out' },
],
},
or: {
label: 'OR',
ports: [
{ name: 'a', direction: 'in' },
{ name: 'b', direction: 'in' },
{ name: 'out', direction: 'out' },
],
},
not: {
label: 'NOT',
ports: [
{ name: 'a', direction: 'in' },
{ name: 'out', direction: 'out' },
],
},
xor: {
label: 'XOR',
ports: [
{ name: 'a', direction: 'in' },
{ name: 'b', direction: 'in' },
{ name: 'out', direction: 'out' },
],
},
nand: {
label: 'NAND',
ports: [
{ name: 'a', direction: 'in' },
{ name: 'b', direction: 'in' },
{ name: 'out', direction: 'out' },
],
},
nor: {
label: 'NOR',
ports: [
{ name: 'a', direction: 'in' },
{ name: 'b', direction: 'in' },
{ name: 'out', direction: 'out' },
],
},
lamp: {
label: 'Lamp',
ports: [{ name: 'in', direction: 'in' }],
},
hex: {
label: 'Hex Display',
ports: [
{ name: 'd0', direction: 'in' }, // LSB
{ name: 'd1', direction: 'in' },
{ name: 'd2', direction: 'in' },
{ name: 'd3', direction: 'in' }, // MSB
],
},
};
exports.GATE_FNS = {
and: (a, b) => a && b,
or: (a, b) => a || b,
not: (a) => !a,
xor: (a, b) => a !== b,
nand: (a, b) => !(a && b),
nor: (a, b) => !(a || b),
};
// ─── Hex Segment Map ────────────────────────────────────────────────
// Each hex digit maps to which segments are on (7-segment display)
// Segments: a(top), b(top-right), c(bot-right), d(bot), e(bot-left), f(top-left), g(mid)
exports.HEX_SEGMENTS = {
0: [1, 1, 1, 1, 1, 1, 0],
1: [0, 1, 1, 0, 0, 0, 0],
2: [1, 1, 0, 1, 1, 0, 1],
3: [1, 1, 1, 1, 0, 0, 1],
4: [0, 1, 1, 0, 0, 1, 1],
5: [1, 0, 1, 1, 0, 1, 1],
6: [1, 0, 1, 1, 1, 1, 1],
7: [1, 1, 1, 0, 0, 0, 0],
8: [1, 1, 1, 1, 1, 1, 1],
9: [1, 1, 1, 1, 0, 1, 1],
10: [1, 1, 1, 0, 1, 1, 1], // A
11: [0, 0, 1, 1, 1, 1, 1], // b
12: [1, 0, 0, 1, 1, 1, 0], // C
13: [0, 1, 1, 1, 1, 0, 1], // d
14: [1, 0, 0, 1, 1, 1, 1], // E
15: [1, 0, 0, 0, 1, 1, 1], // F
};
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createScene = createScene;
exports.newNode = newNode;
exports.addNode = addNode;
exports.removeNode = removeNode;
exports.removeWire = removeWire;
exports.addWire = addWire;
exports.getNodeHeight = getNodeHeight;
exports.getPortPosition = getPortPosition;
exports.getAllPortPositions = getAllPortPositions;
exports.isGate = isGate;
exports.isInputNode = isInputNode;
exports.getInputWire = getInputWire;
exports.getOutputWires = getOutputWires;
const types_1 = require("./types");
let idCounter = 1;
function createScene() {
return { nodes: [], wires: [], nextId: idCounter };
}
function newNode(type, x, y) {
const node = {
id: `n${idCounter++}`,
type,
x,
y,
state: false,
};
if (type === 'clock') {
node.clockRateIdx = 1; // default 500ms
}
return node;
}
function addNode(scene, type, x, y) {
const node = newNode(type, x, y);
scene.nodes.push(node);
return node;
}
function removeNode(scene, nodeId) {
scene.nodes = scene.nodes.filter(n => n.id !== nodeId);
scene.wires = scene.wires.filter(w => w.fromNodeId !== nodeId && w.toNodeId !== nodeId);
}
function removeWire(scene, wireId) {
scene.wires = scene.wires.filter(w => w.id !== wireId);
}
function addWire(scene, fromNodeId, fromPortName, toNodeId, toPortName) {
// Remove existing wire on this input port (one wire per input)
scene.wires = scene.wires.filter(w => !(w.toNodeId === toNodeId && w.toPortName === toPortName));
const wire = {
id: `w${idCounter++}`,
fromNodeId,
fromPortName,
toNodeId,
toPortName,
};
scene.wires.push(wire);
return wire;
}
/** Get the world height of a node based on its port count */
function getNodeHeight(nodeType) {
const def = types_1.NODE_DEFS[nodeType];
if (!def)
return types_1.CONSTANTS.NODE_HEIGHT;
const inputPorts = def.ports.filter(p => p.direction === 'in');
const outputPorts = def.ports.filter(p => p.direction === 'out');
const maxPorts = Math.max(inputPorts.length, outputPorts.length);
// Minimum height for header + at least one port row
return types_1.CONSTANTS.NODE_HEADER_HEIGHT + Math.max(1, maxPorts) * types_1.CONSTANTS.PORT_SPACING - 4;
}
/** Get the world position of a port on a node */
function getPortPosition(node, portName) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
return null;
const idx = def.ports.findIndex(p => p.name === portName);
if (idx < 0)
return null;
const isInput = def.ports[idx].direction === 'in';
// Group ports by direction and compute positions
const sameDirPorts = def.ports.filter(p => p.direction === def.ports[idx].direction);
const localIdx = sameDirPorts.findIndex(p => p.name === portName);
const nodeH = getNodeHeight(node.type);
const bodyH = nodeH - types_1.CONSTANTS.NODE_HEADER_HEIGHT;
if (isInput) {
// Input ports on left side, evenly distributed in body area
const totalInputs = sameDirPorts.length;
const spacing = totalInputs > 1 ? bodyH / (totalInputs + 0.5) : bodyH / 2;
return {
x: node.x,
y: node.y + types_1.CONSTANTS.NODE_HEADER_HEIGHT + spacing * (localIdx + 0.5),
};
}
else {
// Output ports on right side, evenly distributed in body area
const totalOutputs = sameDirPorts.length;
const spacing = totalOutputs > 1 ? bodyH / (totalOutputs + 0.5) : bodyH / 2;
return {
x: node.x + types_1.CONSTANTS.NODE_WIDTH,
y: node.y + types_1.CONSTANTS.NODE_HEADER_HEIGHT + spacing * (localIdx + 0.5),
};
}
}
/** Get all port positions for a node */
function getAllPortPositions(node) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
return new Map();
const result = new Map();
for (const port of def.ports) {
const pos = getPortPosition(node, port.name);
if (pos)
result.set(port.name, pos);
}
return result;
}
/** Check if a node is a gate type */
function isGate(type) {
return ['and', 'or', 'not', 'xor', 'nand', 'nor'].includes(type);
}
/** Check if a node is an input type (toggle, clock) */
function isInputNode(type) {
return type === 'toggle' || type === 'clock';
}
/** Get the connected wire for an input port */
function getInputWire(scene, nodeId, portName) {
return scene.wires.find(w => w.toNodeId === nodeId && w.toPortName === portName) || null;
}
/** Get all output wires from a node's output port */
function getOutputWires(scene, nodeId, portName) {
return scene.wires.filter(w => w.fromNodeId === nodeId && w.fromPortName === portName);
}
};
// ── module: src/evaluation.ts ──
__mods["src/evaluation.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.topologicalSort = topologicalSort;
exports.wouldCreateCycle = wouldCreateCycle;
exports.evaluateScene = evaluateScene;
exports.evaluateDownstream = evaluateDownstream;
const types_1 = require("./types");
const state_1 = require("./state");
/**
* Build a dependency graph and compute topological order.
* Returns nodes in evaluation order (inputs first, then gates downstream).
* Throws if a cycle is detected.
*/
function topologicalSort(scene) {
const nodeMap = new Map();
for (const n of scene.nodes)
nodeMap.set(n.id, n);
// Build adjacency: from -> [to] and in-degree count
const adj = new Map();
const inDegree = new Map();
for (const n of scene.nodes) {
adj.set(n.id, new Set());
inDegree.set(n.id, 0);
}
for (const w of scene.wires) {
if (!adj.has(w.fromNodeId) || !adj.has(w.toNodeId))
continue;
const target = w.toNodeId;
adj.get(w.fromNodeId).add(target);
inDegree.set(target, (inDegree.get(target) || 0) + 1);
}
// Kahn's algorithm
const queue = [];
for (const [id, deg] of inDegree) {
if (deg === 0)
queue.push(id);
}
const result = [];
while (queue.length > 0) {
const id = queue.shift();
result.push(nodeMap.get(id));
for (const neighbor of adj.get(id)) {
inDegree.set(neighbor, inDegree.get(neighbor) - 1);
if (inDegree.get(neighbor) === 0) {
queue.push(neighbor);
}
}
}
// If not all nodes are in result, there's a cycle
if (result.length !== scene.nodes.length) {
throw new Error('Cycle detected in circuit');
}
return result;
}
/**
* Check if adding a wire from `fromNodeId` to `toNodeId` would create a cycle.
* Uses DFS from the target node to see if we can reach the source node.
*/
function wouldCreateCycle(scene, fromNodeId, toNodeId) {
// Same node = self-loop
if (fromNodeId === toNodeId)
return true;
// Build adjacency list including the proposed wire
const adj = new Map();
for (const n of scene.nodes) {
adj.set(n.id, new Set());
}
for (const w of scene.wires) {
if (adj.has(w.fromNodeId) && adj.has(w.toNodeId)) {
adj.get(w.fromNodeId).add(w.toNodeId);
}
}
// Add proposed wire
if (adj.has(fromNodeId) && adj.has(toNodeId)) {
adj.get(fromNodeId).add(toNodeId);
}
// DFS from toNodeId: can we reach fromNodeId?
const visited = new Set();
const stack = [toNodeId];
while (stack.length > 0) {
const current = stack.pop();
if (current === fromNodeId)
return true;
if (visited.has(current))
continue;
visited.add(current);
for (const neighbor of adj.get(current) || []) {
if (!visited.has(neighbor)) {
stack.push(neighbor);
}
}
}
return false;
}
/**
* Evaluate the entire circuit in topological order.
* Returns true if evaluation succeeded, false if there's a cycle.
*/
function evaluateScene(scene) {
let order;
try {
order = topologicalSort(scene);
}
catch (e) {
console.warn('Evaluation failed - cycle detected:', e);
return false;
}
const nodeMap = new Map();
for (const n of scene.nodes)
nodeMap.set(n.id, n);
// Collect input values per port: nodeId+portName -> value
const portValues = new Map();
for (const node of order) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
continue;
// For gate nodes, gather inputs from connected wires and compute output
if (types_1.GATE_FNS[node.type]) {
const inputPorts = def.ports.filter(p => p.direction === 'in');
const values = [];
for (const port of inputPorts) {
const wire = (0, state_1.getInputWire)(scene, node.id, port.name);
if (wire) {
const srcNode = nodeMap.get(wire.fromNodeId);
if (!srcNode)
continue;
// Get the source node's output value
const key = `${wire.fromNodeId}:${wire.fromPortName}`;
values.push(portValues.get(key) || false);
}
else {
// Unconnected input defaults to false
values.push(false);
}
}
const gateFn = types_1.GATE_FNS[node.type];
node.state = gateFn(...values);
}
// For output nodes (lamp, hex), set state based on connected inputs
if (node.type === 'lamp') {
const wire = (0, state_1.getInputWire)(scene, node.id, 'in');
if (wire) {
const key = `${wire.fromNodeId}:${wire.fromPortName}`;
node.state = portValues.get(key) || false;
}
else {
node.state = false;
}
}
// For hex display, compute value from 4 input bits
if (node.type === 'hex') {
let val = 0;
const ports = ['d0', 'd1', 'd2', 'd3']; // LSB to MSB
for (let i = 0; i < ports.length; i++) {
const wire = (0, state_1.getInputWire)(scene, node.id, ports[i]);
if (wire) {
const key = `${wire.fromNodeId}:${wire.fromPortName}`;
if (portValues.get(key))
val |= (1 << i);
}
}
node.state = val > 0; // state is true if any bit is set
node.label = val.toString(16).toUpperCase();
}
// Record output port values for downstream nodes
const outputPorts = def.ports.filter(p => p.direction === 'out');
for (const port of outputPorts) {
const key = `${node.id}:${port.name}`;
if (node.type === 'toggle' || node.type === 'clock') {
// Input nodes: their state IS the output value
portValues.set(key, node.state);
}
else if (types_1.GATE_FNS[node.type]) {
// Gate nodes: output = computed state
portValues.set(key, node.state);
}
}
}
return true;
}
/**
* Re-evaluate only the downstream subgraph affected by a changed node.
* More efficient than full re-evaluation for single changes.
*/
function evaluateDownstream(scene, startNodeId) {
const nodeMap = new Map();
for (const n of scene.nodes)
nodeMap.set(n.id, n);
// Build adjacency list
const adj = new Map();
for (const n of scene.nodes) {
adj.set(n.id, new Set());
}
for (const w of scene.wires) {
if (adj.has(w.fromNodeId) && adj.has(w.toNodeId)) {
adj.get(w.fromNodeId).add(w.toNodeId);
}
}
// BFS/DFS to find all downstream nodes in topological order
const visited = new Set();
const queue = [startNodeId];
const evalOrder = [];
while (queue.length > 0) {
const id = queue.shift();
if (visited.has(id))
continue;
visited.add(id);
evalOrder.push(nodeMap.get(id));
for (const neighbor of adj.get(id) || []) {
if (!visited.has(neighbor)) {
// Only add if all its predecessors are already visited or in queue
const node = nodeMap.get(neighbor);
if (node) {
const def = types_1.NODE_DEFS[node.type];
if (def) {
let allPredsVisited = true;
for (const port of def.ports.filter(p => p.direction === 'in')) {
const wire = (0, state_1.getInputWire)(scene, neighbor, port.name);
if (wire && !visited.has(wire.fromNodeId)) {
allPredsVisited = false;
break;
}
}
if (allPredsVisited) {
queue.push(neighbor);
}
}
}
}
}
}
// Evaluate in order
const portValues = new Map();
for (const node of evalOrder) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
continue;
if (types_1.GATE_FNS[node.type]) {
const inputPorts = def.ports.filter(p => p.direction === 'in');
const values = [];
for (const port of inputPorts) {
const wire = (0, state_1.getInputWire)(scene, node.id, port.name);
if (wire) {
const srcNode = nodeMap.get(wire.fromNodeId);
if (!srcNode)
continue;
const key = `${wire.fromNodeId}:${wire.fromPortName}`;
values.push(portValues.get(key) || false);
}
else {
values.push(false);
}
}
const gateFn = types_1.GATE_FNS[node.type];
node.state = gateFn(...values);
}
// For output nodes (lamp, hex), set state based on connected inputs
if (node.type === 'lamp') {
const wire = (0, state_1.getInputWire)(scene, node.id, 'in');
if (wire) {
const key = `${wire.fromNodeId}:${wire.fromPortName}`;
node.state = portValues.get(key) || false;
}
else {
node.state = false;
}
}
// For hex display, compute value from 4 input bits
if (node.type === 'hex') {
let val = 0;
const ports = ['d0', 'd1', 'd2', 'd3']; // LSB to MSB
for (let i = 0; i < ports.length; i++) {
const wire = (0, state_1.getInputWire)(scene, node.id, ports[i]);
if (wire) {
const key = `${wire.fromNodeId}:${wire.fromPortName}`;
if (portValues.get(key))
val |= (1 << i);
}
}
node.state = val > 0;
node.label = val.toString(16).toUpperCase();
}
// Record outputs
const outputPorts = def.ports.filter(p => p.direction === 'out');
for (const port of outputPorts) {
const key = `${node.id}:${port.name}`;
if (node.type === 'toggle' || node.type === 'clock') {
portValues.set(key, node.state);
}
else if (types_1.GATE_FNS[node.type]) {
portValues.set(key, node.state);
}
}
}
return true;
}
};
// ── module: src/rendering.ts ──
__mods["src/rendering.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.worldToScreen = worldToScreen;
exports.screenToWorld = screenToWorld;
exports.drawGrid = drawGrid;
exports.drawWires = drawWires;
exports.drawWirePreview = drawWirePreview;
exports.drawNode = drawNode;
exports.roundRect = roundRect;
exports.hitTestPorts = hitTestPorts;
exports.hitTestNode = hitTestNode;
exports.renderScene = renderScene;
const types_1 = require("./types");
const state_1 = require("./state");
// ─── Coordinate Transforms ──────────────────────────────────────────
function worldToScreen(wx, wy, v) {
return [wx * v.zoom + v.panX, wy * v.zoom + v.panY];
}
function screenToWorld(sx, sy, v) {
return [(sx - v.panX) / v.zoom, (sy - v.panY) / v.zoom];
}
// ─── Grid Rendering ────────────────────────────────────────────────
function drawGrid(ctx, w, h, v) {
const spacing = types_1.CONSTANTS.NODE_WIDTH; // grid dot spacing in world coords
const [startWX, startWY] = screenToWorld(0, 0, v);
const [endWX, endWY] = screenToWorld(w, h, v);
ctx.fillStyle = types_1.CONSTANTS.COLOR_GRID_DOT;
// Calculate visible range with padding
const pad = spacing * 2;
const startX = Math.floor((startWX - pad) / spacing) * spacing;
const startY = Math.floor((startWY - pad) / spacing) * spacing;
const endX = endWX + pad;
const endY = endWY + pad;
for (let wx = startX; wx <= endX; wx += spacing) {
for (let wy = startY; wy <= endY; wy += spacing) {
const [sx, sy] = worldToScreen(wx, wy, v);
ctx.beginPath();
ctx.arc(sx, sy, Math.max(1.5, 2 * v.zoom), 0, Math.PI * 2);
ctx.fill();
}
}
}
function drawWireCurve(ctx, fromX, fromY, toX, toY, energized, v, isPreview = false) {
// Adaptive control arm length based on distance between nodes
const dx = Math.abs(toX - fromX);
const minArm = types_1.CONSTANTS.WIRE_ARM_LENGTH * v.zoom;
const maxArm = dx * 0.45; // Don't let arms exceed 45% of total distance
const armLen = Math.max(minArm, Math.min(maxArm, (dx / 2) * 0.6));
// Bezier control points - horizontal curves
const cp1x = fromX + armLen;
const cp1y = fromY;
const cp2x = toX - armLen;
const cp2y = toY;
ctx.beginPath();
ctx.moveTo(fromX, fromY);
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, toX, toY);
if (energized && !isPreview) {
// Outer glow layer
ctx.save();
ctx.strokeStyle = types_1.CONSTANTS.COLOR_WIRE_GLOW;
ctx.lineWidth = Math.max(4, 8 * v.zoom);
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
// Inner bright line
ctx.strokeStyle = types_1.CONSTANTS.COLOR_WIRE_ON;
ctx.lineWidth = Math.max(2, 3 * v.zoom);
ctx.lineCap = 'round';
ctx.stroke();
// Core highlight
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = Math.max(1, 1.5 * v.zoom);
ctx.stroke();
}
else if (isPreview) {
ctx.setLineDash([6 * v.zoom, 4 * v.zoom]);
ctx.strokeStyle = energized ? types_1.CONSTANTS.COLOR_WIRE_ON : 'rgba(100,120,160,0.5)';
ctx.lineWidth = Math.max(1.5, 2 * v.zoom);
ctx.stroke();
ctx.setLineDash([]);
}
else if (!isPreview) {
// Dim wire for de-energized signals
ctx.strokeStyle = types_1.CONSTANTS.COLOR_WIRE_OFF;
ctx.lineWidth = Math.max(1.5, 2 * v.zoom);
ctx.lineCap = 'round';
ctx.stroke();
}
}
function drawWires(ctx, scene, v) {
const nodeMap = new Map();
for (const n of scene.nodes)
nodeMap.set(n.id, n);
for (const wire of scene.wires) {
const fromNode = nodeMap.get(wire.fromNodeId);
const toNode = nodeMap.get(wire.toNodeId);
if (!fromNode || !toNode)
continue;
const fromPos = (0, state_1.getPortPosition)(fromNode, wire.fromPortName);
const toPos = (0, state_1.getPortPosition)(toNode, wire.toPortName);
if (!fromPos || !toPos)
continue;
// Determine if energized: source node's output is true
const energized = fromNode.state === true;
const [sx1, sy1] = worldToScreen(fromPos.x, fromPos.y, v);
const [sx2, sy2] = worldToScreen(toPos.x, toPos.y, v);
drawWireCurve(ctx, sx1, sy1, sx2, sy2, energized, v);
}
}
function drawWirePreview(ctx, fromX, fromY, toX, toY, energized, v) {
const [sx1, sy1] = worldToScreen(fromX, fromY, v);
const [sx2, sy2] = worldToScreen(toX, toY, v);
drawWireCurve(ctx, sx1, sy1, sx2, sy2, energized, v, true);
}
// ─── Node Rendering ────────────────────────────────────────────────
function drawNode(ctx, node, v, isSelected, isHovered) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
return;
const [sx, sy] = worldToScreen(node.x, node.y, v);
const nw = types_1.CONSTANTS.NODE_WIDTH * v.zoom;
const nh = (0, state_1.getNodeHeight)(node.type) * v.zoom;
const headerH = types_1.CONSTANTS.NODE_HEADER_HEIGHT * v.zoom;
const r = types_1.CONSTANTS.NODE_CORNER_RADIUS * v.zoom;
// Node body shadow
ctx.save();
if (isHovered) {
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 12 * v.zoom;
ctx.shadowOffsetY = 4 * v.zoom;
}
// Background
ctx.fillStyle = types_1.CONSTANTS.COLOR_NODE_BG;
roundRect(ctx, sx, sy, nw, nh, r);
ctx.fill();
// Border
ctx.strokeStyle = isSelected ? types_1.CONSTANTS.COLOR_NODE_SELECTED : types_1.CONSTANTS.COLOR_NODE_BORDER;
ctx.lineWidth = isSelected ? Math.max(2, 2.5 * v.zoom) : Math.max(1, 1.5 * v.zoom);
roundRect(ctx, sx, sy, nw, nh, r);
ctx.stroke();
// Header bar
ctx.fillStyle = types_1.CONSTANTS.COLOR_NODE_HEADER;
ctx.beginPath();
ctx.moveTo(sx + r, sy);
ctx.lineTo(sx + nw - r, sy);
ctx.arcTo(sx + nw, sy, sx + nw, sy + r, r);
ctx.lineTo(sx + nw, sy + headerH);
ctx.lineTo(sx, sy + headerH);
ctx.lineTo(sx, sy + r);
ctx.arcTo(sx, sy, sx + r, sy, r);
ctx.closePath();
ctx.fill();
// State tint overlay for gates/inputs
if (node.state && types_1.GATE_FNS[node.type]) {
ctx.fillStyle = 'rgba(74,234,255,0.08)';
roundRect(ctx, sx + 1, sy + headerH + 1, nw - 2, nh - headerH - 2, Math.max(0, r - 1));
ctx.fill();
}
ctx.restore();
// Node label
const fontSize = Math.max(11, 13 * v.zoom);
ctx.font = `bold ${fontSize}px 'Segoe UI', system-ui, sans-serif`;
ctx.fillStyle = types_1.CONSTANTS.COLOR_TEXT;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(def.label, sx + 8 * v.zoom, sy + headerH / 2);
// State indicator text (for gates and inputs)
if (types_1.GATE_FNS[node.type] || node.type === 'toggle' || node.type === 'clock') {
const stateText = node.state ? 'ON' : 'OFF';
ctx.font = `${Math.max(9, 10 * v.zoom)}px 'Segoe UI', system-ui, sans-serif`;
ctx.fillStyle = node.state ? types_1.CONSTANTS.COLOR_WIRE_ON : types_1.CONSTANTS.COLOR_TEXT_DIM;
ctx.textAlign = 'right';
ctx.fillText(stateText, sx + nw - 8 * v.zoom, sy + headerH / 2);
}
// Draw ports
const portPositions = (0, state_1.getAllPortPositions)(node);
for (const [portName, pos] of portPositions) {
const portDef = def.ports.find(p => p.name === portName);
const [px, py] = worldToScreen(pos.x, pos.y, v);
const radius = types_1.CONSTANTS.PORT_RADIUS * v.zoom;
// Port circle
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fillStyle = portDef.direction === 'in' ? types_1.CONSTANTS.COLOR_PORT_INPUT : types_1.CONSTANTS.COLOR_PORT_OUTPUT;
ctx.fill();
// Port border
ctx.strokeStyle = '#1a1d23';
ctx.lineWidth = Math.max(1.5, 2 * v.zoom);
ctx.stroke();
// Check if port has a wire connected (for input ports)
if (portDef.direction === 'in') {
const hasWire = false; // Will be checked by caller
if (hasWire) {
ctx.beginPath();
ctx.arc(px, py, radius * 0.5, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
}
}
}
// Special rendering for Lamp node
if (node.type === 'lamp') {
const lampX = sx + nw / 2;
const lampY = sy + headerH + (nh - headerH) / 2;
const lampR = Math.min(nw, nh - headerH) * 0.35;
ctx.save();
if (node.state) {
ctx.shadowColor = types_1.CONSTANTS.COLOR_LAMP_GLOW;
ctx.shadowBlur = 20 * v.zoom;
}
ctx.beginPath();
ctx.arc(lampX, lampY, lampR, 0, Math.PI * 2);
ctx.fillStyle = node.state ? types_1.CONSTANTS.COLOR_LAMP_ON : types_1.CONSTANTS.COLOR_LAMP_OFF;
ctx.fill();
ctx.strokeStyle = '#1a1d23';
ctx.lineWidth = Math.max(1.5, 2 * v.zoom);
ctx.stroke();
ctx.restore();
// Inner glow for lamp when on
if (node.state) {
const grad = ctx.createRadialGradient(lampX, lampY, 0, lampX, lampY, lampR);
grad.addColorStop(0, 'rgba(255,255,255,0.4)');
grad.addColorStop(1, 'rgba(255,204,51,0)');
ctx.beginPath();
ctx.arc(lampX, lampY, lampR, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
}
// Special rendering for Hex Display node
if (node.type === 'hex') {
drawHexDisplay(ctx, node, sx, sy, nw, nh, headerH, v);
}
// Clock rate indicator
if (node.type === 'clock' && node.clockRateIdx !== undefined) {
const rate = types_1.CONSTANTS.CLOCK_RATES[node.clockRateIdx] || 500;
ctx.font = `${Math.max(8, 9 * v.zoom)}px monospace`;
ctx.fillStyle = types_1.CONSTANTS.COLOR_TEXT_DIM;
ctx.textAlign = 'center';
ctx.fillText(`${rate}ms`, sx + nw / 2, sy + nh - 4 * v.zoom);
}
}
function drawHexDisplay(ctx, node, sx, sy, nw, nh, headerH, v) {
const hexVal = parseInt(node.label || '0', 16) || 0;
const segments = types_1.HEX_SEGMENTS[hexVal] || [0, 0, 0, 0, 0, 0, 0];
// Display area within node body
const padX = nw * 0.12;
const padY = (nh - headerH) * 0.15;
const dispW = nw - padX * 2;
const dispH = nh - headerH - padY * 2;
const dx = sx + padX;
const dy = sy + headerH + padY;
// Segment dimensions (proportional to display area)
const segThick = Math.min(dispW, dispH) * 0.1;
const gap = segThick * 0.5;
// Define segment positions: a(top), b(tr), c(br), d(bot), e(bl), f(tl), g(mid)
const halfW = dispW / 2;
const midY = dy + dispH / 2;
const topY = dy + gap;
const botY = dy + dispH - segThick - gap;
// Horizontal segments (a, d, g)
const hSegs = [
{ x: dx + halfW / 4, y: topY, w: dispW * 0.75, h: segThick }, // a (top)
{ x: dx + halfW / 4, y: midY - segThick / 2, w: dispW * 0.75, h: segThick }, // g (mid)
{ x: dx + halfW / 4, y: botY, w: dispW * 0.75, h: segThick }, // d (bot)
];
// Vertical segments (f=tl, b=tr, e=bl, c=br)
const vSegH = (dispH - gap * 2 - segThick) / 2;
const vSegs = [
{ x: dx + halfW / 4, y: topY, w: segThick, h: vSegH }, // f (top-left)
{ x: dx + dispW - halfW / 4 - segThick, y: topY, w: segThick, h: vSegH }, // b (top-right)
{ x: dx + halfW / 4, y: midY + segThick / 2, w: segThick, h: vSegH }, // e (bot-left)
{ x: dx + dispW - halfW / 4 - segThick, y: midY + segThick / 2, w: segThick, h: vSegH }, // c (bot-right)
];
const allSegs = [...hSegs.slice(0), ...vSegs.slice(0), hSegs[1]]; // a,f,b,e,c,g,d order → segments[a,b,c,d,e,f,g]
// Map to segment indices: a=0, b=1, c=2, d=3, e=4, f=5, g=6
const segMap = [hSegs[0], vSegs[1], vSegs[3], hSegs[2], vSegs[2], vSegs[0], hSegs[1]];
for (let i = 0; i < 7; i++) {
const s = segMap[i];
const isOn = segments[i] === 1;
ctx.save();
if (isOn) {
ctx.shadowColor = types_1.CONSTANTS.COLOR_LAMP_GLOW;
ctx.shadowBlur = 6 * v.zoom;
}
ctx.fillStyle = isOn ? types_1.CONSTANTS.COLOR_LAMP_ON : 'rgba(50,55,70,0.6)';
const r = segThick / 2;
roundRect(ctx, s.x, s.y, s.w, s.h, r);
ctx.fill();
// Add subtle border to segments
ctx.strokeStyle = 'rgba(30,33,40,0.8)';
ctx.lineWidth = Math.max(0.5, v.zoom * 0.5);
roundRect(ctx, s.x, s.y, s.w, s.h, r);
ctx.stroke();
ctx.restore();
}
// Hex character label below display
const hexChar = hexVal.toString(16).toUpperCase();
ctx.font = `bold ${Math.max(9, 11 * v.zoom)}px monospace`;
ctx.fillStyle = hexVal > 0 ? types_1.CONSTANTS.COLOR_LAMP_ON : types_1.CONSTANTS.COLOR_TEXT_DIM;
ctx.textAlign = 'center';
ctx.fillText(hexChar, sx + nw / 2, sy + nh - 3 * v.zoom);
}
// ─── Utility: Rounded Rectangle ─────────────────────────────────────
function roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
function hitTestPorts(scene, screenX, screenY, v, direction) {
const [wx, wy] = screenToWorld(screenX, screenY, v);
const hitRadius = types_1.CONSTANTS.PORT_HIT_TOLERANCE / v.zoom; // scale up at low zoom
let best = null;
let bestDist = Infinity;
for (const node of scene.nodes) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
continue;
for (const port of def.ports) {
if (direction && port.direction !== direction)
continue;
const pos = (0, state_1.getPortPosition)(node, port.name);
if (!pos)
continue;
const dx = wx - pos.x;
const dy = wy - pos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < hitRadius && dist < bestDist) {
bestDist = dist;
best = { nodeId: node.id, portName: port.name, worldX: pos.x, worldY: pos.y };
}
}
}
return best;
}
// ─── Node Hit Testing ──────────────────────────────────────────────
function hitTestNode(scene, screenX, screenY, v) {
const [wx, wy] = screenToWorld(screenX, screenY, v);
// Check nodes in reverse order (top-most first)
for (let i = scene.nodes.length - 1; i >= 0; i--) {
const node = scene.nodes[i];
const h = (0, state_1.getNodeHeight)(node.type);
if (wx >= node.x &&
wx <= node.x + types_1.CONSTANTS.NODE_WIDTH &&
wy >= node.y &&
wy <= node.y + h) {
return node;
}
}
return null;
}
// ─── Full Scene Render ──────────────────────────────────────────────
function renderScene(ctx, scene, v, selectedNodeId, hoveredNodeId) {
const w = ctx.canvas.width;
const h = ctx.canvas.height;
// Clear background
ctx.fillStyle = types_1.CONSTANTS.COLOR_BG;
ctx.fillRect(0, 0, w, h);
// Draw grid
drawGrid(ctx, w, h, v);
// Draw wires (behind nodes)
drawWires(ctx, scene, v);
// Draw nodes
for (const node of scene.nodes) {
drawNode(ctx, node, v, node.id === selectedNodeId, node.id === hoveredNodeId);
}
}
};
// ── module: src/interaction.ts ──
__mods["src/interaction.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InteractionManager = void 0;
const types_1 = require("./types");
const state_1 = require("./state");
const rendering_1 = require("./rendering");
const evaluation_1 = require("./evaluation");
class InteractionManager {
constructor(canvas, scene, view) {
this.drag = { type: 'none' };
this.selectedNodeId = null;
this.hoveredNodeId = null;
this.mouseDownPos = null;
this.canvas = canvas;
this.scene = scene;
this.view = view;
this.setupEvents();
}
setupEvents() {
this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e));
this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e));
this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e));
this.canvas.addEventListener('wheel', (e) => this.onWheel(e), { passive: false });
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
window.addEventListener('keydown', (e) => this.onKeyDown(e));
}
onMouseDown(e) {
const rect = this.canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
// Track mousedown position for click detection
this.mouseDownPos = [sx, sy];
// Right-click: open palette
if (e.button === 2) {
return;
}
// Check port hit first (for wire creation)
const portHit = (0, rendering_1.hitTestPorts)(this.scene, sx, sy, this.view);
if (portHit) {
const node = this.scene.nodes.find(n => n.id === portHit.nodeId);
if (!node)
return;
const def = types_1.NODE_DEFS[node.type];
const portDef = def.ports.find(p => p.name === portHit.portName);
if (portDef.direction === 'out') {
// Start dragging wire from output port
this.drag = {
type: 'create-wire',
fromNodeId: node.id,
fromPortName: portHit.portName,
fromWorldX: portHit.worldX,
fromWorldY: portHit.worldY,
toScreenX: sx,
toScreenY: sy,
};
}
else {
// Dragging from input port: pick up existing wire and re-route
const existingWire = (0, state_1.getInputWire)(this.scene, node.id, portHit.portName);
if (existingWire) {
(0, state_1.removeWire)(this.scene, existingWire.id);
const srcNode = this.scene.nodes.find(n => n.id === existingWire.fromNodeId);
if (!srcNode)
return;
const srcPos = (0, state_1.getPortPosition)(srcNode, existingWire.fromPortName);
if (!srcPos)
return;
this.drag = {
type: 'create-wire',
fromNodeId: existingWire.fromNodeId,
fromPortName: existingWire.fromPortName,
fromWorldX: srcPos.x,
fromWorldY: srcPos.y,
toScreenX: sx,
toScreenY: sy,
};
}
}
this.onRender?.();
return;
}
// Check node hit (for dragging nodes)
const nodeHit = (0, rendering_1.hitTestNode)(this.scene, sx, sy, this.view);
if (nodeHit) {
const [wx, wy] = (0, rendering_1.screenToWorld)(sx, sy, this.view);
this.drag = {
type: 'drag-node',
nodeId: nodeHit.id,
offsetX: wx - nodeHit.x,
offsetY: wy - nodeHit.y,
};
this.selectedNodeId = nodeHit.id;
this.onSelectChange?.(this.selectedNodeId);
this.onRender?.();
return;
}
// Click on empty space: start panning
this.drag = { type: 'pan' };
this.selectedNodeId = null;
this.onSelectChange?.(null);
this.onRender?.();
}
onMouseMove(e) {
const rect = this.canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
if (this.drag.type === 'pan') {
this.view.panX += e.movementX;
this.view.panY += e.movementY;
this.onRender?.();
return;
}
if (this.drag.type === 'drag-node' && this.drag.nodeId) {
const [wx, wy] = (0, rendering_1.screenToWorld)(sx, sy, this.view);
const node = this.scene.nodes.find(n => n.id === this.drag.nodeId);
if (node) {
node.x = wx - (this.drag.offsetX || 0);
node.y = wy - (this.drag.offsetY || 0);
// Snap to grid-ish positions for cleaner layout
const snap = 5;
node.x = Math.round(node.x / snap) * snap;
node.y = Math.round(node.y / snap) * snap;
}
this.onRender?.();
return;
}
if (this.drag.type === 'create-wire' || this.drag.type === 'cycle-rejected') {
this.drag.toScreenX = sx;
this.drag.toScreenY = sy;
this.onRender?.();
return;
}
// Hover detection when not dragging
if (this.drag.type === 'none') {
const nodeHit = (0, rendering_1.hitTestNode)(this.scene, sx, sy, this.view);
const newHoveredId = nodeHit ? nodeHit.id : null;
if (newHoveredId !== this.hoveredNodeId) {
this.hoveredNodeId = newHoveredId;
this.canvas.style.cursor = nodeHit ? 'move' : 'default';
this.onRender?.();
}
// Check port hover for cursor change
const portHit = (0, rendering_1.hitTestPorts)(this.scene, sx, sy, this.view);
if (portHit && !nodeHit) {
this.canvas.style.cursor = 'crosshair';
}
else if (!nodeHit) {
this.canvas.style.cursor = 'default';
}
}
}
onMouseUp(e) {
const rect = this.canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
// Detect click (minimal movement from mousedown position)
if (this.mouseDownPos && this.drag.type === 'drag-node') {
const [mdx, mdy] = this.mouseDownPos;
const dx = sx - mdx;
const dy = sy - mdy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5) {
// This was a click, not a drag
const [wx, wy] = (0, rendering_1.screenToWorld)(sx, sy, this.view);
for (let i = this.scene.nodes.length - 1; i >= 0; i--) {
const node = this.scene.nodes[i];
if (node.type === 'toggle') {
// Check bounds (entire node body)
const h = (0, state_1.getNodeHeight)(node.type);
if (wx >= node.x && wx <= node.x + types_1.CONSTANTS.NODE_WIDTH && wy >= node.y && wy <= node.y + h) {
node.state = !node.state;
(0, evaluation_1.evaluateDownstream)(this.scene, node.id);
this.onRender?.();
return; // Don't process further
}
}
}
}
}
if (this.drag.type === 'create-wire' || this.drag.type === 'cycle-rejected') {
// Check if we're over a valid input port
const targetPort = (0, rendering_1.hitTestPorts)(this.scene, sx, sy, this.view, 'in');
if (targetPort && this.drag.fromNodeId) {
// Don't connect to same node's output
if (targetPort.nodeId !== this.drag.fromNodeId) {
// Check for cycles
const cycle = (0, evaluation_1.wouldCreateCycle)(this.scene, this.drag.fromNodeId, targetPort.nodeId);
if (!cycle) {
(0, state_1.addWire)(this.scene, this.drag.fromNodeId, this.drag.fromPortName, targetPort.nodeId, targetPort.portName);
// Re-evaluate downstream from the source node
(0, evaluation_1.evaluateDownstream)(this.scene, this.drag.fromNodeId);
}
else {
// Cycle detected - show rejection feedback briefly
this.drag.type = 'cycle-rejected';
setTimeout(() => {
if (this.drag.type === 'cycle-rejected') {
this.drag = { type: 'none' };
this.onRender?.();
}
}, 500);
this.onRender?.();
return;
}
}
}
// If we didn't connect to anything, the wire is discarded (already removed if re-routing)
}
this.drag = { type: 'none' };
this.onRender?.();
}
onWheel(e) {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Zoom toward cursor
const [wx, wy] = (0, rendering_1.screenToWorld)(mx, my, this.view);
const zoomFactor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const newZoom = Math.max(types_1.CONSTANTS.ZOOM_MIN, types_1.CONSTANTS.ZOOM_MAX * (e.deltaY > 0 ? -1 : 1) +
(e.deltaY > 0 ? types_1.CONSTANTS.ZOOM_MIN : 0));
// Clamp properly
let targetZoom = this.view.zoom * zoomFactor;
targetZoom = Math.max(types_1.CONSTANTS.ZOOM_MIN, Math.min(types_1.CONSTANTS.ZOOM_MAX, targetZoom));
// Adjust pan so the world point under cursor stays fixed
this.view.panX = mx - wx * targetZoom;
this.view.panY = my - wy * targetZoom;
this.view.zoom = targetZoom;
this.onRender?.();
}
onKeyDown(e) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedNodeId) {
(0, state_1.removeNode)(this.scene, this.selectedNodeId);
(0, evaluation_1.evaluateDownstream)(this.scene, this.selectedNodeId);
this.selectedNodeId = null;
this.onSelectChange?.(null);
this.onRender?.();
}
}
// Space to toggle selected node (if it's a toggle)
if (e.key === ' ' && this.selectedNodeId) {
e.preventDefault();
const node = this.scene.nodes.find(n => n.id === this.selectedNodeId);
if (node && node.type === 'toggle') {
node.state = !node.state;
(0, evaluation_1.evaluateDownstream)(this.scene, node.id);
this.onRender?.();
}
}
// Arrow keys to move selected node
if (this.selectedNodeId) {
const step = e.shiftKey ? 20 : 5;
const node = this.scene.nodes.find(n => n.id === this.selectedNodeId);
if (node) {
let moved = false;
if (e.key === 'ArrowLeft') {
node.x -= step;
moved = true;
}
if (e.key === 'ArrowRight') {
node.x += step;
moved = true;
}
if (e.key === 'ArrowUp') {
node.y -= step;
moved = true;
}
if (e.key === 'ArrowDown') {
node.y += step;
moved = true;
}
if (moved) {
e.preventDefault();
this.onRender?.();
}
}
}
}
// ─── Public API ──────────────────────────────────────────────────
getSelectedNodeId() {
return this.selectedNodeId;
}
getHoveredNodeId() {
return this.hoveredNodeId;
}
getDragState() {
return this.drag;
}
addNode(type, worldX, worldY) {
const node = (0, state_1.addNode)(this.scene, type, worldX, worldY);
(0, evaluation_1.evaluateDownstream)(this.scene, node.id);
this.onRender?.();
return node;
}
toggleSelectedNode() {
if (!this.selectedNodeId)
return;
const node = this.scene.nodes.find(n => n.id === this.selectedNodeId);
if (node && node.type === 'toggle') {
node.state = !node.state;
(0, evaluation_1.evaluateDownstream)(this.scene, node.id);
this.onRender?.();
}
}
cycleClockRate() {
if (!this.selectedNodeId)
return;
const node = this.scene.nodes.find(n => n.id === this.selectedNodeId);
if (node && node.type === 'clock') {
node.clockRateIdx = ((node.clockRateIdx || 0) + 1) % types_1.CONSTANTS.CLOCK_RATES.length;
this.onRender?.();
}
}
// ─── Wire Preview Drawing ────────────────────────────────────────
drawWirePreview(ctx) {
if ((this.drag.type !== 'create-wire' && this.drag.type !== 'cycle-rejected') || !this.drag.fromWorldX)
return;
const [sx1, sy1] = (0, rendering_1.worldToScreen)(this.drag.fromWorldX, this.drag.fromWorldY, this.view);
const sx2 = this.drag.toScreenX;
const sy2 = this.drag.toScreenY;
// Determine if energized based on source node state
const srcNode = this.scene.nodes.find(n => n.id === this.drag.fromNodeId);
const energized = srcNode?.state || false;
// Adaptive control arm length based on distance between nodes
const dx = Math.abs(sx2 - sx1);
const minArm = types_1.CONSTANTS.WIRE_ARM_LENGTH * this.view.zoom;
const maxArm = dx * 0.45;
const armLen = Math.max(minArm, Math.min(maxArm, (dx / 2) * 0.6));
ctx.beginPath();
ctx.moveTo(sx1, sy1);
ctx.bezierCurveTo(sx1 + armLen, sy1, sx2 - armLen, sy2, sx2, sy2);
if (this.drag.type === 'cycle-rejected') {
// Red flash for cycle rejection
ctx.strokeStyle = '#ff4444';
ctx.lineWidth = Math.max(3, 4 * this.view.zoom);
ctx.shadowColor = 'rgba(255,68,68,0.5)';
ctx.shadowBlur = 10;
}
else {
// Check if target port is valid (highlight when hovering over one)
const [twx, twy] = (0, rendering_1.screenToWorld)(sx2, sy2, this.view);
const hitRadius = types_1.CONSTANTS.PORT_HIT_TOLERANCE / this.view.zoom;
let isValidTarget = false;
for (const node of this.scene.nodes) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
continue;
for (const port of def.ports.filter(p => p.direction === 'in')) {
const pos = (0, state_1.getPortPosition)(node, port.name);
if (!pos)
continue;
const dx = twx - pos.x;
const dy = twy - pos.y;
if (Math.sqrt(dx * dx + dy * dy) < hitRadius && node.id !== this.drag.fromNodeId) {
isValidTarget = true;
break;
}
}
if (isValidTarget)
break;
}
ctx.setLineDash([6 * this.view.zoom, 4 * this.view.zoom]);
ctx.strokeStyle = isValidTarget ? '#4aeaff' : 'rgba(100,120,160,0.5)';
ctx.lineWidth = Math.max(2, 3 * this.view.zoom);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
// Highlight target port if valid
const [twx, twy] = (0, rendering_1.screenToWorld)(sx2, sy2, this.view);
const hitRadius = types_1.CONSTANTS.PORT_HIT_TOLERANCE / this.view.zoom;
for (const node of this.scene.nodes) {
const def = types_1.NODE_DEFS[node.type];
if (!def)
continue;
for (const port of def.ports.filter(p => p.direction === 'in')) {
const pos = (0, state_1.getPortPosition)(node, port.name);
if (!pos)
continue;
const dx = twx - pos.x;
const dy = twy - pos.y;
if (Math.sqrt(dx * dx + dy * dy) < hitRadius && node.id !== this.drag.fromNodeId) {
// Draw highlight ring around target port
const [px, py] = (0, rendering_1.worldToScreen)(pos.x, pos.y, this.view);
ctx.beginPath();
ctx.arc(px, py, types_1.CONSTANTS.PORT_RADIUS * this.view.zoom + 4, 0, Math.PI * 2);
ctx.strokeStyle = '#4aeaff';
ctx.lineWidth = 2;
ctx.stroke();
break;
}
}
}
}
// ─── Hex Display Update ──────────────────────────────────────────
updateHexDisplays() {
for (const node of this.scene.nodes) {
if (node.type === 'hex') {
let val = 0;
const ports = ['d0', 'd1', 'd2', 'd3']; // LSB to MSB
for (let i = 0; i < ports.length; i++) {
const wire = (0, state_1.getInputWire)(this.scene, node.id, ports[i]);
if (wire) {
const srcNode = this.scene.nodes.find(n => n.id === wire.fromNodeId);
if (srcNode && srcNode.state) {
val |= (1 << i);
}
}
}
node.label = val.toString(16).toUpperCase();
}
}
}
// ─── Clock Ticking ──────────────────────────────────────────────
tickClocks() {
for (const node of this.scene.nodes) {
if (node.type === 'clock') {
node.state = !node.state;
(0, evaluation_1.evaluateDownstream)(this.scene, node.id);
}
}
this.updateHexDisplays();
this.onRender?.();
}
// ─── Scene Accessors ─────────────────────────────────────────────
getScene() {
return this.scene;
}
getView() {
return this.view;
}
}
exports.InteractionManager = InteractionManager;
};
// ── module: src/persistence.ts ──
__mods["src/persistence.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.saveScene = saveScene;
exports.loadScene = loadScene;
exports.clearScene = clearScene;
const STORAGE_KEY = 'logic-circuit-editor-scene';
function saveScene(scene) {
try {
const data = JSON.stringify(scene);
localStorage.setItem(STORAGE_KEY, data);
}
catch (e) {
// Storage might be full or unavailable
console.warn('Failed to save scene:', e);
}
}
function loadScene() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (!data)
return null;
return JSON.parse(data);
}
catch (e) {
console.warn('Failed to load scene:', e);
return null;
}
}
function clearScene() {
localStorage.removeItem(STORAGE_KEY);
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"typescript","sessionId":"3451f853-8a87-42c4-9b42-b16d1972f0c0","tokensIn":7688852,"tokensOut":70461,"tokensTotal":7759313,"turns":126,"toolCalls":125,"failedToolCalls":2,"timestamp":"2026-07-30T23:48:07.479Z"} -->