// 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'; import { isBusInternalConnection, getBusPairs } from './bus.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 drawSelectionHighlight(gate) { if (!state.selectedGates.includes(gate.id)) return; const isDynamic = gate.type.startsWith('COMPONENT:') || gate.type.startsWith('BUS:'); const w = isDynamic ? getComponentWidth(gate) : GATE_W; const h = isDynamic ? getComponentHeight(gate) : GATE_H; const pad = 4; ctx.strokeStyle = '#44ddff'; ctx.lineWidth = 2; ctx.setLineDash([5, 3]); ctx.beginPath(); ctx.roundRect(gate.x - pad, gate.y - pad, w + pad * 2, h + pad * 2, 10); ctx.stroke(); ctx.setLineDash([]); } function drawGate(gate) { // Special gate types have different rendering if (gate.type.startsWith('BUS:')) { drawBusGate(gate); drawSelectionHighlight(gate); return; } if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; } 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(); }); drawSelectionHighlight(gate); } function drawBusGate(gate) { const isHovered = state.hoveredGate === gate; const w = getComponentWidth(gate); // 30 const h = getComponentHeight(gate); const color = '#44ddff'; const n = parseInt(gate.type.substring(4)) || 1; const hasActive = gate.outputValues?.some(v => v === 1); if (hasActive) { ctx.shadowColor = color; ctx.shadowBlur = 12 * state.zoom; } // Main bus bar ctx.fillStyle = hasActive ? 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, 4); ctx.fill(); ctx.stroke(); ctx.shadowBlur = 0; // Thick center line (bus bar visual) ctx.strokeStyle = isHovered ? '#fff' : color; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(gate.x + w / 2, gate.y + 6); ctx.lineTo(gate.x + w / 2, gate.y + h - 6); ctx.stroke(); // Bus size label + role indicator ctx.font = 'bold 9px monospace'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const roleIcon = gate.busRole === 'in' ? '▶' : gate.busRole === 'out' ? '◀' : ''; ctx.fillText(`${roleIcon} ${n}`, gate.x + w / 2, gate.y + h + 10); // Input ports (left) 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 - 1, 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 (right) 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) : 0; ctx.beginPath(); ctx.arc(p.x, p.y, PORT_R - 1, 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 drawBusCables() { const pairs = getBusPairs(); for (const { inGate, outGate } of pairs) { const inW = getComponentWidth(inGate); const inH = getComponentHeight(inGate); const outH = getComponentHeight(outGate); // Cable runs from right edge of inGate to left edge of outGate const x1 = inGate.x + inW; const y1 = inGate.y + inH / 2; const x2 = outGate.x; const y2 = outGate.y + outH / 2; // Check if any channel is active const hasActive = inGate.outputValues?.some(v => v === 1); const n = parseInt(inGate.type.substring(4)) || 1; // Outer thick cable (bus background) const cableWidth = Math.max(6, n * 2.5); ctx.beginPath(); ctx.moveTo(x1, y1); const midX = (x1 + x2) / 2; ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2); ctx.strokeStyle = hasActive ? '#44ddff33' : '#44ddff11'; ctx.lineWidth = cableWidth + 4; ctx.stroke(); // Inner cable ctx.beginPath(); ctx.moveTo(x1, y1); ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2); ctx.strokeStyle = hasActive ? '#44ddff' : '#44ddff66'; ctx.lineWidth = cableWidth; ctx.stroke(); // Channel count label at midpoint const labelX = (x1 + x2) / 2; const labelY = (y1 + y2) / 2 - cableWidth / 2 - 6; ctx.font = 'bold 10px monospace'; ctx.fillStyle = '#44ddff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(`/${n}`, labelX, labelY); // Draw small diagonal slash across cable (bus notation) const slashX = labelX; const slashY = (y1 + y2) / 2; ctx.beginPath(); ctx.moveTo(slashX - 6, slashY + 6); ctx.lineTo(slashX + 6, slashY - 6); ctx.strokeStyle = '#44ddff'; 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) { // Skip internal bus connections (rendered as bus cable instead) if (isBusInternalConnection(conn)) return; 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 drawBusCutLine() { if (!state.busCutting) return; const cut = state.busCutting; // Dashed cyan line showing the cut ctx.beginPath(); ctx.moveTo(cut.startX, cut.startY); ctx.lineTo(cut.endX, cut.endY); ctx.strokeStyle = '#44ddff'; ctx.lineWidth = 2.5; ctx.setLineDash([8, 5]); ctx.stroke(); ctx.setLineDash([]); // Small circles at endpoints ctx.beginPath(); ctx.arc(cut.startX, cut.startY, 4, 0, Math.PI * 2); ctx.fillStyle = '#44ddff'; ctx.fill(); ctx.beginPath(); ctx.arc(cut.endX, cut.endY, 4, 0, Math.PI * 2); ctx.fill(); // Highlight intersecting wires // Import is circular so we compute inline: sample bezier + test intersection const hits = countCutIntersections(cut); if (hits > 0) { ctx.save(); ctx.resetTransform(); ctx.fillStyle = '#44ddff'; ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`✂ ${hits} wire${hits > 1 ? 's' : ''}`, 10, canvas.height - 10); ctx.restore(); } } /** * Quick inline intersection count for preview (avoids circular import from bus.js) */ function countCutIntersections(cut) { let count = 0; for (const conn of state.connections) { const fromGate = state.gates.find(g => g.id === conn.from); const toGate = state.gates.find(g => g.id === conn.to); if (!fromGate || !toGate) continue; const fp = getOutputPorts(fromGate)[conn.fromPort]; const tp = getInputPorts(toGate)[conn.toPort]; if (!fp || !tp) continue; const midX = (fp.x + tp.x) / 2; // Sample bezier at 16 points for (let i = 0; i < 16; i++) { const t1 = i / 16, t2 = (i + 1) / 16; const bx1 = bezAt(fp.x, midX, midX, tp.x, t1); const by1 = bezAt(fp.y, fp.y, tp.y, tp.y, t1); const bx2 = bezAt(fp.x, midX, midX, tp.x, t2); const by2 = bezAt(fp.y, fp.y, tp.y, tp.y, t2); if (segsHit(cut.startX, cut.startY, cut.endX, cut.endY, bx1, by1, bx2, by2)) { count++; break; } } } return count; } function bezAt(p0, p1, p2, p3, t) { const mt = 1 - t; return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3; } function segsHit(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) { const d = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1); if (Math.abs(d) < 1e-10) return false; const t = ((bx1 - ax1) * (by2 - by1) - (by1 - ay1) * (bx2 - bx1)) / d; const u = ((bx1 - ax1) * (ay2 - ay1) - (by1 - ay1) * (ax2 - ax1)) / d; return t >= 0 && t <= 1 && u >= 0 && u <= 1; } function drawSelectionBox() { if (!state.selectionBox) return; const box = state.selectionBox; const x = Math.min(box.startX, box.endX); const y = Math.min(box.startY, box.endY); const w = Math.abs(box.endX - box.startX); const h = Math.abs(box.endY - box.startY); ctx.fillStyle = '#44ddff0a'; ctx.fillRect(x, y, w, h); ctx.strokeStyle = '#44ddff88'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 3]); ctx.strokeRect(x, y, w, h); 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); drawBusCables(); state.gates.forEach(drawGate); drawConnectingWire(); drawBusCutLine(); drawSelectionBox(); drawPlacingGhost(); ctx.restore(); // HUD (drawn without transform) drawZoomIndicator(); if (state.waveformVisible) { drawWaveLabels(); drawWaveforms(); } requestAnimationFrame(draw); }