refactor: modularize into ES6 modules

Split monolithic index.html into:
- js/constants.js - gate config, colors, dimensions
- js/state.js     - shared application state
- js/gates.js     - evaluation logic, port geometry
- js/renderer.js  - canvas drawing
- js/waveform.js  - GTKWave-style signal viewer
- js/simulation.js - clock tick engine
- js/events.js    - mouse, keyboard, UI handlers
- js/app.js       - entry point
- css/style.css   - all styles
This commit is contained in:
Jose Luis Montañes
2026-03-19 22:00:02 +01:00
parent aa97b37f0a
commit 7409a96cf1
11 changed files with 1028 additions and 967 deletions

View File

@@ -1,3 +1,5 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
COPY index.html /usr/share/nginx/html/
COPY css/ /usr/share/nginx/html/css/
COPY js/ /usr/share/nginx/html/js/
EXPOSE 80

208
css/style.css Normal file
View File

@@ -0,0 +1,208 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0a0f;
color: #e0e0e0;
overflow: hidden;
height: 100vh;
}
/* ==================== Toolbar ==================== */
#toolbar {
position: fixed;
top: 0; left: 0; right: 0;
height: 48px;
background: #12121a;
border-bottom: 1px solid #2a2a3a;
display: flex;
align-items: center;
padding: 0 12px;
gap: 6px;
z-index: 100;
}
#toolbar .logo {
font-weight: 700;
font-size: 15px;
color: #00e599;
margin-right: 12px;
}
.gate-btn {
padding: 5px 12px;
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;
}
.gate-btn:hover { background: #252540; border-color: #00e599; color: #fff; }
.gate-btn.input-btn { border-color: #3388ff; }
.gate-btn.input-btn:hover { border-color: #55aaff; }
.gate-btn.clock-btn { border-color: #ff44aa; }
.gate-btn.clock-btn:hover { border-color: #ff66cc; }
.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; }
.toolbar-right { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.action-btn {
padding: 5px 10px;
background: transparent;
border: 1px solid #333;
border-radius: 6px;
color: #888;
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
}
.action-btn:hover { border-color: #ff4444; color: #ff4444; }
.action-btn.help-btn:hover { border-color: #00e599; color: #00e599; }
.action-btn.sim-btn { border-color: #ff44aa; color: #ff44aa; }
.action-btn.sim-btn:hover { background: #ff44aa22; }
.action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; }
/* ==================== Canvas ==================== */
#canvas {
position: fixed;
top: 48px; left: 0; right: 0; bottom: 0;
cursor: default;
}
/* ==================== Waveform Panel ==================== */
#waveform-panel {
display: none;
position: fixed;
left: 0; right: 0; bottom: 0;
height: 220px;
background: #0c0c14;
border-top: 2px solid #00e599;
z-index: 90;
flex-direction: column;
}
#waveform-panel.visible { display: flex; }
#wave-toolbar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 8px;
background: #10101a;
border-bottom: 1px solid #1a1a2a;
height: 32px;
flex-shrink: 0;
}
#wave-toolbar span.wave-title {
font-size: 12px;
font-weight: 700;
color: #00e599;
margin-right: 8px;
}
.wave-btn {
padding: 3px 10px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 4px;
color: #aaa;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s;
}
.wave-btn:hover { border-color: #00e599; color: #fff; }
.wave-btn.active { background: #00e59933; border-color: #00e599; color: #00e599; }
.wave-btn.record { border-color: #ff4444; }
.wave-btn.record.active { background: #ff444433; border-color: #ff4444; color: #ff4444; }
.wave-info { margin-left: auto; font-size: 11px; color: #555; }
#wave-container { display: flex; flex: 1; overflow: hidden; }
#wave-labels {
width: 100px;
flex-shrink: 0;
overflow-y: auto;
background: #0e0e18;
border-right: 1px solid #1a1a2a;
}
.wave-label {
height: 30px;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
border-bottom: 1px solid #111;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wave-label.input-label { color: #3388ff; }
.wave-label.output-label { color: #ff8833; }
.wave-label.gate-label { color: #00e599; }
#wave-canvas { flex: 1; cursor: crosshair; }
/* ==================== Help Modal ==================== */
#help-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 200;
justify-content: center;
align-items: center;
}
#help-modal.visible { display: flex; }
#help-content {
background: #12121a;
border: 1px solid #2a2a3a;
border-radius: 12px;
padding: 24px;
max-width: 520px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
#help-content h2 { color: #00e599; margin-bottom: 12px; font-size: 18px; }
#help-content h3 { color: #ff44aa; margin: 12px 0 8px; font-size: 14px; }
#help-content p, #help-content li { color: #aaa; font-size: 13px; line-height: 1.5; margin-bottom: 6px; }
#help-content ul { padding-left: 20px; }
#help-content kbd {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 4px;
padding: 1px 5px;
font-size: 11px;
color: #ddd;
}
#help-close {
margin-top: 12px;
padding: 6px 18px;
background: #00e599;
border: none;
border-radius: 6px;
color: #000;
font-weight: 600;
cursor: pointer;
}
/* ==================== Resize Handle ==================== */
#wave-resize {
position: absolute;
top: -4px; left: 0; right: 0;
height: 8px;
cursor: ns-resize;
z-index: 91;
}

File diff suppressed because it is too large Load Diff

8
js/app.js Normal file
View File

@@ -0,0 +1,8 @@
// Entry point — initializes all modules
import { initRenderer } from './renderer.js';
import { initEvents } from './events.js';
document.addEventListener('DOMContentLoaded', () => {
initRenderer();
initEvents();
});

26
js/constants.js Normal file
View File

@@ -0,0 +1,26 @@
// Gate dimensions and rendering constants
export const GATE_W = 100;
export const GATE_H = 60;
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'
};
export const SIGNAL_COLORS = [
'#00e599', '#3388ff', '#ff6644', '#e5cc00',
'#cc44ff', '#ff44aa', '#ff8833', '#44ddff',
'#88ff44', '#ff4488', '#44ffaa', '#ffaa44'
];
export function gateInputCount(type) {
if (type === 'CLOCK' || type === 'INPUT') return 0;
if (type === 'NOT' || type === 'OUTPUT') return 1;
return 2;
}
export function gateOutputCount(type) {
return type === 'OUTPUT' ? 0 : 1;
}

193
js/events.js Normal file
View File

@@ -0,0 +1,193 @@
// Event handlers — mouse, keyboard, toolbar, waveform controls
import { GATE_W, GATE_H } from './constants.js';
import { state } from './state.js';
import { evaluateAll, findGateAt, findPortAt } from './gates.js';
import { manualStep, clearWaveData, updateWaveInfo } from './waveform.js';
import { startSim, stopSim, adjustSpeed } from './simulation.js';
import { resize } from './renderer.js';
export function initEvents() {
const canvas = document.getElementById('canvas');
// ==================== CANVAS MOUSE ====================
canvas.addEventListener('mousemove', e => {
state.mouseX = e.offsetX;
state.mouseY = e.offsetY;
state.hoveredPort = findPortAt(state.mouseX, state.mouseY);
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(state.mouseX, state.mouseY);
if (state.dragging) {
state.dragging.x = state.mouseX - state.dragOffset.x;
state.dragging.y = state.mouseY - state.dragOffset.y;
evaluateAll();
}
canvas.style.cursor = state.placingGate ? 'crosshair'
: state.hoveredPort ? 'pointer'
: state.hoveredGate ? 'grab'
: 'default';
});
canvas.addEventListener('mousedown', e => {
if (e.button !== 0) return;
const x = e.offsetX, y = e.offsetY;
// Placing a new gate
if (state.placingGate) {
state.gates.push({
id: state.nextId++,
type: state.placingGate,
x: x - GATE_W / 2,
y: y - GATE_H / 2,
value: 0
});
evaluateAll();
state.placingGate = null;
return;
}
// Port click — connecting
const port = findPortAt(x, y);
if (port) {
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 });
evaluateAll();
} 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 });
evaluateAll();
}
state.connecting = null;
} else {
state.connecting = { gate: port.gate, portIndex: port.index, portType: port.type };
}
return;
}
if (state.connecting) { state.connecting = null; return; }
// Toggle INPUT/CLOCK
const gate = findGateAt(x, y);
if (gate && (gate.type === 'INPUT' || gate.type === 'CLOCK')) {
gate.value = gate.value ? 0 : 1;
evaluateAll();
return;
}
// Drag gate
if (gate) {
state.dragging = gate;
state.dragOffset = { x: x - gate.x, y: y - gate.y };
canvas.style.cursor = 'grabbing';
}
});
canvas.addEventListener('mouseup', () => { state.dragging = null; });
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const x = e.offsetX, y = e.offsetY;
const port = findPortAt(x, y);
if (port && port.type === 'input') {
state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index));
evaluateAll();
} else if (port && port.type === 'output') {
state.connections = state.connections.filter(c => !(c.from === port.gate.id && c.fromPort === port.index));
evaluateAll();
}
});
// ==================== KEYBOARD ====================
document.addEventListener('keydown', e => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.hoveredGate && document.activeElement === document.body) {
e.preventDefault();
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);
delete state.waveData[gateId];
state.hoveredGate = null;
evaluateAll();
}
}
if (e.key === 'Escape') {
state.placingGate = null;
state.connecting = null;
}
});
// ==================== TOOLBAR ====================
document.querySelectorAll('.gate-btn').forEach(btn => {
btn.addEventListener('click', () => { state.placingGate = btn.dataset.gate; });
});
document.getElementById('clear-btn').addEventListener('click', () => {
if (state.gates.length === 0 || confirm('Clear all gates and connections?')) {
state.gates = [];
state.connections = [];
state.connecting = null;
state.placingGate = null;
clearWaveData();
}
});
// Help modal
document.getElementById('help-btn').addEventListener('click', () => {
document.getElementById('help-modal').classList.add('visible');
});
document.getElementById('help-close').addEventListener('click', () => {
document.getElementById('help-modal').classList.remove('visible');
});
document.getElementById('help-modal').addEventListener('click', e => {
if (e.target === e.currentTarget) document.getElementById('help-modal').classList.remove('visible');
});
// Waveform toggle
document.getElementById('sim-btn').addEventListener('click', () => {
state.waveformVisible = !state.waveformVisible;
document.getElementById('waveform-panel').classList.toggle('visible', state.waveformVisible);
document.getElementById('sim-btn').classList.toggle('active', state.waveformVisible);
resize();
});
// ==================== WAVEFORM CONTROLS ====================
document.getElementById('wave-record').addEventListener('click', function() {
state.recording = !state.recording;
this.classList.toggle('active', state.recording);
this.textContent = state.recording ? '⏺ Record' : '⏸ Paused';
});
document.getElementById('wave-clear').addEventListener('click', clearWaveData);
document.getElementById('wave-step').addEventListener('click', manualStep);
document.getElementById('wave-zoom-in').addEventListener('click', () => {
state.waveZoom = Math.min(60, state.waveZoom + 5);
});
document.getElementById('wave-zoom-out').addEventListener('click', () => {
state.waveZoom = Math.max(5, state.waveZoom - 5);
});
// ==================== SIMULATION CONTROLS ====================
document.getElementById('sim-run-btn').addEventListener('click', () => {
if (state.simRunning) stopSim(); else startSim();
});
document.getElementById('sim-faster').addEventListener('click', () => adjustSpeed(-100));
document.getElementById('sim-slower').addEventListener('click', () => adjustSpeed(100));
// ==================== WAVEFORM PANEL RESIZE ====================
const resizeHandle = document.getElementById('wave-resize');
resizeHandle.addEventListener('mousedown', e => {
state.resizingWave = true;
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (state.resizingWave) {
state.waveformHeight = Math.max(100, Math.min(500, window.innerHeight - e.clientY));
document.getElementById('waveform-panel').style.height = state.waveformHeight + 'px';
resize();
}
});
document.addEventListener('mouseup', () => { state.resizingWave = false; });
}

78
js/gates.js Normal file
View File

@@ -0,0 +1,78 @@
// Gate evaluation and port geometry
import { GATE_W, GATE_H, PORT_R, gateInputCount, gateOutputCount } from './constants.js';
import { state } from './state.js';
import { recordSample } from './waveform.js';
export function getInputPorts(gate) {
const count = gateInputCount(gate.type);
const ports = [];
for (let i = 0; i < count; i++) {
const spacing = GATE_H / (count + 1);
ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' });
}
return ports;
}
export function getOutputPorts(gate) {
const count = gateOutputCount(gate.type);
const ports = [];
for (let i = 0; i < count; i++) {
ports.push({ x: gate.x + GATE_W, y: gate.y + GATE_H / 2, 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);
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
const inputCount = gateInputCount(gate.type);
const inputs = [];
for (let i = 0; i < inputCount; i++) {
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);
} 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;
}
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();
}
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);
}
export function findPortAt(x, y) {
for (const gate of state.gates) {
for (const p of getInputPorts(gate)) {
if (Math.hypot(x - p.x, y - p.y) < PORT_R + 4) return { gate, index: p.index, type: 'input' };
}
for (const p of getOutputPorts(gate)) {
if (Math.hypot(x - p.x, y - p.y) < PORT_R + 4) return { gate, index: p.index, type: 'output' };
}
}
return null;
}

188
js/renderer.js Normal file
View File

@@ -0,0 +1,188 @@
// Canvas rendering — gates, connections, grid
import { GATE_W, GATE_H, PORT_R, GATE_COLORS } from './constants.js';
import { state } from './state.js';
import { getInputPorts, getOutputPorts } from './gates.js';
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
let canvas, ctx;
export function initRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resize();
window.addEventListener('resize', resize);
requestAnimationFrame(draw);
}
export function resize() {
canvas.width = window.innerWidth;
const waveH = state.waveformVisible ? state.waveformHeight : 0;
canvas.height = window.innerHeight - 48 - waveH;
}
function drawGate(gate) {
const color = GATE_COLORS[gate.type];
const isHovered = state.hoveredGate === gate;
const isActive = gate.value === 1;
if (isActive) {
ctx.shadowColor = color;
ctx.shadowBlur = 20;
}
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, GATE_W, GATE_H, 8);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
// Label
ctx.fillStyle = isActive ? '#fff' : color;
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK';
ctx.fillText(
gate.type === 'CLOCK' ? '⏱ CLK' : gate.type,
gate.x + GATE_W / 2,
gate.y + GATE_H / 2 - (isIOType ? 8 : 0)
);
// Small ID label
ctx.font = '9px monospace';
ctx.fillStyle = '#444';
ctx.fillText(getGateLabel(gate), gate.x + GATE_W / 2, gate.y + GATE_H - 6);
// Value for INPUT/OUTPUT/CLOCK
if (isIOType) {
ctx.font = 'bold 16px monospace';
ctx.fillStyle = gate.value ? '#00ff88' : '#ff4444';
ctx.fillText(gate.value ? '1' : '0', gate.x + GATE_W / 2, gate.y + GATE_H / 2 + 10);
}
// 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 = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
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();
});
// Output ports
getOutputPorts(gate).forEach(p => {
const isPortHovered = state.hoveredPort &&
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output';
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
function drawConnection(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;
const fromPort = getOutputPorts(fromGate)[conn.fromPort];
const toPort = getInputPorts(toGate)[conn.toPort];
if (!fromPort || !toPort) return;
const active = fromGate.value === 1;
const midX = (fromPort.x + toPort.x) / 2;
ctx.beginPath();
ctx.moveTo(fromPort.x, fromPort.y);
ctx.bezierCurveTo(midX, fromPort.y, midX, toPort.y, toPort.x, toPort.y);
ctx.strokeStyle = active ? '#00ff88' : '#333';
ctx.lineWidth = active ? 2.5 : 1.5;
ctx.stroke();
if (active) {
ctx.strokeStyle = '#00ff8844';
ctx.lineWidth = 6;
ctx.stroke();
}
}
function drawGrid() {
ctx.strokeStyle = '#111118';
ctx.lineWidth = 1;
for (let x = 0; x < canvas.width; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke();
}
for (let y = 0; y < canvas.height; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke();
}
}
function drawConnectingWire() {
if (!state.connecting) return;
const gate = state.connecting.gate;
const port = state.connecting.portType === 'output'
? getOutputPorts(gate)[state.connecting.portIndex]
: getInputPorts(gate)[state.connecting.portIndex];
if (!port) return;
const midX = (port.x + state.mouseX) / 2;
ctx.beginPath();
ctx.moveTo(port.x, port.y);
ctx.bezierCurveTo(midX, port.y, midX, state.mouseY, state.mouseX, state.mouseY);
ctx.strokeStyle = '#00e59988';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
function drawPlacingGhost() {
if (!state.placingGate) return;
ctx.globalAlpha = 0.5;
const ghost = {
x: state.mouseX - GATE_W / 2,
y: state.mouseY - GATE_H / 2,
type: state.placingGate,
value: 0,
id: -1
};
drawGate(ghost);
ctx.globalAlpha = 1;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawGrid();
state.connections.forEach(drawConnection);
state.gates.forEach(drawGate);
drawConnectingWire();
drawPlacingGhost();
if (state.waveformVisible) {
drawWaveLabels();
drawWaveforms();
}
requestAnimationFrame(draw);
}

66
js/simulation.js Normal file
View File

@@ -0,0 +1,66 @@
// Clock simulation engine
import { state } from './state.js';
import { evaluateAll } from './gates.js';
import { forceRecordSample } from './waveform.js';
export function simTick() {
// Toggle all CLOCK gates
state.gates.forEach(g => {
if (g.type === 'CLOCK') {
g.value = g.value ? 0 : 1;
}
});
evaluateAll();
// Force record even if evaluateAll didn't detect change
if (state.recording && state.waveformVisible) {
forceRecordSample();
}
}
export function startSim() {
if (state.simRunning) return;
const hasClocks = state.gates.some(g => g.type === 'CLOCK');
if (!hasClocks) { alert('Place a CLOCK gate first!'); return; }
state.simRunning = true;
// Auto-open waveform panel
if (!state.waveformVisible) {
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);
updateSimUI();
}
export function stopSim() {
state.simRunning = false;
if (state.simInterval) clearInterval(state.simInterval);
state.simInterval = null;
updateSimUI();
}
export function updateSimUI() {
const btn = document.getElementById('sim-run-btn');
if (state.simRunning) {
btn.textContent = '⏹ Stop';
btn.classList.add('active');
} else {
btn.textContent = '▶ Run';
btn.classList.remove('active');
}
document.getElementById('sim-speed-label').textContent = `${state.simSpeed}ms`;
}
export function adjustSpeed(delta) {
state.simSpeed = Math.max(50, Math.min(2000, state.simSpeed + delta));
if (state.simRunning) {
clearInterval(state.simInterval);
state.simInterval = setInterval(simTick, state.simSpeed);
}
updateSimUI();
}

32
js/state.js Normal file
View File

@@ -0,0 +1,32 @@
// Shared application state — single source of truth
export const state = {
// Circuit
gates: [],
connections: [],
nextId: 1,
// Interaction
placingGate: null,
dragging: null,
dragOffset: { x: 0, y: 0 },
connecting: null,
hoveredGate: null,
hoveredPort: null,
mouseX: 0,
mouseY: 0,
// Waveform
waveformVisible: false,
waveformHeight: 220,
recording: true,
waveData: {}, // { gateId: [{ t, value }] }
timeStep: 0,
waveZoom: 20, // pixels per time step
waveScroll: 0,
resizingWave: false,
// Simulation
simRunning: false,
simInterval: null,
simSpeed: 500 // ms per tick
};

212
js/waveform.js Normal file
View File

@@ -0,0 +1,212 @@
// Waveform recording and rendering (GTKWave-style)
import { SIGNAL_COLORS } from './constants.js';
import { state } from './state.js';
export function getTrackedGates() {
const { gates } = state;
const clocks = gates.filter(g => g.type === 'CLOCK');
const inputs = gates.filter(g => g.type === 'INPUT');
const outputs = gates.filter(g => g.type === 'OUTPUT');
const logic = gates.filter(g => g.type !== 'INPUT' && g.type !== 'OUTPUT' && g.type !== 'CLOCK');
return [...clocks, ...inputs, ...logic, ...outputs];
}
export function getGateLabel(gate) {
const sameType = state.gates.filter(g => g.type === gate.type);
const idx = sameType.indexOf(gate);
if (gate.type === 'CLOCK') return `CLK_${idx}`;
if (gate.type === 'INPUT') return `IN_${idx}`;
if (gate.type === 'OUTPUT') return `OUT_${idx}`;
return `${gate.type}_${idx}`;
}
export function recordSample() {
const { gates, waveData } = state;
const changed = gates.some(g => {
const data = waveData[g.id];
if (!data || data.length === 0) return true;
return data[data.length - 1].value !== g.value;
});
if (!changed && state.timeStep > 0) return;
state.timeStep++;
gates.forEach(g => {
if (!waveData[g.id]) waveData[g.id] = [];
const arr = waveData[g.id];
if (arr.length === 0 || arr[arr.length - 1].value !== g.value) {
arr.push({ t: state.timeStep, value: g.value });
}
});
updateWaveInfo();
}
export function forceRecordSample() {
state.timeStep++;
state.gates.forEach(g => {
if (!state.waveData[g.id]) state.waveData[g.id] = [];
state.waveData[g.id].push({ t: state.timeStep, value: g.value });
});
updateWaveInfo();
}
export function manualStep() {
forceRecordSample();
}
export function updateWaveInfo() {
const totalSamples = Object.values(state.waveData).reduce((sum, arr) => sum + arr.length, 0);
document.getElementById('wave-info').textContent = `T=${state.timeStep} | ${totalSamples} samples`;
}
export function clearWaveData() {
state.waveData = {};
state.timeStep = 0;
state.waveScroll = 0;
updateWaveInfo();
}
export function drawWaveLabels() {
const labelsEl = document.getElementById('wave-labels');
labelsEl.innerHTML = '';
const tracked = getTrackedGates();
tracked.forEach((gate, i) => {
const div = document.createElement('div');
div.className = 'wave-label';
if (gate.type === 'INPUT') div.classList.add('input-label');
else if (gate.type === 'OUTPUT') div.classList.add('output-label');
else div.classList.add('gate-label');
div.textContent = getGateLabel(gate);
div.style.color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
labelsEl.appendChild(div);
});
}
export function drawWaveforms() {
const wc = document.getElementById('wave-canvas');
const wctx = wc.getContext('2d');
const container = document.getElementById('wave-container');
wc.width = container.clientWidth - 100;
wc.height = container.clientHeight;
wctx.fillStyle = '#0c0c14';
wctx.fillRect(0, 0, wc.width, wc.height);
const tracked = getTrackedGates();
const rowH = 30;
const sigH = 20;
const margin = (rowH - sigH) / 2;
if (state.timeStep === 0) {
wctx.fillStyle = '#333';
wctx.font = '12px "Segoe UI", system-ui';
wctx.textAlign = 'center';
wctx.fillText('Toggle inputs to record signals...', wc.width / 2, wc.height / 2);
return;
}
// Auto-scroll to show latest
const maxVisible = Math.floor(wc.width / state.waveZoom);
if (state.timeStep > maxVisible) {
state.waveScroll = state.timeStep - maxVisible;
}
// Draw time grid
wctx.strokeStyle = '#151520';
wctx.lineWidth = 1;
for (let t = Math.ceil(state.waveScroll); t <= state.timeStep; t++) {
const x = (t - state.waveScroll) * state.waveZoom;
if (x < 0 || x > wc.width) continue;
wctx.beginPath();
wctx.moveTo(x, 0);
wctx.lineTo(x, wc.height);
wctx.stroke();
if (t % 5 === 0 || state.waveZoom > 30) {
wctx.fillStyle = '#333';
wctx.font = '9px monospace';
wctx.textAlign = 'center';
wctx.fillText(`${t}`, x, 10);
}
}
// Row dividers
tracked.forEach((_, i) => {
const y = i * rowH + rowH;
wctx.strokeStyle = '#111118';
wctx.beginPath();
wctx.moveTo(0, y);
wctx.lineTo(wc.width, y);
wctx.stroke();
});
// Draw signals
tracked.forEach((gate, i) => {
const data = state.waveData[gate.id] || [];
if (data.length === 0) return;
const color = SIGNAL_COLORS[i % SIGNAL_COLORS.length];
const y0 = i * rowH + margin;
const yHigh = y0 + 2;
const yLow = y0 + sigH;
wctx.strokeStyle = color;
wctx.lineWidth = 1.5;
wctx.beginPath();
let lastVal = 0;
let started = false;
// Build complete signal timeline
const timeline = [];
for (let t = 1; t <= state.timeStep; t++) {
const sample = data.filter(s => s.t <= t).pop();
timeline.push(sample ? sample.value : 0);
}
for (let t = 0; t < timeline.length; t++) {
const x = (t + 1 - state.waveScroll) * state.waveZoom;
const val = timeline[t];
const y = val ? yHigh : yLow;
if (!started) {
wctx.moveTo(x, y);
started = true;
} else {
if (val !== lastVal) {
wctx.lineTo(x, lastVal ? yHigh : yLow);
wctx.lineTo(x, y);
}
wctx.lineTo(x + state.waveZoom, y);
}
lastVal = val;
}
wctx.stroke();
// Fill area under signal
wctx.globalAlpha = 0.08;
wctx.fillStyle = color;
for (let t = 0; t < timeline.length; t++) {
const x = (t + 1 - state.waveScroll) * state.waveZoom;
if (timeline[t]) {
wctx.fillRect(x, yHigh, state.waveZoom, sigH);
}
}
wctx.globalAlpha = 1;
});
// Cursor line at current time
const cursorX = (state.timeStep - state.waveScroll) * state.waveZoom;
if (cursorX >= 0 && cursorX <= wc.width) {
wctx.strokeStyle = '#00e59966';
wctx.lineWidth = 1;
wctx.setLineDash([4, 3]);
wctx.beginPath();
wctx.moveTo(cursorX, 0);
wctx.lineTo(cursorX, wc.height);
wctx.stroke();
wctx.setLineDash([]);
}
}