Files
logic-gates/js/renderer.js
Jose Luis 817dab43df feat: port labels on component gates + persistent internal state
Show input/output labels next to ports on custom component chips,
and persist internal gate state between evaluations so latches and
flip-flops retain their values correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:15:37 +01:00

373 lines
12 KiB
JavaScript

// Canvas rendering — gates, connections, grid
import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
import { state } from './state.js';
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } 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() {
const sidebarOpen = document.body.classList.contains('puzzle-sidebar-open');
const sidebarW = sidebarOpen ? 340 : 0;
canvas.width = window.innerWidth - sidebarW;
const waveH = state.waveformVisible ? state.waveformHeight : 0;
const editorH = state.componentEditorActive ? 44 : 0;
canvas.height = window.innerHeight - 56 - editorH - waveH;
}
// Convert screen coords to world coords (accounting for pan/zoom)
export function screenToWorld(sx, sy) {
return {
x: (sx - state.camX) / state.zoom,
y: (sy - state.camY) / state.zoom
};
}
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;
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, 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';
// Show custom label above the gate if it has one
if (gate.label && isIOType) {
ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#888';
ctx.fillText(gate.label, gate.x + GATE_W / 2, gate.y - 8);
}
ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`;
ctx.fillStyle = isActive ? '#fff' : color;
ctx.fillText(
gate.label && isIOType ? gate.label : (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';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
});
}
function drawComponentGate(gate) {
const isHovered = state.hoveredGate === gate;
const isActive = gate.value === 1;
const w = getComponentWidth(gate);
const h = getComponentHeight(gate);
const color = '#9900ff';
if (isActive) {
ctx.shadowColor = color;
ctx.shadowBlur = 20 * state.zoom;
}
ctx.fillStyle = isActive ? color + '22' : '#14141e';
ctx.strokeStyle = isHovered ? '#fff' : color;
ctx.lineWidth = (isHovered ? 2.5 : 1.5);
ctx.beginPath();
ctx.roundRect(gate.x, gate.y, w, h, 8);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
// Component name label
ctx.fillStyle = isActive ? '#fff' : color;
ctx.font = `bold 12px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const componentName = gate.component?.name || 'Component';
ctx.fillText(componentName, gate.x + w / 2, gate.y + h / 2);
// Small ID label
ctx.font = '9px monospace';
ctx.fillStyle = '#444';
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
// Get port labels from the component definition
const comp = gate.component;
const inputLabels = [];
const outputLabels = [];
if (comp) {
const inputIds = comp.inputIds || [];
const outputIds = comp.outputIds || [];
for (const id of inputIds) {
const g = comp.gates.find(g => g.id === id);
inputLabels.push(g?.label || '');
}
for (const id of outputIds) {
const g = comp.gates.find(g => g.id === id);
outputLabels.push(g?.label || '');
}
}
// Input ports
getInputPorts(gate).forEach(p => {
const isPortHovered = state.hoveredPort &&
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'input';
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
const portActive = 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();
// Port label (inside the gate, to the right of the port)
const label = inputLabels[p.index];
if (label) {
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#aaa';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(label, p.x + PORT_R + 4, p.y);
}
});
// Output ports
getOutputPorts(gate).forEach(p => {
const isPortHovered = state.hoveredPort &&
state.hoveredPort.gate === gate &&
state.hoveredPort.index === p.index &&
state.hoveredPort.type === 'output';
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
ctx.stroke();
// Port label (inside the gate, to the left of the port)
const label = outputLabels[p.index];
if (label) {
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#aaa';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(label, p.x - PORT_R - 4, p.y);
}
});
}
function drawConnection(conn) {
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;
// Read correct output port value for multi-output gates (components)
const active = fromGate.outputValues
? (fromGate.outputValues[conn.fromPort] || 0) === 1
: fromGate.value === 1;
const midX = (fromPort.x + toPort.x) / 2;
ctx.beginPath();
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() {
const gridSize = 40;
ctx.strokeStyle = '#111118';
ctx.lineWidth = 1;
// Calculate visible grid range in world coords
const topLeft = screenToWorld(0, 0);
const bottomRight = screenToWorld(canvas.width, canvas.height);
const startX = Math.floor(topLeft.x / gridSize) * gridSize;
const startY = Math.floor(topLeft.y / gridSize) * gridSize;
for (let x = startX; x < bottomRight.x; x += gridSize) {
ctx.beginPath(); ctx.moveTo(x, topLeft.y); ctx.lineTo(x, bottomRight.y); ctx.stroke();
}
for (let y = startY; y < bottomRight.y; y += gridSize) {
ctx.beginPath(); ctx.moveTo(topLeft.x, y); ctx.lineTo(bottomRight.x, 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;
// Convert mouse to world coords for the wire endpoint
const world = screenToWorld(state.mouseX, state.mouseY);
const midX = (port.x + world.x) / 2;
ctx.beginPath();
ctx.moveTo(port.x, port.y);
ctx.bezierCurveTo(midX, port.y, midX, world.y, world.x, world.y);
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 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 - w / 2,
y: world.y - h / 2,
type: state.placingGate,
value: 0,
id: -1,
component: state.customComponents?.[state.placingGate.substring(10)]
};
drawGate(ghost);
ctx.globalAlpha = 1;
}
function drawZoomIndicator() {
const pct = Math.round(state.zoom * 100);
ctx.save();
ctx.resetTransform();
ctx.fillStyle = '#333';
ctx.font = '11px monospace';
ctx.textAlign = 'right';
ctx.fillText(`${pct}%`, canvas.width - 10, canvas.height - 10);
ctx.restore();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply camera transform
ctx.save();
ctx.translate(state.camX, state.camY);
ctx.scale(state.zoom, state.zoom);
drawGrid();
state.connections.forEach(drawConnection);
state.gates.forEach(drawGate);
drawConnectingWire();
drawPlacingGhost();
ctx.restore();
// HUD (drawn without transform)
drawZoomIndicator();
if (state.waveformVisible) {
drawWaveLabels();
drawWaveforms();
}
requestAnimationFrame(draw);
}