Node Graph Editor
thinkingcap qwen3.6 27b · 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 47,246 bytes · SHA-256 32d617815e72
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logic Circuit Editor</title>
<style>
/* ── css: layout ── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1d23;
font-family: 'Segoe UI', system-ui, sans-serif;
}
canvas {
display: block;
cursor: default;
}
#palette {
position: fixed;
top: 12px;
left: 12px;
z-index: 10;
background: #1e2128ee;
border: 1px solid #3a3f4b;
border-radius: 10px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
backdrop-filter: blur(8px);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
.palette-title {
color: #8a90a0;
font-size: 10px;
font-weight: 700;
letter-spacing: 1.5px;
text-align: center;
padding: 6px 0 4px;
}
.palette-btn {
background: #262a33;
color: #e0e4ea;
border: 1px solid #3a3f4b;
border-radius: 6px;
padding: 7px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
min-width: 80px;
}
.palette-btn:hover {
background: #303540;
border-color: #4a9eff;
color: #fff;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(74, 158, 255, 0.2);
}
.palette-btn:active {
transform: translateY(0);
background: #3a3f4b;
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./rendering":"src/rendering.ts","./persistence":"src/persistence.ts","./input":"src/input.ts","./types":"src/types.ts","./evaluation":"src/evaluation.ts"},"src/state.ts":{"./constants":"src/constants.ts","./types":"src/types.ts"},"src/evaluation.ts":{"./state":"src/state.ts"},"src/rendering.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./types":"src/types.ts"},"src/persistence.ts":{"./state":"src/state.ts"},"src/input.ts":{"./constants":"src/constants.ts","./state":"src/state.ts","./evaluation":"src/evaluation.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";
// ─── Entry Point ────────────────────────────────────────────────────
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const constants_1 = require("./constants");
const state_1 = require("./state");
const rendering = __importStar(require("./rendering"));
const persistence = __importStar(require("./persistence"));
const input = __importStar(require("./input"));
const evaluation_1 = require("./evaluation");
// ─── Setup DOM ──────────────────────────────────────────────────────
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);
rendering.initRendering(canvas);
// Camera state
const cam = { worldX: -300, worldY: -100, zoom: 1 };
// ─── Resize handling ──────────────────────────────────────────────
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
// ─── Clock ticker ─────────────────────────────────────────────────
const clockTimers = new Map();
function startClocks() {
for (const [, timerId] of clockTimers)
clearInterval(timerId);
clockTimers.clear();
for (const node of state_1.scene.nodes) {
if (node.type === 'clock' && node.clockRate) {
const interval = Math.round(1000 / node.clockRate);
const timerId = setInterval(() => {
node.outputState = !node.outputState;
(0, evaluation_1.evaluateDownstream)(node.id);
persistence.save();
}, interval);
clockTimers.set(node.id, timerId);
}
}
}
// ─── Palette sidebar ──────────────────────────────────────────────
const palette = document.createElement('div');
palette.id = 'palette';
document.body.appendChild(palette);
function buildPalette() {
palette.innerHTML = '';
const title = document.createElement('div');
title.className = 'palette-title';
title.textContent = 'NODES';
palette.appendChild(title);
for (const [type, info] of Object.entries(constants_1.NODE_TYPES)) {
const btn = document.createElement('button');
btn.className = 'palette-btn';
btn.textContent = info.label;
btn.addEventListener('click', () => {
const cx = cam.worldX + canvas.width / (2 * cam.zoom);
const cy = cam.worldY + canvas.height / (2 * cam.zoom);
input.createNodeAt(type, cx, cy);
startClocks();
});
palette.appendChild(btn);
}
}
buildPalette();
// ─── Wire up modules for circular dep resolution ──────────────
input.setRenderingModule(rendering);
input.setStateModule({ createNode: state_1.createNode });
input.setSaveFn(() => persistence.save());
// Also expose on window as fallback
window.__renderingModule = rendering;
window.__stateModule = { createNode: state_1.createNode };
// ─── Setup input handlers ────────────────────────────────────────
input.setupInput(canvas, cam);
// ─── Load saved scene or create demo ────────────────────────────
if (!persistence.load()) {
// Create a small demo circuit
const t1 = (0, state_1.createNode)('toggle', 150, -80);
const t2 = (0, state_1.createNode)('toggle', 150, 80);
const and = (0, state_1.createNode)('and', 350, 0);
const lamp = (0, state_1.createNode)('lamp', 550, 0);
state_1.scene.wires.push({ fromId: t1.id, fromPort: 0, toId: and.id, toPort: 0 });
state_1.scene.wires.push({ fromId: t2.id, fromPort: 0, toId: and.id, toPort: 1 });
state_1.scene.wires.push({ fromId: and.id, fromPort: 0, toId: lamp.id, toPort: 0 });
(0, evaluation_1.evaluateAll)();
}
startClocks();
// ─── Main render loop ──────────────────────────────────────────
let cycleRejectTimer = 0;
function frame() {
// Handle cycle reject flash timer
if (input.isCycleReject()) {
cycleRejectTimer++;
if (cycleRejectTimer > 15) {
input.clearCycleReject();
cycleRejectTimer = 0;
}
}
rendering.render(cam);
// Draw wire preview during drag
const mode = input.getMode();
if (mode === 'creatingWire') {
const wireSrc = input.getWireSource();
if (wireSrc) {
const fromPos = getPortWorldPos(wireSrc.nodeId, wireSrc.portIndex);
if (fromPos) {
rendering.drawWirePreview(fromPos, input.lastCursorWorld, cam, input.isCycleReject());
}
}
}
// Draw selection highlight
if (input.selectedNodeId) {
rendering.drawSelection(input.selectedNodeId, cam);
}
requestAnimationFrame(frame);
}
// Helper to get port world position for wire preview (mirrors rendering.ts logic)
function getPortWorldPos(nodeId, portIndex) {
const node = state_1.scene.nodes.find(n => n.id === nodeId);
if (!node)
return null;
const info = constants_1.NODE_TYPES[node.type];
// Try output ports first (wire source is always an output port)
if (portIndex < info.outputs) {
const totalPorts = Math.max(info.inputs, info.outputs);
const totalHeight = Math.max(totalPorts, 1) * 28 - 28;
const startY = node.y - totalHeight / 2 + 14;
return { x: node.x + 60, y: startY + portIndex * 28 };
}
// Try input ports
if (portIndex < info.inputs) {
const totalPorts = Math.max(info.inputs, info.outputs);
const totalHeight = Math.max(totalPorts, 1) * 28 - 28;
const startY = node.y - totalHeight / 2 + 14;
return { x: node.x - 60, y: startY + portIndex * 28 };
}
return null;
}
requestAnimationFrame(frame);
};
// ── module: src/constants.ts ──
__mods["src/constants.ts"] = function (exports, require, module) {
"use strict";
// ─── Tunable Constants ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.NODE_TYPES = exports.DEFAULT_CLOCK_RATE = exports.CLOCK_RATES = exports.GRID_SIZE = exports.PORT_SPACING = exports.NODE_HEADER_HEIGHT = exports.NODE_WIDTH = exports.PORT_HIT_TOLERANCE = exports.PORT_RADIUS = exports.ZOOM_MAX = exports.ZOOM_MIN = exports.COLORS = void 0;
exports.COLORS = {
bg: '#1a1d23',
gridDot: '#2a2e35',
nodeBg: '#262a33',
nodeBorder: '#3a3f4b',
nodeHover: '#303540',
nodeSelected: '#4a9eff',
portInput: '#5b8def', // cool blue
portOutput: '#ef7c5b', // warm orange
wireOff: '#5a6070',
wireOn: '#4aff80',
wireDragging: '#ffcc33',
wireCycleReject: '#ff3344',
textPrimary: '#e0e4ea',
textSecondary: '#8a90a0',
paletteBg: '#1e2128',
paletteBorder: '#3a3f4b',
lampOff: '#555566',
lampOn: '#4aff80',
clockPulse: '#ffaa33',
};
exports.ZOOM_MIN = 0.5;
exports.ZOOM_MAX = 2.0;
exports.PORT_RADIUS = 7;
exports.PORT_HIT_TOLERANCE = 12; // world coords, expanded at low zoom
exports.NODE_WIDTH = 120;
exports.NODE_HEADER_HEIGHT = 30;
exports.PORT_SPACING = 28;
exports.GRID_SIZE = 24;
exports.CLOCK_RATES = [0.5, 1, 2, 4]; // Hz
exports.DEFAULT_CLOCK_RATE = 1;
exports.NODE_TYPES = {
toggle: { label: 'Toggle', inputs: 0, outputs: 1 },
clock: { label: 'Clock', inputs: 0, outputs: 1 },
and: { label: 'AND', inputs: 2, outputs: 1 },
or: { label: 'OR', inputs: 2, outputs: 1 },
not: { label: 'NOT', inputs: 1, outputs: 1 },
xor: { label: 'XOR', inputs: 2, outputs: 1 },
nand: { label: 'NAND', inputs: 2, outputs: 1 },
nor: { label: 'NOR', inputs: 2, outputs: 1 },
lamp: { label: 'Lamp', inputs: 1, outputs: 0 },
hex: { label: 'Hex', inputs: 4, outputs: 0 },
};
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
// ─── Data Model Types ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// ─── Scene State Management ──────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.scene = void 0;
exports.createNode = createNode;
exports.removeNode = removeNode;
exports.getNode = getNode;
exports.addWire = addWire;
exports.removeWiresForNode = removeWiresForNode;
exports.getInputCount = getInputCount;
exports.getOutputCount = getOutputCount;
exports.wouldCreateCycle = wouldCreateCycle;
exports.topologicalOrder = topologicalOrder;
exports.serializeScene = serializeScene;
exports.deserializeScene = deserializeScene;
const constants_1 = require("./constants");
let nextId = 1;
function genId() {
return `n${nextId++}`;
}
exports.scene = { nodes: [], wires: [] };
// ─── Node operations ────────────────────────────────────────────────
function createNode(type, x, y) {
const node = {
id: genId(),
type,
x,
y,
outputState: false,
toggleOn: type === 'toggle' ? false : undefined,
clockRate: type === 'clock' ? constants_1.DEFAULT_CLOCK_RATE : undefined,
};
exports.scene.nodes.push(node);
return node;
}
function removeNode(id) {
exports.scene.nodes = exports.scene.nodes.filter(n => n.id !== id);
exports.scene.wires = exports.scene.wires.filter(w => w.fromId !== id && w.toId !== id);
}
function getNode(id) {
return exports.scene.nodes.find(n => n.id === id);
}
// ─── Wire operations ────────────────────────────────────────────────
function addWire(fromId, fromPort, toId, toPort) {
// Remove existing wire on this input port (one wire per input)
exports.scene.wires = exports.scene.wires.filter(w => !(w.toId === toId && w.toPort === toPort));
// Check for duplicate
if (exports.scene.wires.some(w => w.fromId === fromId && w.fromPort === fromPort && w.toId === toId && w.toPort === toPort)) {
return false;
}
exports.scene.wires.push({ fromId, fromPort, toId, toPort });
return true;
}
function removeWiresForNode(id) {
exports.scene.wires = exports.scene.wires.filter(w => w.fromId !== id && w.toId !== id);
}
// ─── Port info helpers ──────────────────────────────────────────────
function getInputCount(type) {
return constants_1.NODE_TYPES[type]?.inputs ?? 0;
}
function getOutputCount(type) {
return constants_1.NODE_TYPES[type]?.outputs ?? 0;
}
// ─── Cycle detection (DFS on wire graph) ────────────────────────────
function wouldCreateCycle(fromId, fromPort, toId, toPort) {
// Adding a wire from `fromId` output to `toId` input creates a cycle if
// there's already a path from `toId` back to `fromId`.
const visited = new Set();
const stack = [toId];
while (stack.length > 0) {
const current = stack.pop();
if (current === fromId)
return true;
if (visited.has(current))
continue;
visited.add(current);
// Follow wires downstream from `current`
for (const w of exports.scene.wires) {
if (w.fromId === current) {
stack.push(w.toId);
}
}
}
return false;
}
// ─── Topological sort for evaluation ────────────────────────────────
function topologicalOrder() {
// Kahn's algorithm on the wire graph
const inDegree = {};
const adjList = {};
for (const node of exports.scene.nodes) {
inDegree[node.id] = 0;
adjList[node.id] = [];
}
for (const w of exports.scene.wires) {
if (inDegree[w.toId] !== undefined && inDegree[w.fromId] !== undefined) {
adjList[w.fromId].push(w.toId);
inDegree[w.toId]++;
}
}
const queue = [];
for (const id of Object.keys(inDegree)) {
if (inDegree[id] === 0)
queue.push(id);
}
const result = [];
while (queue.length > 0) {
const node = queue.shift();
result.push(node);
for (const neighbor of adjList[node]) {
inDegree[neighbor]--;
if (inDegree[neighbor] === 0)
queue.push(neighbor);
}
}
// If not all nodes are in the result, there's a cycle
return result.length === exports.scene.nodes.length ? result : null;
}
// ─── Serialization ──────────────────────────────────────────────────
function serializeScene() {
return JSON.stringify(exports.scene);
}
function deserializeScene(json) {
const data = JSON.parse(json);
exports.scene.nodes = data.nodes;
exports.scene.wires = data.wires;
// Restore nextId to avoid conflicts
let maxNum = 0;
for (const n of exports.scene.nodes) {
const num = parseInt(n.id.slice(1), 10);
if (!isNaN(num) && num > maxNum)
maxNum = num;
}
nextId = maxNum + 1;
}
};
// ── module: src/evaluation.ts ──
__mods["src/evaluation.ts"] = function (exports, require, module) {
"use strict";
// ─── Reactive Evaluation Engine ──────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.evaluateAll = evaluateAll;
exports.evaluateDownstream = evaluateDownstream;
exports.toggleInput = toggleInput;
const state_1 = require("./state");
// Gate truth tables
const GATES = {
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),
};
// Get input values for a node from its connected wires
function getInputValues(nodeId) {
const inputs = [];
for (const w of state_1.scene.wires) {
if (w.toId === nodeId) {
const srcNode = (0, state_1.getNode)(w.fromId);
inputs[w.toPort] = srcNode?.outputState ?? false;
}
}
return inputs;
}
// Evaluate a single node based on its type and input values
function evaluateNode(node) {
switch (node.type) {
case 'toggle':
node.outputState = !!node.toggleOn;
break;
case 'clock':
// Clock state is set externally by the clock ticker
break;
case 'lamp':
case 'hex':
{
const vals = getInputValues(node.id);
node.outputState = vals[0] ?? false;
}
break;
default: {
const gateFn = GATES[node.type];
if (gateFn) {
const vals = getInputValues(node.id);
node.outputState = gateFn(vals[0] ?? false, vals[1]);
}
}
}
}
// Full topological evaluation of the entire graph
function evaluateAll() {
// Simple approach: iterate until stable (safe for combinational circuits)
let changed = true;
let iterations = 0;
const maxIterations = state_1.scene.nodes.length + 2;
while (changed && iterations < maxIterations) {
changed = false;
iterations++;
for (const node of state_1.scene.nodes) {
const oldState = node.outputState;
evaluateNode(node);
if (node.outputState !== oldState)
changed = true;
}
}
}
// Find all downstream nodes from a source using BFS on wire graph
function findDownstreamNodes(sourceId) {
const result = new Set();
const queue = [sourceId];
while (queue.length > 0) {
const current = queue.shift();
for (const w of state_1.scene.wires) {
if (w.fromId === current && !result.has(w.toId)) {
result.add(w.toId);
queue.push(w.toId);
}
}
}
return result;
}
// Evaluate only downstream nodes from a source node
function evaluateDownstream(sourceId) {
const downstream = findDownstreamNodes(sourceId);
// Also include the source itself (for toggle/clock state update)
downstream.add(sourceId);
for (const id of downstream) {
const node = (0, state_1.getNode)(id);
if (node)
evaluateNode(node);
}
}
// Toggle a toggle node and re-evaluate downstream
function toggleInput(id) {
const node = (0, state_1.getNode)(id);
if (!node || node.type !== 'toggle')
return;
node.toggleOn = !node.toggleOn;
evaluateDownstream(id);
}
};
// ── module: src/rendering.ts ──
__mods["src/rendering.ts"] = function (exports, require, module) {
"use strict";
// ─── Canvas Rendering ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.initRendering = initRendering;
exports.worldToScreen = worldToScreen;
exports.screenToWorld = screenToWorld;
exports.render = render;
exports.drawWirePreview = drawWirePreview;
exports.hitTestPort = hitTestPort;
exports.hitTestNode = hitTestNode;
exports.drawSelection = drawSelection;
const constants_1 = require("./constants");
const state_1 = require("./state");
let canvas;
let ctx;
function initRendering(cvs) {
canvas = cvs;
ctx = cvs.getContext('2d');
}
// ─── Coordinate transforms ──────────────────────────────────────────
function worldToScreen(wp, cam) {
return { x: (wp.x - cam.worldX) * cam.zoom, y: (wp.y - cam.worldY) * cam.zoom };
}
function screenToWorld(sp, cam) {
return { x: sp.x / cam.zoom + cam.worldX, y: sp.y / cam.zoom + cam.worldY };
}
// ─── Main render loop ──────────────────────────────────────────────
function render(cam) {
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = constants_1.COLORS.bg;
ctx.fillRect(0, 0, w, h);
drawGrid(cam);
drawWires(cam);
drawNodes(cam);
}
// ─── Grid ──────────────────────────────────────────────────────────
function drawGrid(cam) {
const w = canvas.width;
const h = canvas.height;
// Visible world bounds
const left = cam.worldX;
const top = cam.worldY;
const right = (w / cam.zoom) + cam.worldX;
const bottom = (h / cam.zoom) + cam.worldY;
ctx.fillStyle = constants_1.COLORS.gridDot;
// Snap to grid
const startX = Math.floor(left / constants_1.GRID_SIZE) * constants_1.GRID_SIZE;
const startY = Math.floor(top / constants_1.GRID_SIZE) * constants_1.GRID_SIZE;
for (let wx = startX; wx < right; wx += constants_1.GRID_SIZE) {
for (let wy = startY; wy < bottom; wy += constants_1.GRID_SIZE) {
const sp = worldToScreen({ x: wx, y: wy }, cam);
ctx.beginPath();
ctx.arc(sp.x, sp.y, 1.2 * cam.zoom, 0, Math.PI * 2);
ctx.fill();
}
}
}
// ─── Wires ─────────────────────────────────────────────────────────
function getPortWorldPos(nodeId, portIndex, isInput) {
const node = (0, state_1.getNode)(nodeId);
if (!node)
return null;
const count = isInput ? (0, state_1.getInputCount)(node.type) : (0, state_1.getOutputCount)(node.type);
if (portIndex < 0 || portIndex >= count)
return null;
// Calculate total height of ports area
const totalHeight = Math.max(count, 1) * constants_1.PORT_SPACING - constants_1.PORT_SPACING;
const startY = node.y - totalHeight / 2 + constants_1.PORT_SPACING / 2;
const x = isInput ? node.x - constants_1.NODE_WIDTH / 2 : node.x + constants_1.NODE_WIDTH / 2;
const y = startY + portIndex * constants_1.PORT_SPACING;
return { x, y };
}
function drawWire(fromPos, toPos, cam, energized, highlightColor) {
const sp1 = worldToScreen(fromPos, cam);
const sp2 = worldToScreen(toPos, cam);
// Bezier control points (horizontal curve)
const dx = Math.abs(sp2.x - sp1.x);
const cpOffset = Math.max(dx * 0.4, 30);
ctx.beginPath();
ctx.moveTo(sp1.x, sp1.y);
ctx.bezierCurveTo(sp1.x + cpOffset, sp1.y, sp2.x - cpOffset, sp2.y, sp2.x, sp2.y);
const color = highlightColor ?? (energized ? constants_1.COLORS.wireOn : constants_1.COLORS.wireOff);
ctx.strokeStyle = color;
ctx.lineWidth = energized ? 3 : 2.5;
if (energized) {
ctx.shadowColor = constants_1.COLORS.wireOn;
ctx.shadowBlur = 8 * cam.zoom;
}
else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
ctx.stroke();
ctx.shadowBlur = 0; // reset
}
function drawWires(cam) {
for (const wire of state_1.scene.wires) {
const fromPos = getPortWorldPos(wire.fromId, wire.fromPort, false);
const toPos = getPortWorldPos(wire.toId, wire.toPort, true);
if (!fromPos || !toPos)
continue;
const srcNode = (0, state_1.getNode)(wire.fromId);
const energized = !!srcNode?.outputState;
drawWire(fromPos, toPos, cam, energized);
}
}
// ─── Nodes ─────────────────────────────────────────────────────────
function drawNode(node, cam) {
const hw = constants_1.NODE_WIDTH / 2 * cam.zoom;
const hh = (constants_1.NODE_HEADER_HEIGHT + Math.max((0, state_1.getInputCount)(node.type), (0, state_1.getOutputCount)(node.type)) * constants_1.PORT_SPACING + 10) / 2 * cam.zoom;
const sp = worldToScreen({ x: node.x, y: node.y }, cam);
const rx = sp.x - hw;
const ry = sp.y - hh;
const rw = hw * 2;
const rh = hh * 2;
const r = 8 * cam.zoom; // corner radius
// Node body
ctx.beginPath();
ctx.roundRect(rx, ry, rw, rh, r);
// Tint based on output state
if (node.outputState) {
ctx.fillStyle = '#2a3540';
}
else {
ctx.fillStyle = constants_1.COLORS.nodeBg;
}
ctx.fill();
ctx.strokeStyle = constants_1.COLORS.nodeBorder;
ctx.lineWidth = 1.5 * cam.zoom;
ctx.stroke();
// Header bar with node name
const headerH = constants_1.NODE_HEADER_HEIGHT * cam.zoom;
ctx.beginPath();
ctx.roundRect(rx, ry, rw, headerH, [r, r, 0, 0]);
ctx.fillStyle = '#2e3340';
ctx.fill();
// Node label
const label = node.type.toUpperCase();
ctx.fillStyle = constants_1.COLORS.textPrimary;
ctx.font = `bold ${12 * cam.zoom}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, sp.x, ry + headerH / 2);
// Draw ports
drawPorts(node, cam);
// Special rendering for lamp and hex display
if (node.type === 'lamp') {
drawLamp(node, cam);
}
else if (node.type === 'hex') {
drawHexDisplay(node, cam);
}
else if (node.type === 'toggle') {
drawToggleState(node, cam);
}
else if (node.type === 'clock') {
drawClockRate(node, cam);
}
}
function drawPorts(node, cam) {
const inputCount = (0, state_1.getInputCount)(node.type);
const outputCount = (0, state_1.getOutputCount)(node.type);
// Input ports (left side)
for (let i = 0; i < inputCount; i++) {
const pos = getPortWorldPos(node.id, i, true);
const sp = worldToScreen(pos, cam);
ctx.beginPath();
ctx.arc(sp.x, sp.y, constants_1.PORT_RADIUS * cam.zoom, 0, Math.PI * 2);
ctx.fillStyle = constants_1.COLORS.portInput;
ctx.fill();
ctx.strokeStyle = '#1a1d23';
ctx.lineWidth = 1.5 * cam.zoom;
ctx.stroke();
}
// Output ports (right side)
for (let i = 0; i < outputCount; i++) {
const pos = getPortWorldPos(node.id, i, false);
const sp = worldToScreen(pos, cam);
ctx.beginPath();
ctx.arc(sp.x, sp.y, constants_1.PORT_RADIUS * cam.zoom, 0, Math.PI * 2);
ctx.fillStyle = constants_1.COLORS.portOutput;
ctx.fill();
ctx.strokeStyle = '#1a1d23';
ctx.lineWidth = 1.5 * cam.zoom;
ctx.stroke();
}
}
function drawLamp(node, cam) {
const sp = worldToScreen({ x: node.x, y: node.y + 8 }, cam);
const radius = 14 * cam.zoom;
// Lamp glow
if (node.outputState) {
ctx.beginPath();
ctx.arc(sp.x, sp.y, radius * 2.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(74, 255, 128, 0.15)';
ctx.fill();
ctx.beginPath();
ctx.arc(sp.x, sp.y, radius * 1.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(74, 255, 128, 0.25)';
ctx.fill();
}
// Lamp body
ctx.beginPath();
ctx.arc(sp.x, sp.y, radius, 0, Math.PI * 2);
ctx.fillStyle = node.outputState ? constants_1.COLORS.lampOn : constants_1.COLORS.lampOff;
ctx.fill();
ctx.strokeStyle = '#1a1d23';
ctx.lineWidth = 2 * cam.zoom;
ctx.stroke();
if (node.outputState) {
ctx.shadowColor = constants_1.COLORS.wireOn;
ctx.shadowBlur = 15 * cam.zoom;
ctx.fill();
ctx.shadowBlur = 0;
}
}
function drawHexDisplay(node, cam) {
// Get the 4 input bits (LSB at port 0, MSB at port 3)
const bits = [];
for (const w of state_1.scene.wires) {
if (w.toId === node.id && w.toPort >= 0 && w.toPort <= 3) {
const srcNode = (0, state_1.getNode)(w.fromId);
bits[w.toPort] = srcNode?.outputState ?? false;
}
}
// Calculate hex value: port 0 = LSB, port 3 = MSB
let val = 0;
for (let i = 0; i < 4; i++) {
if (bits[i])
val |= (1 << i);
}
const hexChars = '0123456789ABCDEF';
const char = hexChars[val] || '?';
const sp = worldToScreen({ x: node.x, y: node.y + 5 }, cam);
ctx.fillStyle = constants_1.COLORS.wireOn;
ctx.font = `bold ${24 * cam.zoom}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (val > 0) {
ctx.shadowColor = constants_1.COLORS.wireOn;
ctx.shadowBlur = 10 * cam.zoom;
}
ctx.fillText(char, sp.x, sp.y);
ctx.shadowBlur = 0;
}
function drawToggleState(node, cam) {
const sp = worldToScreen({ x: node.x, y: node.y + 5 }, cam);
ctx.fillStyle = constants_1.COLORS.textSecondary;
ctx.font = `${10 * cam.zoom}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.toggleOn ? 'ON' : 'OFF', sp.x, sp.y);
}
function drawClockRate(node, cam) {
const sp = worldToScreen({ x: node.x, y: node.y + 5 }, cam);
ctx.fillStyle = constants_1.COLORS.textSecondary;
ctx.font = `${10 * cam.zoom}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${node.clockRate ?? 1}Hz`, sp.x, sp.y);
}
function drawNodes(cam) {
for (const node of state_1.scene.nodes) {
drawNode(node, cam);
}
}
// ─── Wire preview during drag ──────────────────────────────────────
function drawWirePreview(fromPos, cursorWorld, cam, cycleReject) {
const sp1 = worldToScreen(fromPos, cam);
const sp2 = worldToScreen(cursorWorld, cam);
const dx = Math.abs(sp2.x - sp1.x);
const cpOffset = Math.max(dx * 0.4, 30);
ctx.beginPath();
ctx.moveTo(sp1.x, sp1.y);
ctx.bezierCurveTo(sp1.x + cpOffset, sp1.y, sp2.x - cpOffset, sp2.y, sp2.x, sp2.y);
ctx.strokeStyle = cycleReject ? constants_1.COLORS.wireCycleReject : constants_1.COLORS.wireDragging;
ctx.lineWidth = 3 * cam.zoom;
ctx.setLineDash([6 * cam.zoom, 4 * cam.zoom]);
ctx.stroke();
ctx.setLineDash([]);
}
// ─── Hit testing ──────────────────────────────────────────────────
function hitTestPort(worldPos, cam) {
const tolerance = Math.max(constants_1.PORT_HIT_TOLERANCE / cam.zoom, constants_1.PORT_RADIUS);
for (const node of state_1.scene.nodes) {
// Check input ports
const inputCount = (0, state_1.getInputCount)(node.type);
for (let i = 0; i < inputCount; i++) {
const pos = getPortWorldPos(node.id, i, true);
if (!pos)
continue;
const dx = worldPos.x - pos.x;
const dy = worldPos.y - pos.y;
if (dx * dx + dy * dy < tolerance * tolerance) {
return { nodeId: node.id, portIndex: i, isInput: true };
}
}
// Check output ports
const outputCount = (0, state_1.getOutputCount)(node.type);
for (let i = 0; i < outputCount; i++) {
const pos = getPortWorldPos(node.id, i, false);
if (!pos)
continue;
const dx = worldPos.x - pos.x;
const dy = worldPos.y - pos.y;
if (dx * dx + dy * dy < tolerance * tolerance) {
return { nodeId: node.id, portIndex: i, isInput: false };
}
}
}
return null;
}
function hitTestNode(worldPos, cam) {
for (let i = state_1.scene.nodes.length - 1; i >= 0; i--) {
const node = state_1.scene.nodes[i];
const hw = constants_1.NODE_WIDTH / 2;
const portCount = Math.max((0, state_1.getInputCount)(node.type), (0, state_1.getOutputCount)(node.type));
const hh = (constants_1.NODE_HEADER_HEIGHT + portCount * constants_1.PORT_SPACING + 10) / 2;
if (worldPos.x >= node.x - hw && worldPos.x <= node.x + hw &&
worldPos.y >= node.y - hh && worldPos.y <= node.y + hh) {
return node.id;
}
}
return null;
}
// ─── Selection highlight ──────────────────────────────────────────
function drawSelection(nodeId, cam) {
const node = (0, state_1.getNode)(nodeId);
if (!node)
return;
const hw = constants_1.NODE_WIDTH / 2 * cam.zoom + 3 * cam.zoom;
const portCount = Math.max((0, state_1.getInputCount)(node.type), (0, state_1.getOutputCount)(node.type));
const hh = (constants_1.NODE_HEADER_HEIGHT + portCount * constants_1.PORT_SPACING + 10) / 2 * cam.zoom + 3 * cam.zoom;
const sp = worldToScreen({ x: node.x, y: node.y }, cam);
ctx.strokeStyle = constants_1.COLORS.nodeSelected;
ctx.lineWidth = 2 * cam.zoom;
ctx.setLineDash([4 * cam.zoom, 4 * cam.zoom]);
ctx.strokeRect(sp.x - hw, sp.y - hh, hw * 2, hh * 2);
ctx.setLineDash([]);
}
};
// ── module: src/persistence.ts ──
__mods["src/persistence.ts"] = function (exports, require, module) {
"use strict";
// ─── localStorage Persistence ──────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.save = save;
exports.load = load;
const state_1 = require("./state");
const STORAGE_KEY = 'logic-circuit-editor-scene';
function save() {
try {
const data = (0, state_1.serializeScene)();
localStorage.setItem(STORAGE_KEY, data);
}
catch (e) {
// Storage full or unavailable — silently fail
}
}
function load() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
(0, state_1.deserializeScene)(data);
return true;
}
}
catch (e) {
// Corrupted data — ignore
}
return false;
}
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
// ─── Input Handling ────────────────────────────────────────────────
Object.defineProperty(exports, "__esModule", { value: true });
exports.lastCursorWorld = exports.selectedNodeId = void 0;
exports.getMode = getMode;
exports.getWireSource = getWireSource;
exports.setupInput = setupInput;
exports.setCycleReject = setCycleReject;
exports.isCycleReject = isCycleReject;
exports.clearCycleReject = clearCycleReject;
exports.createNodeAt = createNodeAt;
exports.setRenderingModule = setRenderingModule;
exports.setStateModule = setStateModule;
exports.setSaveFn = setSaveFn;
const constants_1 = require("./constants");
const state_1 = require("./state");
const evaluation_1 = require("./evaluation");
let state = { mode: 'idle' };
exports.selectedNodeId = null;
// Track cursor world position for wire preview
exports.lastCursorWorld = { x: 0, y: 0 };
// ─── Public API for the main loop to query ────────────────────────
function getMode() { return state.mode; }
function getWireSource() {
if (state.mode === 'creatingWire' && state.wireFromId !== undefined) {
return { nodeId: state.wireFromId, portIndex: state.wireFromPort };
}
return null;
}
// ─── Canvas event handlers ────────────────────────────────────────
function setupInput(canvas, cam) {
canvas.addEventListener('mousedown', (e) => onMouseDown(e, cam));
canvas.addEventListener('mousemove', (e) => onMouseMove(e, cam));
canvas.addEventListener('mouseup', (e) => onMouseUp(e, cam));
canvas.addEventListener('wheel', (e) => onWheel(e, cam), { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Keyboard
window.addEventListener('keydown', onKeyDown);
}
function getMouseWorldPos(e, cam) {
const rect = e.target.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
return {
x: screenX / cam.zoom + cam.worldX,
y: screenY / cam.zoom + cam.worldY,
};
}
function onMouseDown(e, cam) {
if (e.button !== 0)
return; // only left click
const worldPos = getMouseWorldPos(e, cam);
// Check port hit first (for wire creation)
const portHit = importRendering().hitTestPort(worldPos, cam);
if (portHit) {
state.mode = 'creatingWire';
state.wireFromId = portHit.nodeId;
state.wireFromPort = portHit.portIndex;
exports.selectedNodeId = null; // deselect when starting wire drag
return;
}
// Check node hit (for dragging)
const nodeId = importRendering().hitTestNode(worldPos, cam);
if (nodeId) {
state.mode = 'draggingNode';
state.nodeId = nodeId;
const node = (0, state_1.getNode)(nodeId);
state.dragOffsetX = worldPos.x - node.x;
state.dragOffsetY = worldPos.y - node.y;
exports.selectedNodeId = nodeId;
return;
}
// Empty space — start panning
state.mode = 'panning';
const rect = e.target.getBoundingClientRect();
state.panStartScreenX = e.clientX - rect.left;
state.panStartScreenY = e.clientY - rect.top;
state.panStartWorldX = cam.worldX;
state.panStartWorldY = cam.worldY;
exports.selectedNodeId = null; // deselect on empty space click
}
function onMouseMove(e, cam) {
const worldPos = getMouseWorldPos(e, cam);
exports.lastCursorWorld = worldPos;
switch (state.mode) {
case 'panning': {
const psx = state.panStartScreenX ?? 0;
const psy = state.panStartScreenY ?? 0;
const pwx = state.panStartWorldX ?? cam.worldX;
const pwy = state.panStartWorldY ?? cam.worldY;
const rect = e.target.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
cam.worldX = pwx - (screenX - psx) / cam.zoom;
cam.worldY = pwy - (screenY - psy) / cam.zoom;
break;
}
case 'draggingNode': {
const dofx = state.dragOffsetX ?? 0;
const dofy = state.dragOffsetY ?? 0;
if (state.nodeId) {
const node = (0, state_1.getNode)(state.nodeId);
if (node) {
node.x = worldPos.x - dofx;
node.y = worldPos.y - dofy;
}
}
break;
}
case 'creatingWire':
// Wire preview is drawn in the main render loop
break;
}
}
function onMouseUp(e, cam) {
const prevMode = state.mode;
if (prevMode === 'draggingNode' && state.nodeId) {
// Check for zero-distance click on toggle node
const worldPos = getMouseWorldPos(e, cam);
const nodeId = importRendering().hitTestNode(worldPos, cam);
console.log('mouseup: prevMode=draggingNode, state.nodeId=' + state.nodeId + ', hitTestNode=' + nodeId + ', worldPos=' + JSON.stringify(worldPos));
if (nodeId === state.nodeId) {
const node = (0, state_1.getNode)(nodeId);
console.log('same node, type=' + node?.type);
if (node?.type === 'toggle') {
(0, evaluation_1.toggleInput)(nodeId);
saveNow();
console.log('toggled!');
}
}
}
else if (prevMode === 'creatingWire' && state.wireFromId !== undefined) {
// Try to connect to a port under cursor
const worldPos = getMouseWorldPos(e, cam);
const portHit = importRendering().hitTestPort(worldPos, cam);
if (portHit) {
const fromNode = (0, state_1.getNode)(state.wireFromId);
const toNode = (0, state_1.getNode)(portHit.nodeId);
// Don't connect a node to itself
if (fromNode && toNode && fromNode.id !== toNode.id) {
// Determine direction: output port → input port
const fromIsOutput = state.wireFromPort < (0, state_1.getOutputCount)(fromNode.type);
const toIsInput = portHit.portIndex < (0, state_1.getInputCount)(toNode.type);
let validDir = false;
let fnId = '';
let fpIdx = -1;
let tnId = '';
let tpIdx = -1;
if (fromIsOutput && toIsInput) {
fnId = state.wireFromId;
fpIdx = state.wireFromPort;
tnId = portHit.nodeId;
tpIdx = portHit.portIndex;
validDir = true;
}
else if (!fromIsOutput && !toIsInput) {
// Both are output ports — invalid, do nothing
}
else {
// Reverse: the "to" is actually the source
fnId = portHit.nodeId;
fpIdx = portHit.portIndex;
tnId = state.wireFromId;
tpIdx = state.wireFromPort;
validDir = true;
}
if (validDir) {
// Check for cycles
if ((0, state_1.wouldCreateCycle)(fnId, fpIdx, tnId, tpIdx)) {
setCycleReject(true);
}
else {
(0, state_1.addWire)(fnId, fpIdx, tnId, tpIdx);
(0, evaluation_1.evaluateDownstream)(fnId);
saveNow();
}
}
}
}
}
state = { mode: 'idle' };
}
function onWheel(e, cam) {
e.preventDefault();
const rect = e.target.getBoundingClientRect();
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
// World point under cursor before zoom
const worldBeforeX = screenX / cam.zoom + cam.worldX;
const worldBeforeY = screenY / cam.zoom + cam.worldY;
// Apply zoom
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
cam.zoom = Math.max(constants_1.ZOOM_MIN, Math.min(constants_1.ZOOM_MAX, cam.zoom * zoomFactor));
// Adjust world origin so the point under cursor stays fixed
cam.worldX = worldBeforeX - screenX / cam.zoom;
cam.worldY = worldBeforeY - screenY / cam.zoom;
}
function onKeyDown(e) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (exports.selectedNodeId) {
(0, state_1.removeNode)(exports.selectedNodeId);
exports.selectedNodeId = null;
saveNow();
}
}
}
// ─── Cycle reject visual feedback ────────────────────────────────
let cycleRejectActive = false;
function setCycleReject(v) { cycleRejectActive = v; }
function isCycleReject() { return cycleRejectActive; }
function clearCycleReject() { cycleRejectActive = false; }
// ─── Palette / node creation ──────────────────────────────────────
function createNodeAt(type, worldX, worldY) {
const node = importState().createNode(type, worldX, worldY);
(0, evaluation_1.evaluateDownstream)(node.id);
saveNow();
}
// ─── Lazy imports to avoid circular deps ────────────────────────
let _renderingModule;
function importRendering() {
if (!_renderingModule) {
return window.__renderingModule;
}
return _renderingModule;
}
function setRenderingModule(mod) {
_renderingModule = mod;
}
let _stateModule;
function importState() {
if (!_stateModule) {
return window.__stateModule;
}
return _stateModule;
}
function setStateModule(mod) {
_stateModule = mod;
}
let _saveFn = null;
function saveNow() {
if (_saveFn)
_saveFn();
}
function setSaveFn(fn) {
_saveFn = fn;
}
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>