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 {
|
#toolbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0; left: 0; right: 0;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
background: #12121a;
|
background: #12121a;
|
||||||
border-bottom: 1px solid #2a2a3a;
|
border-bottom: 1px solid #2a2a3a;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -97,10 +97,60 @@ body {
|
|||||||
.action-btn.sim-btn:hover { background: #ff44aa22; }
|
.action-btn.sim-btn:hover { background: #ff44aa22; }
|
||||||
.action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; }
|
.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 ==================== */
|
||||||
#canvas {
|
#canvas {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 48px; left: 0; right: 0; bottom: 0;
|
top: 56px; left: 0; right: 0; bottom: 0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition: left 0.2s ease;
|
transition: left 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -273,14 +323,79 @@ body {
|
|||||||
color: #00e599;
|
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 Panels ==================== */
|
||||||
.puzzle-panel {
|
.puzzle-panel {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 48px;
|
top: 56px;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
height: calc(100vh - 48px);
|
height: calc(100vh - 56px);
|
||||||
background: #12121a;
|
background: #12121a;
|
||||||
border-right: 1px solid #2a2a3a;
|
border-right: 1px solid #2a2a3a;
|
||||||
z-index: 95;
|
z-index: 95;
|
||||||
@@ -293,7 +408,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.puzzle-panel.puzzle-info {
|
.puzzle-panel.puzzle-info {
|
||||||
top: 48px;
|
top: 56px;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
index.html
31
index.html
@@ -9,17 +9,39 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="toolbar">
|
<div id="toolbar">
|
||||||
<span class="logo">⚡ Logic Lab</span>
|
<span class="logo">⚡ Logic Lab</span>
|
||||||
|
|
||||||
|
<!-- 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 input-btn" data-gate="INPUT">INPUT</button>
|
||||||
<button class="gate-btn clock-btn" data-gate="CLOCK">CLOCK</button>
|
<button class="gate-btn clock-btn" data-gate="CLOCK">CLOCK</button>
|
||||||
<button class="gate-btn output-btn" data-gate="OUTPUT">OUTPUT</button>
|
<button class="gate-btn output-btn" data-gate="OUTPUT">OUTPUT</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
<!-- 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="AND">AND</button>
|
||||||
<button class="gate-btn" data-gate="OR">OR</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="NOT">NOT</button>
|
||||||
<button class="gate-btn" data-gate="NAND">NAND</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="NOR">NOR</button>
|
||||||
<button class="gate-btn" data-gate="XOR">XOR</button>
|
<button class="gate-btn" data-gate="XOR">XOR</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="separator"></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>
|
<button class="action-btn sim-btn" id="sim-btn">Waveform</button>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
|
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
|
||||||
@@ -32,6 +54,15 @@
|
|||||||
<!-- Hidden file input for import -->
|
<!-- Hidden file input for import -->
|
||||||
<input type="file" id="import-file" accept=".json" style="display:none">
|
<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 -->
|
<!-- Mode toggle will be inserted here by puzzleUI.js -->
|
||||||
|
|
||||||
<canvas id="canvas"></canvas>
|
<canvas id="canvas"></canvas>
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { GATE_W, GATE_H } from './constants.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
|
* Save current circuit as a reusable component
|
||||||
* Returns the component ID if successful
|
* Returns the component ID if successful
|
||||||
@@ -204,3 +210,79 @@ export function importComponent(data) {
|
|||||||
state.customComponents[data.id] = data;
|
state.customComponents[data.id] = data;
|
||||||
return { success: true, component: 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
|
// Gate dimensions and rendering constants
|
||||||
export const GATE_W = 100;
|
export const GATE_W = 100;
|
||||||
export const GATE_H = 60;
|
export const GATE_H = 60;
|
||||||
|
export const COMP_W = 120;
|
||||||
export const PORT_R = 7;
|
export const PORT_R = 7;
|
||||||
|
|
||||||
export const GATE_COLORS = {
|
export const GATE_COLORS = {
|
||||||
@@ -18,9 +19,18 @@ export const SIGNAL_COLORS = [
|
|||||||
export function gateInputCount(type) {
|
export function gateInputCount(type) {
|
||||||
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
||||||
if (type === 'NOT' || type === 'OUTPUT') return 1;
|
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;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function gateOutputCount(type) {
|
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
|
// 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 { 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 { manualStep, clearWaveData } from './waveform.js';
|
||||||
import { startSim, stopSim, adjustSpeed } from './simulation.js';
|
import { startSim, stopSim, adjustSpeed } from './simulation.js';
|
||||||
import { resize, screenToWorld } from './renderer.js';
|
import { resize, screenToWorld } from './renderer.js';
|
||||||
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
|
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
|
||||||
import { getLevel } from './levels.js';
|
import { getLevel } from './levels.js';
|
||||||
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
||||||
|
import { enterComponentEditor, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
||||||
|
|
||||||
const PAN_SPEED = 40;
|
const PAN_SPEED = 40;
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ function updateWaveZoomLabel() {
|
|||||||
export function initEvents() {
|
export function initEvents() {
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
|
|
||||||
|
// Set up resize callback for component editor
|
||||||
|
setResizeCallback(resize);
|
||||||
|
|
||||||
// ==================== CANVAS MOUSE ====================
|
// ==================== CANVAS MOUSE ====================
|
||||||
canvas.addEventListener('mousemove', e => {
|
canvas.addEventListener('mousemove', e => {
|
||||||
state.mouseX = e.offsetX;
|
state.mouseX = e.offsetX;
|
||||||
@@ -62,13 +66,27 @@ export function initEvents() {
|
|||||||
|
|
||||||
// Placing a new gate
|
// Placing a new gate
|
||||||
if (state.placingGate) {
|
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++,
|
id: state.nextId++,
|
||||||
type: state.placingGate,
|
type: state.placingGate,
|
||||||
x: world.x - GATE_W / 2,
|
x: world.x - w / 2,
|
||||||
y: world.y - GATE_H / 2,
|
y: world.y - h / 2,
|
||||||
value: 0
|
value: 0
|
||||||
});
|
};
|
||||||
|
if (state.placingGate.startsWith('COMPONENT:')) {
|
||||||
|
newGate.component = state.customComponents[state.placingGate.substring(10)];
|
||||||
|
}
|
||||||
|
state.gates.push(newGate);
|
||||||
evaluateAll();
|
evaluateAll();
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
return;
|
return;
|
||||||
@@ -311,4 +329,33 @@ export function initEvents() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.addEventListener('mouseup', () => { state.resizingWave = false; });
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
61
js/gates.js
61
js/gates.js
@@ -1,13 +1,52 @@
|
|||||||
// Gate evaluation and port geometry
|
// 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 { state } from './state.js';
|
||||||
import { recordSample, setEvaluateAll } from './waveform.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) {
|
export function getInputPorts(gate) {
|
||||||
const count = gateInputCount(gate.type);
|
const count = gateInputCount(gate.type);
|
||||||
const ports = [];
|
const ports = [];
|
||||||
|
const isComponent = gate.type.startsWith('COMPONENT:');
|
||||||
|
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
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' });
|
ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' });
|
||||||
}
|
}
|
||||||
return ports;
|
return ports;
|
||||||
@@ -16,8 +55,13 @@ export function getInputPorts(gate) {
|
|||||||
export function getOutputPorts(gate) {
|
export function getOutputPorts(gate) {
|
||||||
const count = gateOutputCount(gate.type);
|
const count = gateOutputCount(gate.type);
|
||||||
const ports = [];
|
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++) {
|
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;
|
return ports;
|
||||||
}
|
}
|
||||||
@@ -40,6 +84,10 @@ export function evaluate(gate, visited = new Set()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = 0;
|
let result = 0;
|
||||||
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
|
const outputs = evaluateComponent(gate, inputs);
|
||||||
|
result = outputs[0] || 0;
|
||||||
|
} else {
|
||||||
switch (gate.type) {
|
switch (gate.type) {
|
||||||
case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break;
|
case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break;
|
||||||
case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break;
|
case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break;
|
||||||
@@ -49,6 +97,7 @@ export function evaluate(gate, visited = new Set()) {
|
|||||||
case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break;
|
case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break;
|
||||||
case 'OUTPUT': result = inputs[0] || 0; break;
|
case 'OUTPUT': result = inputs[0] || 0; break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
gate.value = result;
|
gate.value = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -65,7 +114,11 @@ export function evaluateAll(recordWave = false) {
|
|||||||
setEvaluateAll(evaluateAll);
|
setEvaluateAll(evaluateAll);
|
||||||
|
|
||||||
export function findGateAt(x, y) {
|
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) {
|
export function findPortAt(x, y) {
|
||||||
|
|||||||
101
js/renderer.js
101
js/renderer.js
@@ -1,7 +1,7 @@
|
|||||||
// Canvas rendering — gates, connections, grid
|
// 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 { 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 { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
||||||
|
|
||||||
let canvas, ctx;
|
let canvas, ctx;
|
||||||
@@ -19,7 +19,8 @@ export function resize() {
|
|||||||
const sidebarW = sidebarOpen ? 340 : 0;
|
const sidebarW = sidebarOpen ? 340 : 0;
|
||||||
canvas.width = window.innerWidth - sidebarW;
|
canvas.width = window.innerWidth - sidebarW;
|
||||||
const waveH = state.waveformVisible ? state.waveformHeight : 0;
|
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)
|
// Convert screen coords to world coords (accounting for pan/zoom)
|
||||||
@@ -31,6 +32,11 @@ export function screenToWorld(sx, sy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawGate(gate) {
|
function drawGate(gate) {
|
||||||
|
// Component gates have different rendering
|
||||||
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
|
return drawComponentGate(gate);
|
||||||
|
}
|
||||||
|
|
||||||
const color = GATE_COLORS[gate.type];
|
const color = GATE_COLORS[gate.type];
|
||||||
const isHovered = state.hoveredGate === gate;
|
const isHovered = state.hoveredGate === gate;
|
||||||
const isActive = gate.value === 1;
|
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) {
|
function drawConnection(conn) {
|
||||||
const fromGate = state.gates.find(g => g.id === conn.from);
|
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||||
const toGate = state.gates.find(g => g.id === conn.to);
|
const toGate = state.gates.find(g => g.id === conn.to);
|
||||||
@@ -181,12 +257,25 @@ function drawPlacingGhost() {
|
|||||||
if (!state.placingGate) return;
|
if (!state.placingGate) return;
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = 0.5;
|
||||||
const world = screenToWorld(state.mouseX, state.mouseY);
|
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 = {
|
const ghost = {
|
||||||
x: world.x - GATE_W / 2,
|
x: world.x - w / 2,
|
||||||
y: world.y - GATE_H / 2,
|
y: world.y - h / 2,
|
||||||
type: state.placingGate,
|
type: state.placingGate,
|
||||||
value: 0,
|
value: 0,
|
||||||
id: -1
|
id: -1,
|
||||||
|
component: state.customComponents?.[state.placingGate.substring(10)]
|
||||||
};
|
};
|
||||||
drawGate(ghost);
|
drawGate(ghost);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
|||||||
@@ -36,5 +36,10 @@ export const state = {
|
|||||||
simSpeed: 500, // ms per tick
|
simSpeed: 500, // ms per tick
|
||||||
|
|
||||||
// Puzzle/Components
|
// 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