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>
This commit is contained in:
125
css/style.css
125
css/style.css
@@ -41,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;
|
||||
@@ -97,10 +97,60 @@ body {
|
||||
.action-btn.sim-btn:hover { background: #ff44aa22; }
|
||||
.action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; }
|
||||
|
||||
/* ==================== Toolbar Sections ==================== */
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
height: 56px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 8px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 6px;
|
||||
height: 10px;
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
.toolbar-section .gate-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-section #saved-components {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-section .component-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 4px;
|
||||
color: #9900ff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-section .component-btn:hover {
|
||||
border-color: #9900ff;
|
||||
color: #cc66ff;
|
||||
}
|
||||
|
||||
/* ==================== 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;
|
||||
}
|
||||
@@ -273,14 +323,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: 105;
|
||||
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;
|
||||
@@ -293,7 +408,7 @@ body {
|
||||
}
|
||||
|
||||
.puzzle-panel.puzzle-info {
|
||||
top: 48px;
|
||||
top: 56px;
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
|
||||
49
index.html
49
index.html
@@ -9,17 +9,39 @@
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span class="logo">⚡ Logic Lab</span>
|
||||
<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>
|
||||
|
||||
<!-- I/O Section -->
|
||||
<div class="toolbar-section">
|
||||
<div class="section-label">I/O</div>
|
||||
<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>
|
||||
|
||||
<div class="separator"></div>
|
||||
<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>
|
||||
|
||||
<!-- Gates Section -->
|
||||
<div class="toolbar-section">
|
||||
<div class="section-label">Gates</div>
|
||||
<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>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- Components Section -->
|
||||
<div class="toolbar-section" id="components-section">
|
||||
<div class="section-label">Components</div>
|
||||
<button class="gate-btn create-component-btn" id="create-component-btn" title="Create custom component">✚ Create</button>
|
||||
<div id="saved-components"></div>
|
||||
</div>
|
||||
|
||||
<div class="separator"></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 +54,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>
|
||||
|
||||
@@ -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
|
||||
@@ -204,3 +210,79 @@ export function importComponent(data) {
|
||||
state.customComponents[data.id] = data;
|
||||
return { success: true, component: data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter component editor mode
|
||||
*/
|
||||
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.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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit component editor mode
|
||||
*/
|
||||
export function exitComponentEditor(name, shouldSave) {
|
||||
const overlay = document.getElementById('component-editor-overlay');
|
||||
overlay.style.display = 'none';
|
||||
|
||||
if (shouldSave && name) {
|
||||
// Save the component
|
||||
saveComponentFromCircuit(name);
|
||||
}
|
||||
|
||||
// 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.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)`;
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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 = {
|
||||
@@ -18,9 +19,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;
|
||||
}
|
||||
|
||||
59
js/events.js
59
js/events.js
@@ -1,13 +1,14 @@
|
||||
// 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, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
||||
|
||||
const PAN_SPEED = 40;
|
||||
|
||||
@@ -19,6 +20,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;
|
||||
@@ -62,13 +66,27 @@ export function initEvents() {
|
||||
|
||||
// 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) {
|
||||
w = getComponentWidth({ component });
|
||||
h = getComponentHeight({ component });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return;
|
||||
@@ -311,4 +329,33 @@ export function initEvents() {
|
||||
}
|
||||
});
|
||||
document.addEventListener('mouseup', () => { state.resizingWave = false; });
|
||||
|
||||
// ==================== COMPONENT EDITOR ====================
|
||||
document.getElementById('create-component-btn').addEventListener('click', () => {
|
||||
enterComponentEditor();
|
||||
});
|
||||
|
||||
document.getElementById('component-editor-save').addEventListener('click', () => {
|
||||
const name = prompt('Component name:', 'MyComponent');
|
||||
if (name && name.trim()) {
|
||||
exitComponentEditor(name.trim(), true);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('component-editor-cancel').addEventListener('click', () => {
|
||||
if (confirm('Discard component without saving?')) {
|
||||
exitComponentEditor('', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Event delegation for saved component buttons
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('component-btn')) {
|
||||
const componentId = e.target.dataset.componentId;
|
||||
state.placingGate = `COMPONENT:${componentId}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Update component buttons initially and when customComponents changes
|
||||
updateComponentButtons();
|
||||
}
|
||||
|
||||
77
js/gates.js
77
js/gates.js
@@ -1,13 +1,52 @@
|
||||
// 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 types
|
||||
export function gateInputCount(type) {
|
||||
if (type.startsWith('COMPONENT:')) {
|
||||
const componentId = type.substring(10);
|
||||
const component = state.customComponents?.[componentId];
|
||||
return component ? component.inputCount : 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;
|
||||
}
|
||||
return baseGateOutputCount(type);
|
||||
}
|
||||
|
||||
export function getComponentWidth(gate) {
|
||||
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 (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 isComponent = gate.type.startsWith('COMPONENT:');
|
||||
const gateHeight = isComponent ? 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,8 +55,13 @@ export function getInputPorts(gate) {
|
||||
export function getOutputPorts(gate) {
|
||||
const count = gateOutputCount(gate.type);
|
||||
const ports = [];
|
||||
const isComponent = gate.type.startsWith('COMPONENT:');
|
||||
const gateWidth = isComponent ? getComponentWidth(gate) : GATE_W;
|
||||
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -40,14 +84,19 @@ export function evaluate(gate, visited = new Set()) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (gate.type.startsWith('COMPONENT:')) {
|
||||
const outputs = evaluateComponent(gate, inputs);
|
||||
result = outputs[0] || 0;
|
||||
} else {
|
||||
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;
|
||||
@@ -65,7 +114,11 @@ export function evaluateAll(recordWave = false) {
|
||||
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 w = g.type.startsWith('COMPONENT:') ? getComponentWidth(g) : GATE_W;
|
||||
const h = g.type.startsWith('COMPONENT:') ? getComponentHeight(g) : GATE_H;
|
||||
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
|
||||
});
|
||||
}
|
||||
|
||||
export function findPortAt(x, y) {
|
||||
|
||||
101
js/renderer.js
101
js/renderer.js
@@ -1,7 +1,7 @@
|
||||
// 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';
|
||||
|
||||
let canvas, ctx;
|
||||
@@ -19,7 +19,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)
|
||||
@@ -31,6 +32,11 @@ export function screenToWorld(sx, sy) {
|
||||
}
|
||||
|
||||
function drawGate(gate) {
|
||||
// Component gates have different rendering
|
||||
if (gate.type.startsWith('COMPONENT:')) {
|
||||
return drawComponentGate(gate);
|
||||
}
|
||||
|
||||
const color = GATE_COLORS[gate.type];
|
||||
const isHovered = state.hoveredGate === gate;
|
||||
const isActive = gate.value === 1;
|
||||
@@ -110,6 +116,76 @@ function drawGate(gate) {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
@@ -181,12 +257,25 @@ 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;
|
||||
|
||||
@@ -36,5 +36,10 @@ 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: ''
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user