Compare commits

...

14 Commits

Author SHA1 Message Date
Jose Luis
bbde11dfc7 fix: BUS_OUT port and cable visual state reading wrong output value
Input port rendering on standard gates and components read srcGate.value
to determine active state, but for multi-output gates (BUS_OUT, COMPONENT)
.value only holds port 0's value. Connections from port N>0 always showed
port 0's state visually despite working correctly at the logic level.

Extract getSourcePortValue() helper that checks outputValues[fromPort]
for multi-output sources, and apply it consistently across all three
input port renderers (drawGate, drawBusGate, drawComponentGate).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 14:10:53 +01:00
Jose Luis
a1cc631406 fix: component port labels showing wrong name after editing blueprint
The label lookup in drawComponentGate read from gate.component (potentially
stale copy) while gateOutputCount read from state.customComponents (updated
definition), causing a mismatch — fewer ports but old outputIds, so the
first (deleted) output's label was shown instead of the surviving one.

Three fixes:
- renderer: use customComponents as authoritative source for label lookup
- saveLoad: re-link gate.component refs to customComponents after loading
- components: update existing instances even when a "new" component
  overwrites an existing definition with the same name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 14:04:42 +01:00
Jose Luis
c116b6cf84 refactor: bus terminals with single-sided pins only
BUS_IN has input pins only (left side), BUS_OUT has output pins
only (right side). No internal connections between them — BUS_OUT
reads values directly from its paired BUS_IN via busPairId. The
bus cable between them is purely visual, representing the grouped
signal bundle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:50:42 +01:00
Jose Luis
9ec3367253 feat: drag selection box to select, move, and delete multiple gates
Click and drag on empty space to draw a selection rectangle. Gates
inside the box get selected (cyan dashed outline). Drag any selected
gate to move all of them together. Delete/Backspace removes all
selected gates and their connections. Escape clears the selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:47:34 +01:00
Jose Luis
12d7331d2c refactor: bus now spawns two paired terminals with a bus cable
Instead of a single pass-through gate, shift+drag now creates two
BUS terminals (IN and OUT) connected by a thick bus cable. Internal
connections between terminals are hidden and rendered as a single
cable with /N notation and a diagonal slash. Each terminal is a
thin cyan bar that can be moved independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:43:30 +01:00
Jose Luis
99f0fefe5c feat: shift+drag to cut wires and create bus connectors
Hold Shift and drag across wires to create a BUS gate that groups
them together. The cut line shows a live preview with wire count.
BUS gates are pass-through (each input maps to its output) and
render as a thin cyan bar with ports on each side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:39:00 +01:00
Jose Luis
89d118f738 feat: keep placing mode active for multiple placements
Selected gate type stays active after placing, allowing multiple
gates of the same type without re-selecting. Right-click or Escape
to cancel placing mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:28:10 +01:00
Jose Luis
2fd22cc79d feat: persist circuit and components to localStorage
Auto-saves every 3 seconds and on page unload. Restores the full
state (circuit, camera, custom components) on page load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:25:28 +01:00
Jose Luis
5bd157c059 fix: only show port label prompt inside component editor
Double-click rename for INPUT/OUTPUT/CLOCK gates now only triggers
when inside the component editor, not in the main circuit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:24:39 +01:00
Jose Luis
eb22a5ff62 feat: double-click component gates to edit their blueprint
Opens the component editor with the internal circuit loaded for
modification. On save, updates the component definition and all
existing instances in the main circuit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:22:45 +01:00
Jose Luis
817dab43df feat: port labels on component gates + persistent internal state
Show input/output labels next to ports on custom component chips,
and persist internal gate state between evaluations so latches and
flip-flops retain their values correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:15:37 +01:00
Jose Luis
1c45dc6104 fix: complete rewrite of component evaluation system
Major fixes for custom components when used in the main circuit:

- Add outputValues[] array for multi-output component gates, so each
  output port carries its own independent value
- readSourcePort() reads the correct port value from source gates
  instead of always reading gate.value
- evaluateComponent() now uses iterative fixed-point evaluation
  (matching main evaluateAll) instead of a simple 10-pass loop
- Store inputIds/outputIds in component definition for consistent
  port-to-gate mapping across save/load
- Renderer reads per-port values for connection color and port glow
- Added debug logs for component save and evaluation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:10:28 +01:00
Jose Luis
bc8823bcd4 feat: editable labels for INPUT/OUTPUT/CLOCK gates
Double-click any INPUT, OUTPUT, or CLOCK gate to assign a custom label.
Labels are shown inside the gate and used in waveform viewer instead of
generic IN_0/OUT_0 names. Example circuits now ship with meaningful
labels (S, R, D, EN, Q, Q̅, CLK).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:06:50 +01:00
Jose Luis
6cb3f091d4 fix: dropdown menus render above component editor overlay
Lower component editor overlay z-index from 105 to 90 so toolbar
dropdown menus (z-index 150) appear on top of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:05:24 +01:00
12 changed files with 1036 additions and 149 deletions

View File

@@ -443,7 +443,7 @@ body {
height: 44px;
background: #1a1a2e;
border-bottom: 2px solid #9900ff;
z-index: 105;
z-index: 90;
display: flex;
align-items: center;
padding: 0 12px;

View File

@@ -2,9 +2,21 @@
import { initRenderer } from './renderer.js';
import { initEvents } from './events.js';
import { initPuzzleUI } from './puzzleUI.js';
import { loadFromStorage, startAutoSave } from './saveLoad.js';
import { updateComponentButtons } from './components.js';
import { evaluateAll } from './gates.js';
document.addEventListener('DOMContentLoaded', () => {
initRenderer();
initEvents();
initPuzzleUI();
// Restore previous session from localStorage
if (loadFromStorage()) {
updateComponentButtons();
evaluateAll();
}
// Auto-save every 3 seconds + on page unload
startAutoSave(3000);
});

197
js/bus.js Normal file
View File

@@ -0,0 +1,197 @@
// Bus system — shift+drag to cut wires and create paired bus terminals + cable
import { state } from './state.js';
import { getOutputPorts, getInputPorts, evaluateAll } from './gates.js';
/**
* Sample a cubic bezier curve into discrete line segments.
*/
function sampleBezier(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, steps = 24) {
const points = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const mt = 1 - t;
const mt2 = mt * mt, mt3 = mt2 * mt;
const t2 = t * t, t3 = t2 * t;
points.push({
x: mt3 * p0x + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t3 * p3x,
y: mt3 * p0y + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t3 * p3y
});
}
return points;
}
/**
* Test intersection between two line segments.
*/
function segmentIntersect(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
const dx1 = ax2 - ax1, dy1 = ay2 - ay1;
const dx2 = bx2 - bx1, dy2 = by2 - by1;
const d = dx1 * dy2 - dy1 * dx2;
if (Math.abs(d) < 1e-10) return null;
const t = ((bx1 - ax1) * dy2 - (by1 - ay1) * dx2) / d;
const u = ((bx1 - ax1) * dy1 - (by1 - ay1) * dx1) / d;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return { x: ax1 + t * dx1, y: ay1 + t * dy1, t };
}
return null;
}
/**
* Get bezier control points for a connection (matching renderer.js drawConnection).
*/
function getConnectionBezier(conn) {
const fromGate = state.gates.find(g => g.id === conn.from);
const toGate = state.gates.find(g => g.id === conn.to);
if (!fromGate || !toGate) return null;
const fromPort = getOutputPorts(fromGate)[conn.fromPort];
const toPort = getInputPorts(toGate)[conn.toPort];
if (!fromPort || !toPort) return null;
const midX = (fromPort.x + toPort.x) / 2;
return {
p0x: fromPort.x, p0y: fromPort.y,
p1x: midX, p1y: fromPort.y,
p2x: midX, p2y: toPort.y,
p3x: toPort.x, p3y: toPort.y
};
}
/**
* Find all connections that intersect a cut line.
* Returns array of { conn, hitPoint } sorted by position along the cut line.
*/
export function findIntersectingConnections(startX, startY, endX, endY) {
const results = [];
for (const conn of state.connections) {
const bez = getConnectionBezier(conn);
if (!bez) continue;
const points = sampleBezier(
bez.p0x, bez.p0y, bez.p1x, bez.p1y,
bez.p2x, bez.p2y, bez.p3x, bez.p3y
);
for (let i = 0; i < points.length - 1; i++) {
const hit = segmentIntersect(
startX, startY, endX, endY,
points[i].x, points[i].y, points[i + 1].x, points[i + 1].y
);
if (hit) {
results.push({ conn, hitPoint: hit });
break;
}
}
}
results.sort((a, b) => a.hitPoint.t - b.hitPoint.t);
return results;
}
/**
* Create two paired BUS terminals from a cut line and rewire connections through them.
*
* Layout:
* Wire1 ──┐ ┌── Wire1
* Wire2 ──┤ ═══════ ├── Wire2
* Wire3 ──┘ └── Wire3
* Terminal IN Terminal OUT
*
* The thick cable between them is rendered by the renderer using busPairId.
*/
export function createBusFromCut() {
const cut = state.busCutting;
if (!cut) return;
const hits = findIntersectingConnections(cut.startX, cut.startY, cut.endX, cut.endY);
if (hits.length === 0) {
console.log('[bus] no connections intersected');
return;
}
const n = hits.length;
console.log(`[bus] cut intersected ${n} connection(s)`);
// Calculate position from hit points
const avgX = hits.reduce((s, h) => s + h.hitPoint.x, 0) / n;
const avgY = hits.reduce((s, h) => s + h.hitPoint.y, 0) / n;
const busH = Math.max(40, (n + 1) * 22);
const gap = 120; // horizontal distance between the two terminals
// Reserve IDs
const busInId = state.nextId++;
const busOutId = state.nextId++;
// Create BUS_IN terminal (left — collects wires into bus, only input pins)
const busIn = {
id: busInId,
type: `BUS_IN:${n}`,
x: avgX - gap / 2 - 15,
y: avgY - busH / 2,
value: 0,
busValues: new Array(n).fill(0),
busPairId: busOutId
};
// Create BUS_OUT terminal (right — distributes bus back to wires, only output pins)
const busOut = {
id: busOutId,
type: `BUS_OUT:${n}`,
x: avgX + gap / 2 - 15,
y: avgY - busH / 2,
value: 0,
outputValues: new Array(n).fill(0),
busPairId: busInId
};
state.gates.push(busIn, busOut);
// Rewire: source → BUS_IN input, BUS_OUT output → destination
// No internal connections — BUS_OUT reads from BUS_IN directly via busPairId
hits.forEach((hit, i) => {
const orig = hit.conn;
// Remove original connection
state.connections = state.connections.filter(c => c !== orig);
// Source → BUS_IN input[i]
state.connections.push({
from: orig.from,
fromPort: orig.fromPort,
to: busIn.id,
toPort: i
});
// BUS_OUT output[i] → original destination
state.connections.push({
from: busOut.id,
fromPort: i,
to: orig.to,
toPort: orig.toPort
});
});
console.log(`[bus] created BUS_IN#${busIn.id} ↔ BUS_OUT#${busOut.id} with ${n} channels`);
evaluateAll();
}
/**
* Get all bus pairs for rendering the bus cables.
* Returns array of { inGate, outGate } for each pair.
*/
export function getBusPairs() {
const pairs = [];
const seen = new Set();
for (const gate of state.gates) {
if (!gate.type.startsWith('BUS_IN:') || !gate.busPairId || seen.has(gate.id)) continue;
const outGate = state.gates.find(g => g.id === gate.busPairId);
if (!outGate || !outGate.type.startsWith('BUS_OUT:')) continue;
seen.add(gate.id);
seen.add(outGate.id);
pairs.push({ inGate: gate, outGate });
}
return pairs;
}

View File

@@ -18,15 +18,22 @@ export function saveComponentFromCircuit(name) {
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
if (inputGates.length === 0 || outputGates.length === 0) {
alert('Component must have at least one INPUT and one OUTPUT');
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
}
// Store the input/output gate IDs in order so we can map ports consistently
const inputIds = inputGates.map(g => g.id);
const outputIds = outputGates.map(g => g.id);
// Create component definition
const component = {
id: sanitizeComponentName(name),
name,
inputCount: inputGates.length,
outputCount: outputGates.length,
inputIds,
outputIds,
gates: JSON.parse(JSON.stringify(state.gates)),
connections: JSON.parse(JSON.stringify(state.connections))
};
@@ -37,125 +44,114 @@ export function saveComponentFromCircuit(name) {
}
state.customComponents[component.id] = component;
console.log(`[component] saved "${name}" (${component.inputCount} in, ${component.outputCount} out)`,
`inputIds=${inputIds}`, `outputIds=${outputIds}`);
return { success: true, component };
}
/**
* Instantiate a component on the canvas
*/
export function instantiateComponent(componentId, x, y) {
if (!state.customComponents || !state.customComponents[componentId]) {
return { success: false, error: 'Component not found' };
}
const component = state.customComponents[componentId];
const instanceId = state.nextId++;
// Create a component instance gate
const gate = {
id: instanceId,
type: `COMPONENT:${componentId}`,
x,
y,
value: 0,
component
};
state.gates.push(gate);
return { success: true, gate };
}
/**
* Evaluate a component instance
* Simulates the internal circuit and returns output
* Evaluate a component instance.
* Simulates the internal circuit and returns an array of output values.
* IMPORTANT: Uses persistent internal state so latches/flip-flops retain
* their values between evaluations (just like the main circuit).
*/
export function evaluateComponent(gate, inputs) {
if (!gate.component) return 0;
if (!gate.component) {
console.warn('[component] evaluateComponent called without component data', gate);
return [0];
}
const comp = gate.component;
const internalState = {
gates: JSON.parse(JSON.stringify(comp.gates)),
connections: JSON.parse(JSON.stringify(comp.connections)),
nextId: Math.max(...comp.gates.map(g => g.id), 0) + 1
};
// Set inputs
const inputGates = internalState.gates.filter(g => g.type === 'INPUT');
inputs.forEach((val, i) => {
if (inputGates[i]) inputGates[i].value = val;
});
// Persist internal gate state on the gate instance so latches hold their value
if (!gate._internalGates) {
gate._internalGates = JSON.parse(JSON.stringify(comp.gates));
}
const internalGates = gate._internalGates;
const internalConns = comp.connections; // read-only, no need to clone
// Evaluate internal circuit
evaluateInternalCircuit(internalState);
// Map external inputs to internal INPUT gates using stored inputIds
const inputIds = comp.inputIds || [];
for (let i = 0; i < inputs.length; i++) {
const targetId = inputIds[i];
const inputGate = targetId != null
? internalGates.find(g => g.id === targetId)
: internalGates.filter(g => g.type === 'INPUT')[i]; // fallback for old components
if (inputGate) {
inputGate.value = inputs[i];
}
}
// Get outputs
const outputGates = internalState.gates.filter(g => g.type === 'OUTPUT');
const outputs = outputGates.map(g => g.value || 0);
// Iterative fixed-point evaluation (same approach as main evaluateAll)
const MAX_ITER = 20;
for (let iter = 0; iter < MAX_ITER; iter++) {
let changed = false;
for (const g of internalGates) {
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
const inCount = getGateInputCount(g.type);
const gInputs = [];
for (let j = 0; j < inCount; j++) {
const conn = internalConns.find(c => c.to === g.id && c.toPort === j);
if (conn) {
const src = internalGates.find(s => s.id === conn.from);
gInputs.push(src ? (src.value || 0) : 0);
} else {
gInputs.push(0);
}
}
let result = 0;
switch (g.type) {
case 'AND': result = (gInputs[0] && gInputs[1]) ? 1 : 0; break;
case 'OR': result = (gInputs[0] || gInputs[1]) ? 1 : 0; break;
case 'NOT': result = gInputs[0] ? 0 : 1; break;
case 'NAND': result = (gInputs[0] && gInputs[1]) ? 0 : 1; break;
case 'NOR': result = (gInputs[0] || gInputs[1]) ? 0 : 1; break;
case 'XOR': result = (gInputs[0] !== gInputs[1]) ? 1 : 0; break;
case 'OUTPUT': result = gInputs[0] || 0; break;
default: result = 0;
}
if (result !== g.value) {
g.value = result;
changed = true;
}
}
if (!changed) break;
}
// Read outputs using stored outputIds
const outputIds = comp.outputIds || [];
const outputs = [];
if (outputIds.length > 0) {
for (const outId of outputIds) {
const outGate = internalGates.find(g => g.id === outId);
outputs.push(outGate ? (outGate.value || 0) : 0);
}
} else {
// Fallback for old components without outputIds
const outputGates = internalGates.filter(g => g.type === 'OUTPUT');
for (const g of outputGates) {
outputs.push(g.value || 0);
}
}
console.log(`[component] eval "${comp.name}" inputs=[${inputs}] → outputs=[${outputs}]`,
`(internal state preserved: ${gate._internalGates ? 'yes' : 'no'})`);
return outputs;
}
/**
* Helper to evaluate internal circuit
*/
function evaluateInternalCircuit(internalState) {
const { gates, connections } = internalState;
// Simple evaluation - may need optimization for complex circuits
for (let i = 0; i < 10; i++) {
for (const gate of gates) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
const inputCount = getGateInputCount(gate.type);
const inputs = [];
for (let j = 0; j < inputCount; j++) {
const conn = connections.find(c => c.to === gate.id && c.toPort === j);
if (conn) {
const srcGate = gates.find(g => g.id === conn.from);
inputs.push(srcGate ? srcGate.value || 0 : 0);
} else {
inputs.push(0);
}
}
// Evaluate based on gate type
let result = 0;
if (gate.type === 'AND') result = (inputs[0] && inputs[1]) ? 1 : 0;
else if (gate.type === 'OR') result = (inputs[0] || inputs[1]) ? 1 : 0;
else if (gate.type === 'NOT') result = inputs[0] ? 0 : 1;
else if (gate.type === 'NAND') result = (inputs[0] && inputs[1]) ? 0 : 1;
else if (gate.type === 'NOR') result = (inputs[0] || inputs[1]) ? 0 : 1;
else if (gate.type === 'XOR') result = (inputs[0] !== inputs[1]) ? 1 : 0;
else if (gate.type === 'OUTPUT') result = inputs[0] || 0;
gate.value = result;
}
}
}
/**
* Get input count for a gate type (includes component types)
* Get input count for a gate type
*/
function getGateInputCount(type) {
if (type === 'CLOCK' || type === 'INPUT') return 0;
if (type === 'NOT' || type === 'OUTPUT') return 1;
if (type.startsWith('COMPONENT:')) {
// Return the component's input count
return 2; // Default for now, should lookup
}
return 2;
}
/**
* Get output count for a gate type
*/
function getGateOutputCount(type) {
if (type === 'OUTPUT') return 0;
return 1;
}
/**
* Sanitize component name for use as ID
*/
@@ -212,7 +208,7 @@ export function importComponent(data) {
}
/**
* Enter component editor mode
* Enter component editor mode (new component)
*/
export function enterComponentEditor() {
// Save current main circuit
@@ -227,6 +223,7 @@ export function enterComponentEditor() {
state.connections = [];
state.nextId = 1;
state.componentEditorActive = true;
state.editingComponentId = null; // new component, not editing existing
state.placingGate = null;
state.connecting = null;
@@ -239,6 +236,42 @@ export function enterComponentEditor() {
if (resizeCallback) resizeCallback();
}
/**
* Enter component editor to edit an existing component's blueprint.
* Loads the component's internal circuit for modification.
*/
export function editComponentBlueprint(gate) {
if (!gate.component) return;
const comp = gate.component;
// Save current main circuit
state.savedMainCircuit = {
gates: JSON.parse(JSON.stringify(state.gates)),
connections: JSON.parse(JSON.stringify(state.connections)),
nextId: state.nextId
};
// Load the component's internal circuit into the canvas
state.gates = JSON.parse(JSON.stringify(comp.gates));
state.connections = JSON.parse(JSON.stringify(comp.connections));
// Set nextId to max existing id + 1 so new gates don't collide
state.nextId = state.gates.reduce((max, g) => Math.max(max, g.id), 0) + 1;
state.componentEditorActive = true;
state.editingComponentId = comp.id; // track which component we're editing
state.placingGate = null;
state.connecting = null;
// Show editor overlay
const overlay = document.getElementById('component-editor-overlay');
overlay.style.display = 'flex';
document.getElementById('component-editor-title').textContent = `Editing Component: ${comp.name}`;
console.log(`[component] editing blueprint of "${comp.name}" (${comp.inputCount} in, ${comp.outputCount} out)`);
// Resize canvas to account for editor bar
if (resizeCallback) resizeCallback();
}
/**
* Exit component editor mode
*/
@@ -246,9 +279,29 @@ export function exitComponentEditor(name, shouldSave) {
const overlay = document.getElementById('component-editor-overlay');
overlay.style.display = 'none';
const editingId = state.editingComponentId;
if (shouldSave && name) {
// Save the component
saveComponentFromCircuit(name);
// Save the component (works for both new and edited)
const result = saveComponentFromCircuit(name);
// Update all placed instances of this component in the main circuit.
// Handles both: editing existing component (editingId matches) AND
// creating a "new" component that overwrites an existing one (same sanitized name).
if (result.success && state.savedMainCircuit) {
const updatedComp = state.customComponents[result.component.id];
if (updatedComp) {
const matchId = editingId || result.component.id;
for (const gate of state.savedMainCircuit.gates) {
if (gate.component && gate.component.id === matchId) {
gate.component = updatedComp;
// Clear persisted internal state so it re-initializes from updated blueprint
delete gate._internalGates;
console.log(`[component] updated instance #${gate.id} with new blueprint`);
}
}
}
}
}
// Restore main circuit
@@ -260,6 +313,7 @@ export function exitComponentEditor(name, shouldSave) {
}
state.componentEditorActive = false;
state.editingComponentId = null;
state.placingGate = null;
// Update component buttons to show newly saved component

View File

@@ -7,7 +7,8 @@ export const PORT_R = 7;
export const GATE_COLORS = {
AND: '#00e599', OR: '#3388ff', NOT: '#ff6644',
NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa',
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833'
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833',
BUS: '#44ddff'
};
export const SIGNAL_COLORS = [

View File

@@ -8,8 +8,9 @@ import { resize, screenToWorld } from './renderer.js';
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
import { getLevel } from './levels.js';
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
import { enterComponentEditor, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { getExampleList, loadExample } from './examples.js';
import { createBusFromCut } from './bus.js';
const PAN_SPEED = 40;
@@ -31,6 +32,43 @@ export function initEvents() {
// Convert to world coords for gate/port detection
const world = screenToWorld(e.offsetX, e.offsetY);
// Update bus cut line endpoint
if (state.busCutting) {
state.busCutting.endX = world.x;
state.busCutting.endY = world.y;
return;
}
// Update selection box
if (state.selectionBox) {
state.selectionBox.endX = world.x;
state.selectionBox.endY = world.y;
return;
}
// Multi-drag selected gates
if (state.multiDrag) {
if (dragStartPos && !dragMoved) {
const dx = e.offsetX - dragStartPos.x;
const dy = e.offsetY - dragStartPos.y;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragMoved = true;
}
if (dragMoved) {
const dx = world.x - state.multiDrag.startX;
const dy = world.y - state.multiDrag.startY;
for (const orig of state.multiDrag.origins) {
const gate = state.gates.find(g => g.id === orig.id);
if (gate) {
gate.x = orig.x + dx;
gate.y = orig.y + dy;
}
}
evaluateAll();
}
return;
}
state.hoveredPort = findPortAt(world.x, world.y);
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
@@ -51,6 +89,7 @@ export function initEvents() {
}
canvas.style.cursor = state.placingGate ? 'crosshair'
: state.selectionBox ? 'crosshair'
: state.hoveredPort ? 'pointer'
: state.hoveredGate ? 'grab'
: 'default';
@@ -65,6 +104,19 @@ export function initEvents() {
dragStartPos = { x: e.offsetX, y: e.offsetY };
dragMoved = false;
// Shift+click on empty space → start bus cut
if (e.shiftKey && !state.placingGate) {
const port = findPortAt(world.x, world.y);
const gate = findGateAt(world.x, world.y);
if (!port && !gate) {
state.busCutting = {
startX: world.x, startY: world.y,
endX: world.x, endY: world.y
};
return;
}
}
// Placing a new gate
if (state.placingGate) {
let w = GATE_W, h = GATE_H;
@@ -90,7 +142,7 @@ export function initEvents() {
}
state.gates.push(newGate);
evaluateAll();
state.placingGate = null;
// Keep placingGate active so user can place multiple — right-click to cancel
return;
}
@@ -125,13 +177,78 @@ export function initEvents() {
// Drag any gate (including INPUT/CLOCK)
const gate = findGateAt(world.x, world.y);
if (gate) {
// If clicking a selected gate → multi-drag all selected
if (state.selectedGates.includes(gate.id)) {
state.multiDrag = {
startX: world.x,
startY: world.y,
origins: state.selectedGates.map(id => {
const g = state.gates.find(g => g.id === id);
return g ? { id: g.id, x: g.x, y: g.y } : null;
}).filter(Boolean)
};
canvas.style.cursor = 'grabbing';
return;
}
// Clicking an unselected gate → clear selection, drag just this one
state.selectedGates = [];
state.dragging = gate;
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
canvas.style.cursor = 'grabbing';
return;
}
// Click on empty space → clear selection and start selection box
state.selectedGates = [];
state.selectionBox = {
startX: world.x, startY: world.y,
endX: world.x, endY: world.y
};
});
canvas.addEventListener('mouseup', e => {
// Finish bus cut
if (state.busCutting) {
createBusFromCut();
state.busCutting = null;
return;
}
// Finish selection box → select gates inside
if (state.selectionBox) {
const box = state.selectionBox;
const x1 = Math.min(box.startX, box.endX);
const y1 = Math.min(box.startY, box.endY);
const x2 = Math.max(box.startX, box.endX);
const y2 = Math.max(box.startY, box.endY);
// Only select if box is big enough (not just a click)
if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
state.selectedGates = state.gates
.filter(g => {
const isDynamic = g.type.startsWith('COMPONENT:') || g.type.startsWith('BUS_IN:') || g.type.startsWith('BUS_OUT:');
const gw = isDynamic ? getComponentWidth(g) : GATE_W;
const gh = isDynamic ? getComponentHeight(g) : GATE_H;
// Gate overlaps selection box
return g.x + gw > x1 && g.x < x2 && g.y + gh > y1 && g.y < y2;
})
.map(g => g.id);
if (state.selectedGates.length > 0) {
console.log(`[select] ${state.selectedGates.length} gate(s) selected`);
}
}
state.selectionBox = null;
dragStartPos = null;
return;
}
// Finish multi-drag
if (state.multiDrag) {
state.multiDrag = null;
dragStartPos = null;
return;
}
// Toggle INPUT/CLOCK only on click (no drag movement)
if (state.dragging && !dragMoved) {
const gate = state.dragging;
@@ -139,7 +256,6 @@ export function initEvents() {
gate.value = gate.value ? 0 : 1;
console.log(`[toggle] ${gate.type}#${gate.id}${gate.value}`);
evaluateAll(true); // record waveform on intentional toggle
// Log all gate values after evaluation
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
}
}
@@ -147,8 +263,38 @@ export function initEvents() {
dragStartPos = null;
});
// Double-click to rename INPUT/OUTPUT/CLOCK gates, or edit component blueprint
canvas.addEventListener('dblclick', e => {
const world = screenToWorld(e.offsetX, e.offsetY);
const gate = findGateAt(world.x, world.y);
if (!gate) return;
// Double-click on component gate → edit its blueprint
if (gate.type.startsWith('COMPONENT:') && gate.component) {
editComponentBlueprint(gate);
return;
}
// Double-click on I/O gates → rename (only inside component editor)
if (state.componentEditorActive && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) {
const current = gate.label || '';
const label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
if (label !== null) {
gate.label = label.trim() || undefined;
console.log(`[label] ${gate.type}#${gate.id} → "${gate.label || ''}"`);
}
}
});
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
// Right-click cancels placing mode
if (state.placingGate) {
state.placingGate = null;
return;
}
const world = screenToWorld(e.offsetX, e.offsetY);
const port = findPortAt(world.x, world.y);
if (port && port.type === 'input') {
@@ -182,8 +328,22 @@ export function initEvents() {
keysDown.add(e.key);
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.hoveredGate && document.activeElement === document.body) {
e.preventDefault();
if (document.activeElement !== document.body) return;
e.preventDefault();
// Delete all selected gates
if (state.selectedGates.length > 0) {
for (const gateId of state.selectedGates) {
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
state.gates = state.gates.filter(g => g.id !== gateId);
delete state.waveData[gateId];
}
console.log(`[delete] removed ${state.selectedGates.length} gate(s)`);
state.selectedGates = [];
state.hoveredGate = null;
evaluateAll();
} else if (state.hoveredGate) {
// Delete single hovered gate
const gateId = state.hoveredGate.id;
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
state.gates = state.gates.filter(g => g.id !== gateId);
@@ -195,6 +355,7 @@ export function initEvents() {
if (e.key === 'Escape') {
state.placingGate = null;
state.connecting = null;
state.selectedGates = [];
}
// Pan with arrow keys
@@ -401,7 +562,11 @@ export function initEvents() {
});
document.getElementById('component-editor-save').addEventListener('click', () => {
const name = prompt('Component name:', 'MyComponent');
// If editing existing, pre-fill with current name
const existingName = state.editingComponentId
? (state.customComponents[state.editingComponentId]?.name || 'MyComponent')
: 'MyComponent';
const name = prompt('Component name:', existingName);
if (name && name.trim()) {
exitComponentEditor(name.trim(), true);
}

View File

@@ -20,16 +20,16 @@ const GAP_Y = 100;
function srFlipFlop() {
const gates = [
// Inputs
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 0 }, // S (Set)
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0 }, // R (Reset)
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 0, label: 'S' },
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0, label: 'R' },
// Cross-coupled NOR gates
{ id: 3, type: 'NOR', x: 300, y: 80, value: 0 }, // Top NOR → Q
{ id: 4, type: 'NOR', x: 300, y: 280, value: 0 }, // Bottom NOR → Q̅
{ id: 3, type: 'NOR', x: 300, y: 80, value: 0 },
{ id: 4, type: 'NOR', x: 300, y: 280, value: 0 },
// Outputs
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0 }, // Q
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0 } // Q̅
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0, label: 'Q' },
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0, label: 'Q̅' }
];
const connections = [
@@ -70,16 +70,16 @@ function srFlipFlop() {
function srFlipFlopNand() {
const gates = [
// Inputs (active low for NAND SR)
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 1 }, // S̅
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 1 }, // R̅
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 1, label: 'S̅' },
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 1, label: 'R̅' },
// Cross-coupled NAND gates
{ id: 3, type: 'NAND', x: 300, y: 80, value: 0 }, // Top NAND → Q
{ id: 4, type: 'NAND', x: 300, y: 280, value: 0 }, // Bottom NAND → Q̅
{ id: 3, type: 'NAND', x: 300, y: 80, value: 0 },
{ id: 4, type: 'NAND', x: 300, y: 280, value: 0 },
// Outputs
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0 }, // Q
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0 } // Q̅
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0, label: 'Q' },
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0, label: 'Q̅' }
];
const connections = [
@@ -124,8 +124,8 @@ function srFlipFlopNand() {
function dLatch() {
const gates = [
// Inputs
{ id: 1, type: 'INPUT', x: 50, y: 100, value: 0 }, // D (Data)
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0 }, // E (Enable)
{ id: 1, type: 'INPUT', x: 50, y: 100, value: 0, label: 'D' },
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0, label: 'EN' },
// NOT gate to invert D
{ id: 3, type: 'NOT', x: 200, y: 340, value: 0 },
@@ -139,8 +139,8 @@ function dLatch() {
{ id: 7, type: 'NOR', x: 550, y: 340, value: 0 }, // → Q̅
// Outputs
{ id: 8, type: 'OUTPUT', x: 750, y: 60, value: 0 }, // Q
{ id: 9, type: 'OUTPUT', x: 750, y: 340, value: 0 } // Q̅
{ id: 8, type: 'OUTPUT', x: 750, y: 60, value: 0, label: 'Q' },
{ id: 9, type: 'OUTPUT', x: 750, y: 340, value: 0, label: 'Q̅' }
];
const connections = [
@@ -189,8 +189,8 @@ function dLatch() {
function dFlipFlop() {
const gates = [
// Inputs
{ id: 1, type: 'INPUT', x: 30, y: 100, value: 0 }, // D
{ id: 2, type: 'CLOCK', x: 30, y: 350, value: 0 }, // CLK
{ id: 1, type: 'INPUT', x: 30, y: 100, value: 0, label: 'D' },
{ id: 2, type: 'CLOCK', x: 30, y: 350, value: 0, label: 'CLK' },
// CLK inverter (for master latch)
{ id: 3, type: 'NOT', x: 170, y: 350, value: 0 },
@@ -210,8 +210,8 @@ function dFlipFlop() {
{ id: 13, type: 'NOR', x: 830, y: 240, value: 0 }, // Slave Q̅
// Outputs
{ id: 14, type: 'OUTPUT', x: 1010, y: 60, value: 0 }, // Q
{ id: 15, type: 'OUTPUT', x: 1010, y: 240, value: 0 } // Q̅
{ id: 14, type: 'OUTPUT', x: 1010, y: 60, value: 0, label: 'Q' },
{ id: 15, type: 'OUTPUT', x: 1010, y: 240, value: 0, label: 'Q̅' }
];
const connections = [

View File

@@ -4,13 +4,15 @@ import { state } from './state.js';
import { recordSample, setEvaluateAll } from './waveform.js';
import { evaluateComponent } from './components.js';
// Wrappers that handle component types
// Wrappers that handle component and BUS types
export function gateInputCount(type) {
if (type.startsWith('COMPONENT:')) {
const componentId = type.substring(10);
const component = state.customComponents?.[componentId];
return component ? component.inputCount : 0;
}
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 0;
if (type.startsWith('BUS_OUT:')) return 0;
return baseGateInputCount(type);
}
@@ -20,10 +22,23 @@ export function gateOutputCount(type) {
const component = state.customComponents?.[componentId];
return component ? component.outputCount : 0;
}
if (type.startsWith('BUS_IN:')) return 0;
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 0;
return baseGateOutputCount(type);
}
function isBusType(type) {
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
}
function getBusSize(type) {
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 1;
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 1;
return 1;
}
export function getComponentWidth(gate) {
if (isBusType(gate.type)) return 30;
if (gate.type.startsWith('COMPONENT:')) {
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
return Math.max(120, (count + 1) * 25);
@@ -32,6 +47,10 @@ export function getComponentWidth(gate) {
}
export function getComponentHeight(gate) {
if (isBusType(gate.type)) {
const n = getBusSize(gate.type);
return Math.max(40, (n + 1) * 22);
}
if (gate.type.startsWith('COMPONENT:')) {
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
return Math.max(60, (count + 1) * 25);
@@ -42,8 +61,8 @@ export function getComponentHeight(gate) {
export function getInputPorts(gate) {
const count = gateInputCount(gate.type);
const ports = [];
const isComponent = gate.type.startsWith('COMPONENT:');
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
for (let i = 0; i < count; i++) {
const spacing = gateHeight / (count + 1);
@@ -55,9 +74,9 @@ export function getInputPorts(gate) {
export function getOutputPorts(gate) {
const count = gateOutputCount(gate.type);
const ports = [];
const isComponent = gate.type.startsWith('COMPONENT:');
const gateWidth = isComponent ? getComponentWidth(gate) : GATE_W;
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
const gateWidth = isDynamic ? getComponentWidth(gate) : GATE_W;
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
for (let i = 0; i < count; i++) {
const spacing = gateHeight / (count + 1);
@@ -66,9 +85,22 @@ export function getOutputPorts(gate) {
return ports;
}
/**
* Read the value from a source gate at a specific output port.
* For component gates with multiple outputs, reads from outputValues[].
* For normal gates (single output), reads gate.value.
*/
function readSourcePort(srcGate, fromPort) {
if (srcGate.outputValues && fromPort < srcGate.outputValues.length) {
return srcGate.outputValues[fromPort];
}
return srcGate.value || 0;
}
/**
* Compute the output of a single gate given its current input values.
* Does NOT recurse — just reads source gate .value directly.
* For COMPONENT gates, evaluates internal circuit and stores all outputs.
*/
function computeGate(gate) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
@@ -79,7 +111,7 @@ function computeGate(gate) {
const conn = state.connections.find(c => c.to === gate.id && c.toPort === i);
if (conn) {
const srcGate = state.gates.find(g => g.id === conn.from);
inputs.push(srcGate ? (srcGate.value || 0) : 0);
inputs.push(srcGate ? readSourcePort(srcGate, conn.fromPort) : 0);
} else {
inputs.push(0);
}
@@ -87,9 +119,28 @@ function computeGate(gate) {
if (gate.type.startsWith('COMPONENT:')) {
const outputs = evaluateComponent(gate, inputs);
// Store all output values for multi-output components
gate.outputValues = outputs;
return outputs[0] || 0;
}
// BUS_IN: collect input values and store them for the paired BUS_OUT
if (gate.type.startsWith('BUS_IN:')) {
gate.busValues = [...inputs];
gate.value = inputs[0] || 0;
return gate.value;
}
// BUS_OUT: read values from paired BUS_IN terminal
if (gate.type.startsWith('BUS_OUT:')) {
const pair = state.gates.find(g => g.id === gate.busPairId);
if (pair && pair.busValues) {
gate.outputValues = [...pair.busValues];
gate.value = gate.outputValues[0] || 0;
}
return gate.value || 0;
}
switch (gate.type) {
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
@@ -115,14 +166,25 @@ export function evaluateAll(recordWave = false) {
let changed = false;
for (const gate of state.gates) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
const oldVal = gate.value;
const oldOutputs = gate.outputValues ? [...gate.outputValues] : null;
const newVal = computeGate(gate);
if (newVal !== gate.value) {
if (newVal !== oldVal) {
gate.value = newVal;
changed = true;
}
// Also check if outputValues changed (for multi-output components)
if (gate.outputValues && oldOutputs) {
for (let i = 0; i < gate.outputValues.length; i++) {
if (gate.outputValues[i] !== oldOutputs[i]) {
changed = true;
break;
}
}
}
}
if (!changed) {
console.log(`[eval] stable after ${iter + 1} iteration(s)`);
if (iter > 0) console.log(`[eval] stable after ${iter + 1} iteration(s)`);
break;
}
if (iter === MAX_ITERATIONS - 1) {
@@ -142,8 +204,9 @@ setEvaluateAll(evaluateAll);
export function findGateAt(x, y) {
return state.gates.find(g => {
const w = g.type.startsWith('COMPONENT:') ? getComponentWidth(g) : GATE_W;
const h = g.type.startsWith('COMPONENT:') ? getComponentHeight(g) : GATE_H;
const isDynamic = g.type.startsWith('COMPONENT:') || isBusType(g.type);
const w = isDynamic ? getComponentWidth(g) : GATE_W;
const h = isDynamic ? getComponentHeight(g) : GATE_H;
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
});
}

View File

@@ -3,9 +3,24 @@ import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
import { state } from './state.js';
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
import { getBusPairs } from './bus.js';
let canvas, ctx;
/**
* Read the value arriving at an input port by looking up the source gate/port.
* Handles multi-output sources (BUS_OUT, COMPONENT) via outputValues[].
*/
function getSourcePortValue(conn) {
if (!conn) return 0;
const srcGate = state.gates.find(g => g.id === conn.from);
if (!srcGate) return 0;
if (srcGate.outputValues && conn.fromPort < srcGate.outputValues.length) {
return srcGate.outputValues[conn.fromPort] || 0;
}
return srcGate.value || 0;
}
export function initRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
@@ -31,11 +46,29 @@ export function screenToWorld(sx, sy) {
};
}
function isBusType(type) {
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
}
function drawSelectionHighlight(gate) {
if (!state.selectedGates.includes(gate.id)) return;
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
const w = isDynamic ? getComponentWidth(gate) : GATE_W;
const h = isDynamic ? getComponentHeight(gate) : GATE_H;
const pad = 4;
ctx.strokeStyle = '#44ddff';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.beginPath();
ctx.roundRect(gate.x - pad, gate.y - pad, w + pad * 2, h + pad * 2, 10);
ctx.stroke();
ctx.setLineDash([]);
}
function drawGate(gate) {
// Component gates have different rendering
if (gate.type.startsWith('COMPONENT:')) {
return drawComponentGate(gate);
}
// Special gate types have different rendering
if (isBusType(gate.type)) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
const color = GATE_COLORS[gate.type];
const isHovered = state.hoveredGate === gate;
@@ -63,8 +96,18 @@ function drawGate(gate) {
ctx.textBaseline = 'middle';
const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK';
// Show custom label above the gate if it has one
if (gate.label && isIOType) {
ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#888';
ctx.fillText(gate.label, gate.x + GATE_W / 2, gate.y - 8);
}
ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`;
ctx.fillStyle = isActive ? '#fff' : color;
ctx.fillText(
gate.type === 'CLOCK' ? '⏱ CLK' : gate.type,
gate.label && isIOType ? gate.label : (gate.type === 'CLOCK' ? '⏱ CLK' : gate.type),
gate.x + GATE_W / 2,
gate.y + GATE_H / 2 - (isIOType ? 8 : 0)
);
@@ -88,7 +131,7 @@ function drawGate(gate) {
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'input';
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
const portActive = getSourcePortValue(conn);
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
@@ -105,15 +148,159 @@ function drawGate(gate) {
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e');
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
});
drawSelectionHighlight(gate);
}
function drawBusGate(gate) {
const isHovered = state.hoveredGate === gate;
const w = getComponentWidth(gate); // 30
const h = getComponentHeight(gate);
const color = '#44ddff';
const isIn = gate.type.startsWith('BUS_IN:');
const n = isIn
? parseInt(gate.type.substring(7)) || 1
: parseInt(gate.type.substring(8)) || 1;
// Check if any channel is active
const values = isIn ? gate.busValues : gate.outputValues;
const hasActive = values?.some(v => v === 1);
if (hasActive) {
ctx.shadowColor = color;
ctx.shadowBlur = 12 * state.zoom;
}
// Main bus bar
ctx.fillStyle = hasActive ? color + '22' : '#14141e';
ctx.strokeStyle = isHovered ? '#fff' : color;
ctx.lineWidth = isHovered ? 2.5 : 1.5;
ctx.beginPath();
ctx.roundRect(gate.x, gate.y, w, h, 4);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
// Thick center line (bus bar visual)
ctx.strokeStyle = isHovered ? '#fff' : color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(gate.x + w / 2, gate.y + 6);
ctx.lineTo(gate.x + w / 2, gate.y + h - 6);
ctx.stroke();
// Bus size label + role indicator
ctx.font = 'bold 9px monospace';
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const roleIcon = isIn ? '▶' : '◀';
ctx.fillText(`${roleIcon} ${n}`, gate.x + w / 2, gate.y + h + 10);
// BUS_IN: only input ports (left side)
if (isIn) {
getInputPorts(gate).forEach(p => {
const isPortHovered = state.hoveredPort &&
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'input';
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
const portActive = getSourcePortValue(conn);
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
// BUS_OUT: only output ports (right side)
if (!isIn) {
getOutputPorts(gate).forEach(p => {
const isPortHovered = state.hoveredPort &&
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : 0;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
}
function drawBusCables() {
const pairs = getBusPairs();
for (const { inGate, outGate } of pairs) {
const inW = getComponentWidth(inGate);
const inH = getComponentHeight(inGate);
const outH = getComponentHeight(outGate);
// Cable runs from right edge of inGate to left edge of outGate
const x1 = inGate.x + inW;
const y1 = inGate.y + inH / 2;
const x2 = outGate.x;
const y2 = outGate.y + outH / 2;
// Check if any channel is active
const hasActive = inGate.busValues?.some(v => v === 1);
const n = parseInt(inGate.type.substring(7)) || 1;
// Outer thick cable (bus background)
const cableWidth = Math.max(6, n * 2.5);
ctx.beginPath();
ctx.moveTo(x1, y1);
const midX = (x1 + x2) / 2;
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
ctx.strokeStyle = hasActive ? '#44ddff33' : '#44ddff11';
ctx.lineWidth = cableWidth + 4;
ctx.stroke();
// Inner cable
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
ctx.strokeStyle = hasActive ? '#44ddff' : '#44ddff66';
ctx.lineWidth = cableWidth;
ctx.stroke();
// Channel count label at midpoint
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2 - cableWidth / 2 - 6;
ctx.font = 'bold 10px monospace';
ctx.fillStyle = '#44ddff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`/${n}`, labelX, labelY);
// Draw small diagonal slash across cable (bus notation)
const slashX = labelX;
const slashY = (y1 + y2) / 2;
ctx.beginPath();
ctx.moveTo(slashX - 6, slashY + 6);
ctx.lineTo(slashX + 6, slashY - 6);
ctx.strokeStyle = '#44ddff';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function drawComponentGate(gate) {
@@ -151,6 +338,25 @@ function drawComponentGate(gate) {
ctx.fillStyle = '#444';
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
// Get port labels from the authoritative component definition (customComponents)
// This must match the source used by gateOutputCount/gateInputCount for port counts
const compId = gate.type.substring(10);
const comp = state.customComponents?.[compId] || gate.component;
const inputLabels = [];
const outputLabels = [];
if (comp) {
const inputIds = comp.inputIds || [];
const outputIds = comp.outputIds || [];
for (const id of inputIds) {
const g = comp.gates.find(g => g.id === id);
inputLabels.push(g?.label || '');
}
for (const id of outputIds) {
const g = comp.gates.find(g => g.id === id);
outputLabels.push(g?.label || '');
}
}
// Input ports
getInputPorts(gate).forEach(p => {
const isPortHovered = state.hoveredPort &&
@@ -158,7 +364,7 @@ function drawComponentGate(gate) {
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'input';
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
const portActive = getSourcePortValue(conn);
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
@@ -167,6 +373,16 @@ function drawComponentGate(gate) {
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
// Port label (inside the gate, to the right of the port)
const label = inputLabels[p.index];
if (label) {
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#aaa';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(label, p.x + PORT_R + 4, p.y);
}
});
// Output ports
@@ -175,14 +391,25 @@ function drawComponentGate(gate) {
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e');
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
// Port label (inside the gate, to the left of the port)
const label = outputLabels[p.index];
if (label) {
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#aaa';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(label, p.x - PORT_R - 4, p.y);
}
});
}
@@ -195,7 +422,10 @@ function drawConnection(conn) {
const toPort = getInputPorts(toGate)[conn.toPort];
if (!fromPort || !toPort) return;
const active = fromGate.value === 1;
// Read correct output port value for multi-output gates (components)
const active = fromGate.outputValues
? (fromGate.outputValues[conn.fromPort] || 0) === 1
: fromGate.value === 1;
const midX = (fromPort.x + toPort.x) / 2;
ctx.beginPath();
@@ -253,6 +483,103 @@ function drawConnectingWire() {
ctx.setLineDash([]);
}
function drawBusCutLine() {
if (!state.busCutting) return;
const cut = state.busCutting;
// Dashed cyan line showing the cut
ctx.beginPath();
ctx.moveTo(cut.startX, cut.startY);
ctx.lineTo(cut.endX, cut.endY);
ctx.strokeStyle = '#44ddff';
ctx.lineWidth = 2.5;
ctx.setLineDash([8, 5]);
ctx.stroke();
ctx.setLineDash([]);
// Small circles at endpoints
ctx.beginPath();
ctx.arc(cut.startX, cut.startY, 4, 0, Math.PI * 2);
ctx.fillStyle = '#44ddff';
ctx.fill();
ctx.beginPath();
ctx.arc(cut.endX, cut.endY, 4, 0, Math.PI * 2);
ctx.fill();
// Highlight intersecting wires
// Import is circular so we compute inline: sample bezier + test intersection
const hits = countCutIntersections(cut);
if (hits > 0) {
ctx.save();
ctx.resetTransform();
ctx.fillStyle = '#44ddff';
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${hits} wire${hits > 1 ? 's' : ''}`, 10, canvas.height - 10);
ctx.restore();
}
}
/**
* Quick inline intersection count for preview (avoids circular import from bus.js)
*/
function countCutIntersections(cut) {
let count = 0;
for (const conn of state.connections) {
const fromGate = state.gates.find(g => g.id === conn.from);
const toGate = state.gates.find(g => g.id === conn.to);
if (!fromGate || !toGate) continue;
const fp = getOutputPorts(fromGate)[conn.fromPort];
const tp = getInputPorts(toGate)[conn.toPort];
if (!fp || !tp) continue;
const midX = (fp.x + tp.x) / 2;
// Sample bezier at 16 points
for (let i = 0; i < 16; i++) {
const t1 = i / 16, t2 = (i + 1) / 16;
const bx1 = bezAt(fp.x, midX, midX, tp.x, t1);
const by1 = bezAt(fp.y, fp.y, tp.y, tp.y, t1);
const bx2 = bezAt(fp.x, midX, midX, tp.x, t2);
const by2 = bezAt(fp.y, fp.y, tp.y, tp.y, t2);
if (segsHit(cut.startX, cut.startY, cut.endX, cut.endY, bx1, by1, bx2, by2)) {
count++;
break;
}
}
}
return count;
}
function bezAt(p0, p1, p2, p3, t) {
const mt = 1 - t;
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
}
function segsHit(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
const d = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1);
if (Math.abs(d) < 1e-10) return false;
const t = ((bx1 - ax1) * (by2 - by1) - (by1 - ay1) * (bx2 - bx1)) / d;
const u = ((bx1 - ax1) * (ay2 - ay1) - (by1 - ay1) * (ax2 - ax1)) / d;
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
}
function drawSelectionBox() {
if (!state.selectionBox) return;
const box = state.selectionBox;
const x = Math.min(box.startX, box.endX);
const y = Math.min(box.startY, box.endY);
const w = Math.abs(box.endX - box.startX);
const h = Math.abs(box.endY - box.startY);
ctx.fillStyle = '#44ddff0a';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#44ddff88';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 3]);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
function drawPlacingGhost() {
if (!state.placingGate) return;
ctx.globalAlpha = 0.5;
@@ -302,8 +629,11 @@ function draw() {
drawGrid();
state.connections.forEach(drawConnection);
drawBusCables();
state.gates.forEach(drawGate);
drawConnectingWire();
drawBusCutLine();
drawSelectionBox();
drawPlacingGhost();
ctx.restore();

View File

@@ -2,6 +2,8 @@
import { state } from './state.js';
import { progress } from './levels.js';
const STORAGE_KEY = 'logiclab_state';
/**
* Save complete application state to JSON
*/
@@ -54,6 +56,16 @@ export function loadState(data) {
state.customComponents = JSON.parse(JSON.stringify(data.components));
}
// Re-link gate.component references to customComponents (authoritative source)
for (const gate of state.gates) {
if (gate.type.startsWith('COMPONENT:')) {
const compId = gate.type.substring(10);
if (state.customComponents[compId]) {
gate.component = state.customComponents[compId];
}
}
}
// Load progress
if (data.progress) {
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
@@ -147,3 +159,46 @@ export async function pasteFromClipboard() {
return { success: false, error: e.message };
}
}
/**
* Save state to localStorage
*/
export function saveToStorage() {
try {
const data = saveState();
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn('[storage] failed to save:', e.message);
}
}
/**
* Load state from localStorage (returns true if state was restored)
*/
export function loadFromStorage() {
try {
const json = localStorage.getItem(STORAGE_KEY);
if (!json) return false;
const data = JSON.parse(json);
const result = loadState(data);
if (result.success) {
console.log('[storage] restored state from localStorage');
}
return result.success;
} catch (e) {
console.warn('[storage] failed to load:', e.message);
return false;
}
}
/**
* Start auto-saving to localStorage on an interval
*/
let autoSaveInterval = null;
export function startAutoSave(intervalMs = 3000) {
if (autoSaveInterval) clearInterval(autoSaveInterval);
autoSaveInterval = setInterval(saveToStorage, intervalMs);
// Also save on page unload
window.addEventListener('beforeunload', saveToStorage);
console.log(`[storage] auto-save enabled (every ${intervalMs}ms)`);
}

View File

@@ -41,5 +41,14 @@ export const state = {
// Component Editor
componentEditorActive: false,
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
componentEditorName: ''
componentEditorName: '',
editingComponentId: null, // ID of component being edited (null = new component)
// Bus cutting (shift+drag)
busCutting: null, // { startX, startY, endX, endY } in world coords, or null
// Multi-selection
selectedGates: [], // array of gate IDs currently selected
selectionBox: null, // { startX, startY, endX, endY } in world coords while dragging
multiDrag: null // { startX, startY, origins: [{id, x, y}] } while dragging selected gates
};

View File

@@ -12,6 +12,7 @@ export function getTrackedGates() {
}
export function getGateLabel(gate) {
if (gate.label) return gate.label;
const sameType = state.gates.filter(g => g.type === gate.type);
const idx = sameType.indexOf(gate);
if (gate.type === 'CLOCK') return `CLK_${idx}`;