Files
logic-gates/js/components.js
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

346 lines
12 KiB
JavaScript

// Custom components system — save and reuse circuits as components
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
*/
export function saveComponentFromCircuit(name) {
// Validate inputs exist
const inputGates = state.gates.filter(g => g.type === 'INPUT');
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))
};
// Store in state
if (!state.customComponents) {
state.customComponents = {};
}
state.customComponents[component.id] = component;
console.log(`[component] saved "${name}" (${component.inputCount} in, ${component.outputCount} out)`,
`inputIds=${inputIds}`, `outputIds=${outputIds}`);
return { success: true, component };
}
/**
* 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) {
console.warn('[component] evaluateComponent called without component data', gate);
return [0];
}
const comp = gate.component;
// 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
// 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];
}
}
// 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;
}
/**
* Get input count for a gate type
*/
function getGateInputCount(type) {
if (type === 'CLOCK' || type === 'INPUT') return 0;
if (type === 'NOT' || type === 'OUTPUT') return 1;
return 2;
}
/**
* Sanitize component name for use as ID
*/
function sanitizeComponentName(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
}
/**
* Get all custom components
*/
export function getAllComponents() {
return state.customComponents || {};
}
/**
* Delete a component
*/
export function deleteComponent(componentId) {
if (state.customComponents) {
delete state.customComponents[componentId];
return { success: true };
}
return { success: false, error: 'Component not found' };
}
/**
* Export component data as JSON
*/
export function exportComponent(componentId) {
if (!state.customComponents || !state.customComponents[componentId]) {
return { success: false, error: 'Component not found' };
}
return { success: true, data: state.customComponents[componentId] };
}
/**
* Import component from JSON
*/
export function importComponent(data) {
if (!data.id || !data.gates || !data.connections) {
return { success: false, error: 'Invalid component data' };
}
if (!state.customComponents) {
state.customComponents = {};
}
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);
// If editing an existing component, update all placed instances in the main circuit
if (editingId && result.success && state.savedMainCircuit) {
const updatedComp = state.customComponents[result.component.id];
if (updatedComp) {
for (const gate of state.savedMainCircuit.gates) {
if (gate.component && gate.component.id === editingId) {
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);
});
}