Compare commits

..

23 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
Jose Luis
a4292b42cf fix: iterative evaluation for sequential circuits + debug logs
Replace single-pass recursive evaluation with iterative fixed-point
evaluation that runs multiple passes until all gate values stabilize.
Crucially, gate values are NO LONGER reset to 0 before evaluation,
which preserves latch/flip-flop memory state.

Add console logs for toggle, wire, and evaluation stability debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:59:26 +01:00
Jose Luis
2384c489b9 feat: add Examples dropdown with pre-built circuits
Add 4 example circuits accessible from a new toolbar dropdown:
- SR Flip-Flop (NOR) — basic set-reset latch
- SR Flip-Flop (NAND) — active-low variant
- D Latch (1-bit Memory) — gated latch with enable
- D Flip-Flop (Master-Slave) — edge-triggered with CLK

Each example shows name + description in the dropdown and loads
the full circuit with proper gate placement and wiring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:54:04 +01:00
Jose Luis
d78b45841c fix: INPUT toggle no longer disrupts CLK timing in waveform
recordSample() (triggered by user INPUT toggle) now only advances
timeStep when the simulation is stopped. When sim is running, it
records at the current timeStep without advancing it, so the clock's
regular tick cadence is never stolen by manual interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:19:28 +01:00
Jose Luis
f0f3516efa fix: use requestAnimationFrame + real timestamps for clock simulation
Replace setInterval with requestAnimationFrame loop that tracks elapsed
time via performance.now(). Clock ticks now fire based on real time
rather than assuming perfect interval spacing, so user interactions
(toggling inputs, dragging gates) no longer cause the clock to stutter
or pause.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:16:26 +01:00
Jose Luis
2a58ad372e feat: toolbar sections as dropdown menus
Replace horizontal toolbar sections with dropdown buttons (I/O, Gates,
Components). Each opens a dropdown menu on click, keeping the toolbar
clean and compact. Dropdowns close on outside click or after selecting
a gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:14:43 +01:00
Jose Luis
53d600fcb0 fix: horizontal toolbar layout + fix component button placement
Redesign toolbar sections to use horizontal button rows instead of
vertical stacking. Fix component placement by attaching click handlers
directly to dynamically created buttons and passing correct gate object
shape to getComponentWidth/Height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:06:23 +01:00
Jose Luis
268013d053 feat: sectioned toolbar + custom component editor
- Redesigned toolbar with I/O, Gates, and Components sections
- Component editor: sub-canvas mode to design reusable chips
  - Save/Cancel with main circuit state preservation
  - Components persist in localStorage
- Custom components render as purple chips with dynamic I/O ports
- Component evaluation simulates internal circuit as black box
- Toolbar height increased to 56px for section labels
- All height references updated consistently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 02:54:04 +01:00
Jose Luis
3bff1fd4b4 fix: allow dragging all gates + stop waveform recording on edits
- INPUT/CLOCK gates can now be dragged (click-without-move = toggle,
  click-and-drag = move). Uses 4px movement threshold.
- Waveform only records samples on intentional actions (INPUT toggle,
  manual step, simulation tick), not on gate placement/movement/connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 02:42:00 +01:00
Jose Luis
3f1daa77bd feat: custom scrollbar + expand puzzle levels from 8 to 20
- Custom dark-themed scrollbar (WebKit + Firefox) matching the cyberpunk UI
- Added 12 new levels across new categories:
  - Combinational Logic: MUX, DEMUX, 3-input AND, Majority, Parity
  - Arithmetic: Half Subtractor, 1-bit Comparator
  - Decoders & Encoders: 2-to-4 Decoder, 4-to-2 Encoder, 7-Segment
  - Components: 1-bit ALU
  - Logic Basics: XNOR Gate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 02:37:59 +01:00
15 changed files with 2558 additions and 464 deletions

View File

@@ -1,5 +1,34 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
/* ==================== Custom Scrollbar ==================== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2a2a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #00e59966;
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #2a2a3a transparent;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0a0f;
@@ -12,7 +41,7 @@ body {
#toolbar {
position: fixed;
top: 0; left: 0; right: 0;
height: 48px;
height: 56px;
background: #12121a;
border-bottom: 1px solid #2a2a3a;
display: flex;
@@ -49,7 +78,21 @@ body {
.gate-btn.output-btn { border-color: #ff8833; }
.gate-btn.output-btn:hover { border-color: #ffaa55; }
.separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 6px; }
.separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 4px; }
.create-component-btn {
padding: 4px 10px;
background: #1a1a2e;
border: 1px solid #9900ff;
border-radius: 6px;
color: #9900ff;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s;
user-select: none;
}
.create-component-btn:hover { background: #252540; border-color: #cc66ff; color: #cc66ff; }
.toolbar-right { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.action-btn {
@@ -68,10 +111,157 @@ body {
.action-btn.sim-btn:hover { background: #ff44aa22; }
.action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; }
/* ==================== Toolbar Dropdowns ==================== */
.toolbar-dropdown {
position: relative;
}
.dropdown-toggle {
padding: 6px 14px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 6px;
color: #ccc;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.dropdown-toggle:hover {
background: #252540;
border-color: #00e599;
color: #fff;
}
.toolbar-dropdown.open .dropdown-toggle {
background: #252540;
border-color: #00e599;
color: #00e599;
}
.dropdown-arrow {
font-size: 10px;
opacity: 0.6;
transition: transform 0.15s;
}
.toolbar-dropdown.open .dropdown-arrow {
transform: rotate(180deg);
opacity: 1;
}
.dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 140px;
background: #14141e;
border: 1px solid #2a2a3a;
border-radius: 8px;
padding: 4px;
z-index: 150;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
flex-direction: column;
gap: 2px;
}
.toolbar-dropdown.open .dropdown-menu {
display: flex;
}
.dropdown-menu .gate-btn {
width: 100%;
text-align: left;
padding: 7px 12px;
border-radius: 5px;
font-size: 12px;
}
.dropdown-menu .create-component-btn {
width: 100%;
text-align: left;
padding: 7px 12px;
border-radius: 5px;
font-size: 12px;
}
.dropdown-menu #saved-components {
display: flex;
flex-direction: column;
gap: 2px;
}
.dropdown-menu .component-btn {
width: 100%;
text-align: left;
padding: 7px 12px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 5px;
color: #9900ff;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.dropdown-menu .component-btn:hover {
border-color: #9900ff;
color: #cc66ff;
background: #252540;
}
/* Example buttons */
.example-btn {
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
color: #ccc;
cursor: pointer;
transition: all 0.15s;
display: flex;
flex-direction: column;
gap: 2px;
}
.example-btn:hover {
background: #1a1a3a;
border-color: #00e599;
}
.example-name {
font-size: 12px;
font-weight: 600;
color: #00e599;
}
.example-btn:hover .example-name {
color: #00ff99;
}
.example-desc {
font-size: 10px;
color: #666;
line-height: 1.3;
}
#examples-menu {
min-width: 260px;
}
/* ==================== Canvas ==================== */
#canvas {
position: fixed;
top: 48px; left: 0; right: 0; bottom: 0;
top: 56px; left: 0; right: 0; bottom: 0;
cursor: default;
transition: left 0.2s ease;
}
@@ -244,14 +434,79 @@ body {
color: #00e599;
}
/* ==================== Component Editor Overlay ==================== */
#component-editor-overlay {
position: fixed;
top: 56px;
left: 0;
right: 0;
height: 44px;
background: #1a1a2e;
border-bottom: 2px solid #9900ff;
z-index: 90;
display: flex;
align-items: center;
padding: 0 12px;
gap: 12px;
}
#component-editor-bar {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
#component-editor-title {
color: #9900ff;
font-weight: 600;
font-size: 13px;
flex: 1;
}
#component-editor-save, #component-editor-cancel {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
#component-editor-save {
background: #00e599;
color: #000;
}
#component-editor-save:hover {
background: #00ff99;
box-shadow: 0 0 10px #00e59944;
}
#component-editor-cancel {
background: transparent;
border: 1px solid #ff4444;
color: #ff4444;
}
#component-editor-cancel:hover {
background: #ff444422;
}
/* Shift canvas when editor is active */
#component-editor-overlay:not([style*="display: none"]) ~ #canvas {
top: calc(56px + 44px);
}
/* ==================== Puzzle Panels ==================== */
.puzzle-panel {
display: none;
position: fixed;
top: 48px;
top: 56px;
left: 0;
width: 340px;
height: calc(100vh - 48px);
height: calc(100vh - 56px);
background: #12121a;
border-right: 1px solid #2a2a3a;
z-index: 95;
@@ -264,7 +519,7 @@ body {
}
.puzzle-panel.puzzle-info {
top: 48px;
top: 56px;
width: 340px;
}

View File

@@ -9,17 +9,45 @@
<body>
<div id="toolbar">
<span class="logo">⚡ Logic Lab</span>
<!-- I/O Dropdown -->
<div class="toolbar-dropdown">
<button class="dropdown-toggle">I/O <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu">
<button class="gate-btn input-btn" data-gate="INPUT">INPUT</button>
<button class="gate-btn clock-btn" data-gate="CLOCK">CLOCK</button>
<button class="gate-btn output-btn" data-gate="OUTPUT">OUTPUT</button>
<div class="separator"></div>
</div>
</div>
<!-- Gates Dropdown -->
<div class="toolbar-dropdown">
<button class="dropdown-toggle">Gates <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu">
<button class="gate-btn" data-gate="AND">AND</button>
<button class="gate-btn" data-gate="OR">OR</button>
<button class="gate-btn" data-gate="NOT">NOT</button>
<button class="gate-btn" data-gate="NAND">NAND</button>
<button class="gate-btn" data-gate="NOR">NOR</button>
<button class="gate-btn" data-gate="XOR">XOR</button>
<div class="separator"></div>
</div>
</div>
<!-- Components Dropdown -->
<div class="toolbar-dropdown" id="components-section">
<button class="dropdown-toggle">Components <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu" id="components-menu">
<button class="create-component-btn" id="create-component-btn" title="Create custom component">✚ Create</button>
<div id="saved-components"></div>
</div>
</div>
<!-- Examples Dropdown -->
<div class="toolbar-dropdown" id="examples-section">
<button class="dropdown-toggle">Examples <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu" id="examples-menu"></div>
</div>
<button class="action-btn sim-btn" id="sim-btn">Waveform</button>
<div class="toolbar-right">
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
@@ -32,6 +60,15 @@
<!-- Hidden file input for import -->
<input type="file" id="import-file" accept=".json" style="display:none">
<!-- Component Editor Overlay -->
<div id="component-editor-overlay" style="display:none;">
<div id="component-editor-bar">
<span id="component-editor-title">Editing Component: </span>
<button id="component-editor-save">Save</button>
<button id="component-editor-cancel">Cancel</button>
</div>
</div>
<!-- Mode toggle will be inserted here by puzzleUI.js -->
<canvas id="canvas"></canvas>

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

@@ -2,6 +2,12 @@
import { state } from './state.js';
import { GATE_W, GATE_H } from './constants.js';
// Avoid circular imports - resize will be called from events.js
let resizeCallback = null;
export function setResizeCallback(fn) {
resizeCallback = fn;
}
/**
* Save current circuit as a reusable component
* Returns the component ID if successful
@@ -12,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))
};
@@ -31,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
*/
@@ -204,3 +206,143 @@ export function importComponent(data) {
state.customComponents[data.id] = data;
return { success: true, component: data };
}
/**
* Enter component editor mode (new component)
*/
export function enterComponentEditor() {
// Save current main circuit
state.savedMainCircuit = {
gates: JSON.parse(JSON.stringify(state.gates)),
connections: JSON.parse(JSON.stringify(state.connections)),
nextId: state.nextId
};
// Clear canvas for sub-circuit design
state.gates = [];
state.connections = [];
state.nextId = 1;
state.componentEditorActive = true;
state.editingComponentId = null; // new component, not editing existing
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: (New)';
// Resize canvas to account for editor bar
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
*/
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 (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
if (state.savedMainCircuit) {
state.gates = state.savedMainCircuit.gates;
state.connections = state.savedMainCircuit.connections;
state.nextId = state.savedMainCircuit.nextId;
state.savedMainCircuit = null;
}
state.componentEditorActive = false;
state.editingComponentId = null;
state.placingGate = null;
// Update component buttons to show newly saved component
updateComponentButtons();
// Resize canvas via callback
if (resizeCallback) resizeCallback();
}
/**
* Update component buttons in toolbar
*/
export function updateComponentButtons() {
const container = document.getElementById('saved-components');
container.innerHTML = '';
const components = getAllComponents();
Object.values(components).forEach(comp => {
const btn = document.createElement('button');
btn.className = 'component-btn';
btn.dataset.componentId = comp.id;
btn.textContent = comp.name;
btn.title = `${comp.inputCount} input(s), ${comp.outputCount} output(s)`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
state.placingGate = `COMPONENT:${comp.id}`;
// Close dropdown
document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
});
container.appendChild(btn);
});
}

View File

@@ -1,12 +1,14 @@
// Gate dimensions and rendering constants
export const GATE_W = 100;
export const GATE_H = 60;
export const COMP_W = 120;
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 = [
@@ -18,9 +20,18 @@ export const SIGNAL_COLORS = [
export function gateInputCount(type) {
if (type === 'CLOCK' || type === 'INPUT') return 0;
if (type === 'NOT' || type === 'OUTPUT') return 1;
if (type.startsWith('COMPONENT:')) {
// Component types look up their input count from state
return 0; // Will be overridden by lookup in gates.js
}
return 2;
}
export function gateOutputCount(type) {
return type === 'OUTPUT' ? 0 : 1;
if (type === 'OUTPUT') return 0;
if (type.startsWith('COMPONENT:')) {
// Component types look up their output count from state
return 0; // Will be overridden by lookup in gates.js
}
return 1;
}

View File

@@ -1,13 +1,16 @@
// Event handlers — mouse, keyboard, toolbar, waveform controls
import { GATE_W, GATE_H } from './constants.js';
import { GATE_W, GATE_H, COMP_W } from './constants.js';
import { state } from './state.js';
import { evaluateAll, findGateAt, findPortAt } from './gates.js';
import { evaluateAll, findGateAt, findPortAt, getComponentWidth, getComponentHeight } from './gates.js';
import { manualStep, clearWaveData } from './waveform.js';
import { startSim, stopSim, adjustSpeed } from './simulation.js';
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, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { getExampleList, loadExample } from './examples.js';
import { createBusFromCut } from './bus.js';
const PAN_SPEED = 40;
@@ -19,6 +22,9 @@ function updateWaveZoomLabel() {
export function initEvents() {
const canvas = document.getElementById('canvas');
// Set up resize callback for component editor
setResizeCallback(resize);
// ==================== CANVAS MOUSE ====================
canvas.addEventListener('mousemove', e => {
state.mouseX = e.offsetX;
@@ -26,36 +32,117 @@ 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);
if (state.dragging) {
// Detect if mouse actually moved (threshold of 4px to distinguish click vs drag)
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) {
state.dragging.x = world.x - state.dragOffset.x;
state.dragging.y = world.y - state.dragOffset.y;
evaluateAll();
}
}
canvas.style.cursor = state.placingGate ? 'crosshair'
: state.selectionBox ? 'crosshair'
: state.hoveredPort ? 'pointer'
: state.hoveredGate ? 'grab'
: 'default';
});
let dragStartPos = null;
let dragMoved = false;
canvas.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const world = screenToWorld(e.offsetX, e.offsetY);
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) {
state.gates.push({
let w = GATE_W, h = GATE_H;
if (state.placingGate.startsWith('COMPONENT:')) {
const componentId = state.placingGate.substring(10);
const component = state.customComponents?.[componentId];
if (component) {
const fakeGate = { type: state.placingGate, component };
w = getComponentWidth(fakeGate);
h = getComponentHeight(fakeGate);
}
}
const newGate = {
id: state.nextId++,
type: state.placingGate,
x: world.x - GATE_W / 2,
y: world.y - GATE_H / 2,
x: world.x - w / 2,
y: world.y - h / 2,
value: 0
});
};
if (state.placingGate.startsWith('COMPONENT:')) {
newGate.component = state.customComponents[state.placingGate.substring(10)];
}
state.gates.push(newGate);
evaluateAll();
state.placingGate = null;
// Keep placingGate active so user can place multiple — right-click to cancel
return;
}
@@ -65,12 +152,18 @@ export function initEvents() {
if (state.connecting) {
if (state.connecting.portType === 'output' && port.type === 'input') {
state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index));
state.connections.push({ from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index });
const conn = { from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index };
state.connections.push(conn);
console.log(`[wire] ${conn.from}:${conn.fromPort}${conn.to}:${conn.toPort}`);
evaluateAll();
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
} else if (state.connecting.portType === 'input' && port.type === 'output') {
state.connections = state.connections.filter(c => !(c.to === state.connecting.gate.id && c.toPort === state.connecting.portIndex));
state.connections.push({ from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex });
const conn = { from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex };
state.connections.push(conn);
console.log(`[wire] ${conn.from}:${conn.fromPort}${conn.to}:${conn.toPort}`);
evaluateAll();
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
}
state.connecting = null;
} else {
@@ -81,26 +174,127 @@ export function initEvents() {
if (state.connecting) { state.connecting = null; return; }
// Toggle INPUT/CLOCK
// Drag any gate (including INPUT/CLOCK)
const gate = findGateAt(world.x, world.y);
if (gate && (gate.type === 'INPUT' || gate.type === 'CLOCK')) {
gate.value = gate.value ? 0 : 1;
evaluateAll();
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;
}
// Drag gate
if (gate) {
// 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;
if (gate.type === 'INPUT' || gate.type === 'CLOCK') {
gate.value = gate.value ? 0 : 1;
console.log(`[toggle] ${gate.type}#${gate.id}${gate.value}`);
evaluateAll(true); // record waveform on intentional toggle
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
}
}
state.dragging = null;
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('mouseup', () => { state.dragging = null; });
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') {
@@ -134,8 +328,22 @@ export function initEvents() {
keysDown.add(e.key);
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.hoveredGate && document.activeElement === document.body) {
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);
@@ -147,6 +355,7 @@ export function initEvents() {
if (e.key === 'Escape') {
state.placingGate = null;
state.connecting = null;
state.selectedGates = [];
}
// Pan with arrow keys
@@ -177,9 +386,32 @@ export function initEvents() {
keysDown.delete(e.key);
});
// ==================== TOOLBAR ====================
document.querySelectorAll('.gate-btn').forEach(btn => {
btn.addEventListener('click', () => {
// ==================== TOOLBAR DROPDOWNS ====================
function closeAllDropdowns() {
document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
}
document.querySelectorAll('.dropdown-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const dropdown = toggle.parentElement;
const wasOpen = dropdown.classList.contains('open');
closeAllDropdowns();
if (!wasOpen) dropdown.classList.add('open');
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.toolbar-dropdown')) {
closeAllDropdowns();
}
});
// Gate buttons inside dropdowns
document.querySelectorAll('.dropdown-menu .gate-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const gateName = btn.dataset.gate;
// In puzzle mode, check if gate is allowed
if (puzzleMode && currentLevel) {
@@ -190,9 +422,38 @@ export function initEvents() {
}
}
state.placingGate = gateName;
closeAllDropdowns();
});
});
// ==================== EXAMPLES ====================
const examplesMenu = document.getElementById('examples-menu');
getExampleList().forEach((ex, i) => {
const btn = document.createElement('button');
btn.className = 'example-btn';
btn.innerHTML = `<span class="example-name">${ex.name}</span><span class="example-desc">${ex.description}</span>`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const hasGates = state.gates.length > 0;
if (hasGates && !confirm('Load example? This will replace your current circuit.')) return;
const data = loadExample(i);
if (data) {
state.gates = data.circuit.gates;
state.connections = data.circuit.connections;
state.nextId = data.circuit.nextId;
if (data.camera) {
state.camX = data.camera.camX;
state.camY = data.camera.camY;
state.zoom = data.camera.zoom;
}
clearWaveData();
evaluateAll();
}
closeAllDropdowns();
});
examplesMenu.appendChild(btn);
});
document.getElementById('clear-btn').addEventListener('click', () => {
if (state.gates.length === 0 || confirm('Clear all gates and connections?')) {
state.gates = [];
@@ -292,4 +553,31 @@ export function initEvents() {
}
});
document.addEventListener('mouseup', () => { state.resizingWave = false; });
// ==================== COMPONENT EDITOR ====================
document.getElementById('create-component-btn').addEventListener('click', (e) => {
e.stopPropagation();
closeAllDropdowns();
enterComponentEditor();
});
document.getElementById('component-editor-save').addEventListener('click', () => {
// 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);
}
});
document.getElementById('component-editor-cancel').addEventListener('click', () => {
if (confirm('Discard component without saving?')) {
exitComponentEditor('', false);
}
});
// Update component buttons initially
updateComponentButtons();
}

300
js/examples.js Normal file
View File

@@ -0,0 +1,300 @@
// Pre-built example circuits
// Each example is a { gates, connections, nextId, camera } object
const W = 100; // GATE_W
const H = 60; // GATE_H
const GAP_X = 160;
const GAP_Y = 100;
/**
* SR Flip-Flop using cross-coupled NOR gates
*
* S ──┐
* NOR─── Q
* ┌──┘ │
* │ │
* └──┐ │
* NOR─── Q̅
* R ──┘
*/
function srFlipFlop() {
const gates = [
// Inputs
{ 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 },
{ id: 4, type: 'NOR', x: 300, y: 280, value: 0 },
// Outputs
{ 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 = [
// S → Top NOR input 0
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
// R → Bottom NOR input 1
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
// Top NOR (Q) → Bottom NOR input 0 (cross-couple)
{ from: 3, fromPort: 0, to: 4, toPort: 0 },
// Bottom NOR (Q̅) → Top NOR input 1 (cross-couple)
{ from: 4, fromPort: 0, to: 3, toPort: 1 },
// NOR outputs → visible outputs
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
{ from: 4, fromPort: 0, to: 6, toPort: 0 }
];
return {
name: 'SR Flip-Flop (NOR)',
description: 'Set-Reset latch using cross-coupled NOR gates. Toggle S to set Q=1, toggle R to reset Q=0.',
gates,
connections,
nextId: 7,
camera: { camX: 0, camY: 0, zoom: 1 }
};
}
/**
* SR Flip-Flop using cross-coupled NAND gates
*
* S̅ ──┐
* NAND── Q
* ┌───┘ │
* │ │
* └───┐ │
* NAND── Q̅
* R̅ ──┘
*/
function srFlipFlopNand() {
const gates = [
// Inputs (active low for NAND SR)
{ 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 },
{ id: 4, type: 'NAND', x: 300, y: 280, value: 0 },
// Outputs
{ 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 = [
// S̅ → Top NAND input 0
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
// R̅ → Bottom NAND input 1
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
// Top NAND (Q) → Bottom NAND input 0 (cross-couple)
{ from: 3, fromPort: 0, to: 4, toPort: 0 },
// Bottom NAND (Q̅) → Top NAND input 1 (cross-couple)
{ from: 4, fromPort: 0, to: 3, toPort: 1 },
// Outputs
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
{ from: 4, fromPort: 0, to: 6, toPort: 0 }
];
return {
name: 'SR Flip-Flop (NAND)',
description: 'Set-Reset latch using cross-coupled NAND gates. Inputs are active-low: set S̅=0 to set, R̅=0 to reset.',
gates,
connections,
nextId: 7,
camera: { camX: 0, camY: 0, zoom: 1 }
};
}
/**
* Gated D Latch (1-bit memory)
*
* Uses an SR flip-flop with gating logic:
* D ──AND──┐
* E ──┤ NOR── Q
* │ │ ┌──┘│
* │ │ │ │
* │ │ └──┐│
* E ──┤ NOR── Q̅
* D─NOT─AND┘
*
* When Enable=1, Q follows D.
* When Enable=0, Q holds its value.
*/
function dLatch() {
const gates = [
// Inputs
{ 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 },
// AND gates for gating
{ id: 4, type: 'AND', x: 350, y: 60, value: 0 }, // D AND E → S
{ id: 5, type: 'AND', x: 350, y: 340, value: 0 }, // NOT(D) AND E → R
// Cross-coupled NOR gates (SR latch core)
{ id: 6, type: 'NOR', x: 550, y: 60, value: 0 }, // → Q
{ id: 7, type: 'NOR', x: 550, y: 340, value: 0 }, // → Q̅
// Outputs
{ 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 = [
// D → AND top input, and D → NOT
{ from: 1, fromPort: 0, to: 4, toPort: 0 },
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
// E → both AND gates
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
{ from: 2, fromPort: 0, to: 5, toPort: 1 },
// NOT(D) → bottom AND
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
// AND outputs → NOR inputs (S and R)
{ from: 4, fromPort: 0, to: 6, toPort: 0 },
{ from: 5, fromPort: 0, to: 7, toPort: 1 },
// Cross-coupling
{ from: 6, fromPort: 0, to: 7, toPort: 0 },
{ from: 7, fromPort: 0, to: 6, toPort: 1 },
// Outputs
{ from: 6, fromPort: 0, to: 8, toPort: 0 },
{ from: 7, fromPort: 0, to: 9, toPort: 0 }
];
return {
name: 'D Latch (1-bit Memory)',
description: 'Gated D latch — a 1-bit memory cell. When Enable=1, output Q follows input D. When Enable=0, Q holds its last value.',
gates,
connections,
nextId: 10,
camera: { camX: 0, camY: 0, zoom: 1 }
};
}
/**
* D Flip-Flop (edge-triggered, master-slave)
*
* Two D latches in series with inverted enable:
* - Master latch captures D when CLK=0
* - Slave latch outputs when CLK=1
* This creates rising-edge triggered behavior.
*/
function dFlipFlop() {
const gates = [
// Inputs
{ 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 },
// === MASTER LATCH (enabled when CLK=0, i.e. NOT CLK=1) ===
{ id: 4, type: 'NOT', x: 170, y: 200, value: 0 }, // NOT D for master
{ id: 5, type: 'AND', x: 300, y: 60, value: 0 }, // D AND !CLK
{ id: 6, type: 'AND', x: 300, y: 240, value: 0 }, // !D AND !CLK
{ id: 7, type: 'NOR', x: 450, y: 60, value: 0 }, // Master Q
{ id: 8, type: 'NOR', x: 450, y: 240, value: 0 }, // Master Q̅
// === SLAVE LATCH (enabled when CLK=1) ===
{ id: 9, type: 'NOT', x: 570, y: 200, value: 0 }, // NOT master Q for slave
{ id: 10, type: 'AND', x: 680, y: 60, value: 0 }, // masterQ AND CLK
{ id: 11, type: 'AND', x: 680, y: 240, value: 0 }, // !masterQ AND CLK
{ id: 12, type: 'NOR', x: 830, y: 60, value: 0 }, // Slave Q
{ id: 13, type: 'NOR', x: 830, y: 240, value: 0 }, // Slave Q̅
// Outputs
{ 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 = [
// D → master AND, D → master NOT
{ from: 1, fromPort: 0, to: 5, toPort: 0 },
{ from: 1, fromPort: 0, to: 4, toPort: 0 },
// CLK → NOT (invert for master)
{ from: 2, fromPort: 0, to: 3, toPort: 0 },
// !CLK → master AND gates (enable)
{ from: 3, fromPort: 0, to: 5, toPort: 1 },
{ from: 3, fromPort: 0, to: 6, toPort: 1 },
// !D → master bottom AND
{ from: 4, fromPort: 0, to: 6, toPort: 0 },
// Master AND outputs → Master NOR (SR latch)
{ from: 5, fromPort: 0, to: 7, toPort: 0 },
{ from: 6, fromPort: 0, to: 8, toPort: 1 },
// Master cross-coupling
{ from: 7, fromPort: 0, to: 8, toPort: 0 },
{ from: 8, fromPort: 0, to: 7, toPort: 1 },
// Master Q → slave AND, Master Q → slave NOT
{ from: 7, fromPort: 0, to: 10, toPort: 0 },
{ from: 7, fromPort: 0, to: 9, toPort: 0 },
// CLK → slave AND gates (enable, direct CLK)
{ from: 2, fromPort: 0, to: 10, toPort: 1 },
{ from: 2, fromPort: 0, to: 11, toPort: 1 },
// !masterQ → slave bottom AND
{ from: 9, fromPort: 0, to: 11, toPort: 0 },
// Slave AND outputs → Slave NOR (SR latch)
{ from: 10, fromPort: 0, to: 12, toPort: 0 },
{ from: 11, fromPort: 0, to: 13, toPort: 1 },
// Slave cross-coupling
{ from: 12, fromPort: 0, to: 13, toPort: 0 },
{ from: 13, fromPort: 0, to: 12, toPort: 1 },
// Outputs
{ from: 12, fromPort: 0, to: 14, toPort: 0 },
{ from: 13, fromPort: 0, to: 15, toPort: 0 }
];
return {
name: 'D Flip-Flop (Master-Slave)',
description: 'Edge-triggered D flip-flop built from two D latches. Captures D on the rising edge of CLK. Use with the clock simulation to see it in action.',
gates,
connections,
nextId: 16,
camera: { camX: 0, camY: -50, zoom: 0.9 }
};
}
// Export all examples as a list
export const examples = [
srFlipFlop,
srFlipFlopNand,
dLatch,
dFlipFlop
];
export function getExampleList() {
return examples.map((fn, i) => {
const ex = fn();
return { id: i, name: ex.name, description: ex.description };
});
}
export function loadExample(index) {
if (index < 0 || index >= examples.length) return null;
const ex = examples[index]();
return {
circuit: {
gates: JSON.parse(JSON.stringify(ex.gates)),
connections: JSON.parse(JSON.stringify(ex.connections)),
nextId: ex.nextId
},
camera: ex.camera
};
}

View File

@@ -1,13 +1,71 @@
// Gate evaluation and port geometry
import { GATE_W, GATE_H, PORT_R, gateInputCount, gateOutputCount } from './constants.js';
import { GATE_W, GATE_H, COMP_W, PORT_R, gateInputCount as baseGateInputCount, gateOutputCount as baseGateOutputCount } from './constants.js';
import { state } from './state.js';
import { recordSample, setEvaluateAll } from './waveform.js';
import { evaluateComponent } from './components.js';
// 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);
}
export function gateOutputCount(type) {
if (type.startsWith('COMPONENT:')) {
const componentId = type.substring(10);
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);
}
return GATE_W;
}
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);
}
return GATE_H;
}
export function getInputPorts(gate) {
const count = gateInputCount(gate.type);
const ports = [];
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 = GATE_H / (count + 1);
const spacing = gateHeight / (count + 1);
ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' });
}
return ports;
@@ -16,15 +74,35 @@ export function getInputPorts(gate) {
export function getOutputPorts(gate) {
const count = gateOutputCount(gate.type);
const ports = [];
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++) {
ports.push({ x: gate.x + GATE_W, y: gate.y + GATE_H / 2, index: i, type: 'output' });
const spacing = gateHeight / (count + 1);
ports.push({ x: gate.x + gateWidth, y: gate.y + spacing * (i + 1), index: i, type: 'output' });
}
return ports;
}
export function evaluate(gate, visited = new Set()) {
if (visited.has(gate.id)) return gate.value || 0;
visited.add(gate.id);
/**
* 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;
const inputCount = gateInputCount(gate.type);
@@ -33,39 +111,104 @@ export function evaluate(gate, visited = new Set()) {
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 ? evaluate(srcGate, visited) : 0);
inputs.push(srcGate ? readSourcePort(srcGate, conn.fromPort) : 0);
} else {
inputs.push(0);
}
}
let result = 0;
switch (gate.type) {
case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break;
case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break;
case 'NOT': result = inputs[0] ? 0 : 1; break;
case 'NAND': result = (inputs[0] && inputs[1]) ? 0 : 1; break;
case 'NOR': result = (inputs[0] || inputs[1]) ? 0 : 1; break;
case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break;
case 'OUTPUT': result = inputs[0] || 0; break;
}
gate.value = result;
return result;
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;
}
export function evaluateAll() {
state.gates.forEach(g => {
if (g.type !== 'INPUT' && g.type !== 'CLOCK') g.value = 0;
});
state.gates.forEach(g => evaluate(g));
if (state.recording && state.waveformVisible) recordSample();
// 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;
case 'NOT': return inputs[0] ? 0 : 1;
case 'NAND': return (inputs[0] && inputs[1]) ? 0 : 1;
case 'NOR': return (inputs[0] || inputs[1]) ? 0 : 1;
case 'XOR': return (inputs[0] !== inputs[1]) ? 1 : 0;
case 'OUTPUT': return inputs[0] || 0;
default: return 0;
}
}
/**
* Iterative fixed-point evaluation.
* Runs multiple passes over all gates until no values change (stable)
* or a max iteration limit is reached. Does NOT reset gate values,
* preserving latch/flip-flop state across evaluations.
*/
const MAX_ITERATIONS = 20;
export function evaluateAll(recordWave = false) {
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
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 !== 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) {
if (iter > 0) console.log(`[eval] stable after ${iter + 1} iteration(s)`);
break;
}
if (iter === MAX_ITERATIONS - 1) {
console.warn(`[eval] did not stabilize after ${MAX_ITERATIONS} iterations (oscillation?)`);
}
}
if (recordWave && state.recording && state.waveformVisible) recordSample();
}
// Keep legacy export name for components.js internal use
export function evaluate(gate) {
return computeGate(gate);
}
// Register evaluateAll in waveform to break circular dependency
setEvaluateAll(evaluateAll);
export function findGateAt(x, y) {
return state.gates.find(g => x >= g.x && x <= g.x + GATE_W && y >= g.y && y <= g.y + GATE_H);
return state.gates.find(g => {
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;
});
}
export function findPortAt(x, y) {

View File

@@ -75,6 +75,111 @@ export const LEVELS = [
hints: ['XOR = (A NAND B) NAND (NOT(A NAND NOT B) NAND NOT(NOT A NAND B))', 'This requires 4-5 NAND gates.'],
difficulty: 3
},
{
id: 'xnor_gate',
category: 'Logic Basics',
title: 'XNOR Gate',
description: 'Build an XNOR gate. True when both inputs are equal.',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 1 } },
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } }
],
hints: ['XNOR is the inverse of XOR.', 'Build XOR first, then invert the output with another NAND-as-NOT.'],
difficulty: 3
},
// ============ Combinational Logic ============
{
id: 'mux_2to1',
category: 'Combinational Logic',
title: '2:1 Multiplexer',
description: 'Build a MUX. When SEL=0, output A. When SEL=1, output B. Three inputs: A (0), B (1), SEL (2).',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // SEL=0 → A=0
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1 } }, // SEL=0 → A=1
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 0 } }, // SEL=0 → A=0
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } }, // SEL=0 → A=1
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } }, // SEL=1 → B=0
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } }, // SEL=1 → B=1
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0 } }, // SEL=1 → B=0
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } } // SEL=1 → B=1
],
hints: ['MUX = (A AND NOT SEL) OR (B AND SEL).', 'You need 1 NOT, 2 AND, and 1 OR gate.'],
difficulty: 2
},
{
id: 'demux_1to2',
category: 'Combinational Logic',
title: '1:2 Demultiplexer',
description: 'Route input to one of two outputs based on SEL. Inputs: DATA (0), SEL (1). Outputs: Q0, Q1.',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // DATA=0, SEL=0 → Q0=0, Q1=0
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // DATA=1, SEL=0 → Q0=1, Q1=0
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0, 1: 0 } }, // DATA=0, SEL=1 → Q0=0, Q1=0
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } } // DATA=1, SEL=1 → Q0=0, Q1=1
],
hints: ['Q0 = DATA AND NOT SEL. Q1 = DATA AND SEL.', 'You need 1 NOT and 2 AND gates.'],
difficulty: 2
},
{
id: 'and3',
category: 'Combinational Logic',
title: '3-input AND',
description: 'Build a 3-input AND gate. Output is 1 only when all three inputs are 1.',
availableGates: ['INPUT', 'OUTPUT', 'AND'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } }
],
hints: ['Chain two AND gates: (A AND B) AND C.'],
difficulty: 1
},
{
id: 'majority',
category: 'Combinational Logic',
title: 'Majority Gate',
description: 'Output 1 if at least two of the three inputs are 1.',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 1 } },
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } }
],
hints: ['Majority = (A AND B) OR (A AND C) OR (B AND C).', 'You need 3 AND gates and 2 OR gates.'],
difficulty: 2
},
{
id: 'parity',
category: 'Combinational Logic',
title: 'Even Parity',
description: 'Output 1 if an even number of inputs are 1 (0 counts as even). Three inputs.',
availableGates: ['INPUT', 'OUTPUT', 'XOR', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 1 } }, // 0 ones = even
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // 1 one = odd
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 0 } },
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } },
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } }, // 2 ones = even
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 1 } },
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } },
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 0 } } // 3 ones = odd
],
hints: ['XOR gives odd parity. Invert it for even parity.', 'Chain: NOT(A XOR B XOR C).'],
difficulty: 2
},
// ============ Arithmetic ============
{
id: 'half_adder',
category: 'Arithmetic',
@@ -82,10 +187,10 @@ export const LEVELS = [
description: 'Build a half adder. Two outputs: Sum (A XOR B) and Carry (A AND B).',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // 0+0: sum=0, carry=0
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 0 } }, // 0+1: sum=1, carry=0
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // 1+0: sum=1, carry=0
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } } // 1+1: sum=0, carry=1
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } },
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 0 } },
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } },
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1 } }
],
hints: ['Two independent functions: Sum=XOR, Carry=AND.', 'You need 2 OUTPUT gates.'],
difficulty: 3
@@ -94,36 +199,131 @@ export const LEVELS = [
id: 'full_adder',
category: 'Arithmetic',
title: 'Full Adder',
description: 'Build a full adder with carry-in. Outputs: Sum (3-input XOR) and Carry.',
description: 'Build a full adder with carry-in. Outputs: Sum and Carry-out.',
availableGates: ['INPUT', 'OUTPUT', 'NAND'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0, 1: 0 } }, // 0+0+0
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 1, 1: 0 } }, // 0+0+1
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 1, 1: 0 } }, // 0+1+0
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0, 1: 1 } }, // 0+1+1
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1, 1: 0 } }, // 1+0+0
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0, 1: 1 } }, // 1+0+1
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0, 1: 1 } }, // 1+1+0
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1, 1: 1 } } // 1+1+1
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0, 1: 0 } },
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 1, 1: 0 } },
{ inputs: { 0: 0, 1: 1, 2: 0 }, outputs: { 0: 1, 1: 0 } },
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 0, 1: 1 } },
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 1, 1: 0 } },
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 0, 1: 1 } },
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 0, 1: 1 } },
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1, 1: 1 } }
],
hints: ['Reuse your half adder logic. A full adder = half adder + half adder + OR.'],
hints: ['A full adder = two half adders + OR for the carry.', 'Sum = A XOR B XOR Cin. Cout = (A AND B) OR (Cin AND (A XOR B)).'],
difficulty: 4
},
{
id: 'half_subtractor',
category: 'Arithmetic',
title: 'Half Subtractor',
description: 'Build A minus B. Outputs: Difference (A XOR B) and Borrow (NOT A AND B).',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'XOR', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 0 } }, // 0-0: diff=0, borrow=0
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 1, 1: 1 } }, // 0-1: diff=1, borrow=1
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0 } }, // 1-0: diff=1, borrow=0
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 0 } } // 1-1: diff=0, borrow=0
],
hints: ['Difference = A XOR B (same as addition!).', 'Borrow = (NOT A) AND B.'],
difficulty: 2
},
{
id: 'comparator',
category: 'Arithmetic',
title: '1-bit Comparator',
description: 'Compare A and B. Three outputs: A>B, A=B, A<B.',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT', 'XOR'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 0, 1: 1, 2: 0 } }, // 0=0: GT=0, EQ=1, LT=0
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } }, // 0<1: GT=0, EQ=0, LT=1
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1, 1: 0, 2: 0 } }, // 1>0: GT=1, EQ=0, LT=0
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 1, 2: 0 } } // 1=1: GT=0, EQ=1, LT=0
],
hints: ['A>B = A AND NOT B.', 'A=B = NOT(A XOR B) = XNOR.', 'A<B = NOT A AND B.'],
difficulty: 3
},
// ============ Decoders & Encoders ============
{
id: 'decoder_2to4',
category: 'Decoders & Encoders',
title: '2-to-4 Decoder',
description: 'Decode 2 input bits into 4 output lines. Only one output is 1 at a time.',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 1, 1: 0, 2: 0, 3: 0 } }, // 00 → line 0
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0, 1: 1, 2: 0, 3: 0 } }, // 01 → line 1
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 0, 1: 0, 2: 1, 3: 0 } }, // 10 → line 2
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 0, 1: 0, 2: 0, 3: 1 } } // 11 → line 3
],
hints: ['Q0 = NOT A AND NOT B.', 'Q1 = NOT A AND B. Q2 = A AND NOT B. Q3 = A AND B.', 'You need 2 NOT gates and 4 AND gates.'],
difficulty: 3
},
{
id: 'encoder_4to2',
category: 'Decoders & Encoders',
title: '4-to-2 Encoder',
description: 'Encode 4 input lines into 2-bit binary. Assume only one input is active at a time.',
availableGates: ['INPUT', 'OUTPUT', 'OR'],
testCases: [
{ inputs: { 0: 1, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0 } }, // line 0 → 00
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 1 } }, // line 1 → 01
{ inputs: { 0: 0, 1: 0, 2: 1, 3: 0 }, outputs: { 0: 1, 1: 0 } }, // line 2 → 10
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1 } } // line 3 → 11
],
hints: ['Bit 1 (MSB) = Input2 OR Input3.', 'Bit 0 (LSB) = Input1 OR Input3.', 'Just 2 OR gates!'],
difficulty: 2
},
{
id: 'seven_seg_a',
category: 'Decoders & Encoders',
title: '7-Segment: Segment A',
description: 'Drive segment A of a 7-segment display for digits 0-3. Segment A is ON for 0, 2, 3.',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0 }, outputs: { 0: 1 } }, // 0 → A on
{ inputs: { 0: 0, 1: 1 }, outputs: { 0: 0 } }, // 1 → A off
{ inputs: { 0: 1, 1: 0 }, outputs: { 0: 1 } }, // 2 → A on
{ inputs: { 0: 1, 1: 1 }, outputs: { 0: 1 } } // 3 → A on
],
hints: ['A = NOT(NOT A1 AND A0). Or equivalently: A = A1 OR NOT A0.'],
difficulty: 2
},
// ============ Components ============
{
id: 'two_bit_adder',
category: 'Components',
title: '2-bit Adder',
description: 'Chain two full adders to add two 2-bit numbers.',
availableGates: ['INPUT', 'OUTPUT', 'FULL_ADDER'], // FULL_ADDER is saved component
availableGates: ['INPUT', 'OUTPUT', 'FULL_ADDER'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0, 2: 0 } }, // 00+00=000
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } }, // 00+01=001
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } }, // 01+01=010
{ inputs: { 0: 1, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1, 2: 1 } }, // 11+01=100
{ inputs: { 0: 1, 1: 1, 2: 1, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } } // 11+11=110
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 0 }, outputs: { 0: 0, 1: 0, 2: 0 } },
{ inputs: { 0: 0, 1: 0, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 0, 2: 1 } },
{ inputs: { 0: 0, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } },
{ inputs: { 0: 1, 1: 1, 2: 0, 3: 1 }, outputs: { 0: 1, 1: 1, 2: 1 } },
{ inputs: { 0: 1, 1: 1, 2: 1, 3: 1 }, outputs: { 0: 0, 1: 1, 2: 1 } }
],
hints: ['Use two FULL_ADDER components chained together.', 'First adder has no carry-in (set to 0).'],
difficulty: 3
},
{
id: 'alu_1bit',
category: 'Components',
title: '1-bit ALU',
description: 'Build a 1-bit ALU. OP=0 → AND, OP=1 → OR. Inputs: A (0), B (1), OP (2).',
availableGates: ['INPUT', 'OUTPUT', 'AND', 'OR', 'NOT'],
testCases: [
{ inputs: { 0: 0, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // 0 AND 0 = 0
{ inputs: { 0: 1, 1: 0, 2: 0 }, outputs: { 0: 0 } }, // 1 AND 0 = 0
{ inputs: { 0: 1, 1: 1, 2: 0 }, outputs: { 0: 1 } }, // 1 AND 1 = 1
{ inputs: { 0: 0, 1: 0, 2: 1 }, outputs: { 0: 0 } }, // 0 OR 0 = 0
{ inputs: { 0: 1, 1: 0, 2: 1 }, outputs: { 0: 1 } }, // 1 OR 0 = 1
{ inputs: { 0: 0, 1: 1, 2: 1 }, outputs: { 0: 1 } }, // 0 OR 1 = 1
{ inputs: { 0: 1, 1: 1, 2: 1 }, outputs: { 0: 1 } } // 1 OR 1 = 1
],
hints: ['Compute both A AND B and A OR B, then use a MUX to select based on OP.', 'MUX = (result0 AND NOT OP) OR (result1 AND OP).'],
difficulty: 3
}
];

View File

@@ -1,11 +1,26 @@
// Canvas rendering — gates, connections, grid
import { GATE_W, GATE_H, PORT_R, GATE_COLORS } from './constants.js';
import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
import { state } from './state.js';
import { getInputPorts, getOutputPorts } from './gates.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');
@@ -19,7 +34,8 @@ export function resize() {
const sidebarW = sidebarOpen ? 340 : 0;
canvas.width = window.innerWidth - sidebarW;
const waveH = state.waveformVisible ? state.waveformHeight : 0;
canvas.height = window.innerHeight - 48 - waveH;
const editorH = state.componentEditorActive ? 44 : 0;
canvas.height = window.innerHeight - 56 - editorH - waveH;
}
// Convert screen coords to world coords (accounting for pan/zoom)
@@ -30,7 +46,30 @@ 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) {
// 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;
const isActive = gate.value === 1;
@@ -57,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)
);
@@ -82,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);
@@ -99,15 +148,269 @@ 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) {
const isHovered = state.hoveredGate === gate;
const isActive = gate.value === 1;
const w = getComponentWidth(gate);
const h = getComponentHeight(gate);
const color = '#9900ff';
if (isActive) {
ctx.shadowColor = color;
ctx.shadowBlur = 20 * state.zoom;
}
ctx.fillStyle = isActive ? 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, 8);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
// Component name label
ctx.fillStyle = isActive ? '#fff' : color;
ctx.font = `bold 12px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const componentName = gate.component?.name || 'Component';
ctx.fillText(componentName, gate.x + w / 2, gate.y + h / 2);
// Small ID label
ctx.font = '9px monospace';
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 &&
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, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
ctx.fill();
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
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) : gate.value;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
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);
}
});
}
function drawConnection(conn) {
@@ -119,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();
@@ -177,16 +483,126 @@ 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;
const world = screenToWorld(state.mouseX, state.mouseY);
let w = GATE_W, h = GATE_H;
if (state.placingGate.startsWith('COMPONENT:')) {
const componentId = state.placingGate.substring(10);
const component = state.customComponents?.[componentId];
if (component) {
const count = Math.max(component.inputCount, component.outputCount);
w = 120;
h = Math.max(60, (count + 1) * 25);
}
}
const ghost = {
x: world.x - GATE_W / 2,
y: world.y - GATE_H / 2,
x: world.x - w / 2,
y: world.y - h / 2,
type: state.placingGate,
value: 0,
id: -1
id: -1,
component: state.customComponents?.[state.placingGate.substring(10)]
};
drawGate(ghost);
ctx.globalAlpha = 1;
@@ -213,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

@@ -1,9 +1,18 @@
// Clock simulation engine
// Clock simulation engine — uses real timestamps for consistent timing
import { state } from './state.js';
import { evaluateAll } from './gates.js';
import { forceRecordSample } from './waveform.js';
export function simTick() {
let lastTickTime = 0;
let animFrameId = null;
function simLoop(now) {
if (!state.simRunning) return;
// Fire ticks that are due based on real elapsed time
while (now - lastTickTime >= state.simSpeed) {
lastTickTime += state.simSpeed;
// Toggle all CLOCK gates
state.gates.forEach(g => {
if (g.type === 'CLOCK') {
@@ -11,12 +20,14 @@ export function simTick() {
}
});
evaluateAll();
// Force record even if evaluateAll didn't detect change
if (state.recording && state.waveformVisible) {
forceRecordSample();
}
}
animFrameId = requestAnimationFrame(simLoop);
}
export function startSim() {
if (state.simRunning) return;
const hasClocks = state.gates.some(g => g.type === 'CLOCK');
@@ -29,18 +40,18 @@ export function startSim() {
state.waveformVisible = true;
document.getElementById('waveform-panel').classList.add('visible');
document.getElementById('sim-btn').classList.add('active');
// Trigger resize via event so renderer picks it up
window.dispatchEvent(new Event('resize'));
}
state.simInterval = setInterval(simTick, state.simSpeed);
lastTickTime = performance.now();
animFrameId = requestAnimationFrame(simLoop);
updateSimUI();
}
export function stopSim() {
state.simRunning = false;
if (state.simInterval) clearInterval(state.simInterval);
state.simInterval = null;
if (animFrameId) cancelAnimationFrame(animFrameId);
animFrameId = null;
updateSimUI();
}
@@ -57,10 +68,8 @@ export function updateSimUI() {
}
export function adjustSpeed(delta) {
const wasRunning = state.simRunning;
state.simSpeed = Math.max(50, Math.min(2000, state.simSpeed + delta));
if (state.simRunning) {
clearInterval(state.simInterval);
state.simInterval = setInterval(simTick, state.simSpeed);
}
// No need to restart — the loop reads simSpeed dynamically
updateSimUI();
}

View File

@@ -36,5 +36,19 @@ export const state = {
simSpeed: 500, // ms per tick
// Puzzle/Components
customComponents: {} // { id -> component definition }
customComponents: {}, // { id -> component definition }
// Component Editor
componentEditorActive: false,
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
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}`;
@@ -20,6 +21,11 @@ export function getGateLabel(gate) {
return `${gate.type}_${idx}`;
}
/**
* Record a sample triggered by user interaction (INPUT toggle).
* When sim is running, records at current timeStep WITHOUT advancing it.
* When sim is stopped, advances timeStep first.
*/
export function recordSample() {
const { gates, waveData } = state;
@@ -31,8 +37,11 @@ export function recordSample() {
if (!changed && state.timeStep > 0) return;
// Manual toggles advance by simSpeed too for consistency
// Only advance time if sim is NOT running (manual mode)
if (!state.simRunning) {
state.timeStep += state.simSpeed;
}
gates.forEach(g => {
if (!waveData[g.id]) waveData[g.id] = [];
const arr = waveData[g.id];
@@ -43,8 +52,11 @@ export function recordSample() {
updateWaveInfo();
}
/**
* Record a sample from the simulation tick.
* Always advances timeStep — this is the ONLY source of time when sim is running.
*/
export function forceRecordSample() {
// Advance time by the current simSpeed (in ms) to reflect real time
state.timeStep += state.simSpeed;
state.gates.forEach(g => {
if (!state.waveData[g.id]) state.waveData[g.id] = [];