Files
project-math/src/components/workbench/modules/electronics/ElectronicsLab.tsx
Jose Luis Montañes 8d8a811ede feat: circuit builder, electronics lab, SPICE simulator, expanded skill tree
Workbenches:
- Circuit Builder: drag-and-drop logic gates, wire connections, truth table verification, fullscreen mode
- Electronics Lab: SPICE-like DC simulator with MNA solver, voltage sources, resistors, capacitors, LEDs, switches, NMOS/PMOS transistors, voltmeter, ammeter, play/pause simulation, fullscreen mode
- Explanation renderer: auto-detects ASCII truth tables and renders them as styled HTML

Skill tree:
- 65+ nodes across 19 groups spanning math → electronics → CPU → ASM → OS → networking → web
- Groups: Aritmética, Álgebra, Lógica, Electrónica, Circuitos Digitales, Secuenciales, Tu CPU, Verilog/HDL, Arquitectura Extendida, Sistemas Operativos, Programación en C, Redes, La Web, Señales, Síntesis Audio, Gráficos, Tu Consola
- Dependency highlighting: clicking a node dims all others and highlights the full path
- Group boxes with colored borders around related nodes
- Dependency chain audit: fixed illogical prerequisites throughout the tree

Content:
- 24 electronics challenges (basics, series/parallel, capacitors, diodes, transistors, op-amps, power supplies)
- 12 circuit builder challenges (logic gates, NAND universality, combinational circuits)
- Fixed all explanation spoilers: examples now use different numbers than the challenge questions
- Probe system now requires voltmeter/ammeter instruments instead of checking arbitrary node IDs

UX:
- Custom dark-themed scrollbars
- Fullscreen mode for circuit/electronics editors (portal-based, Esc to exit)
- SVG coordinate fix using getScreenCTM for accurate wire placement in fullscreen
- Meter reading labels positioned correctly regardless of component rotation
- Scratchpad defaults to closed, persists open/close state in localStorage
- Empty placeholder nodes show "Próximamente" instead of appearing completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 03:50:07 +01:00

454 lines
18 KiB
TypeScript

'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
ElectronicsContent,
ElectronicCircuitState,
ElectronicComponent,
ElectronicWire,
ElectronicComponentType,
getTerminals,
componentLabel,
} from '@/types/electronics';
import { simulateElectronics } from './simulateElectronics';
import { ComponentSVG, COMP_W, COMP_H, getTerminalPositions } from './ComponentSVG';
import { Trash2, RotateCw, Play, Pause, Maximize2, Minimize2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ElectronicsLabProps {
content: ElectronicsContent;
onCircuitChange: (state: ElectronicCircuitState) => void;
disabled?: boolean;
}
const CANVAS_W = 700;
const CANVAS_H = 500;
interface TermPos { id: string; x: number; y: number }
let nextCompId = 1;
export function ElectronicsLab({ content, onCircuitChange, disabled }: ElectronicsLabProps) {
const [components, setComponents] = useState<ElectronicComponent[]>(content.preplacedComponents ?? []);
const [wires, setWires] = useState<ElectronicWire[]>(content.preplacedWires ?? []);
const [placing, setPlacing] = useState<ElectronicComponentType | null>(null);
const [wiringFrom, setWiringFrom] = useState<TermPos | null>(null);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
const [dragging, setDragging] = useState<{ id: string; ox: number; oy: number } | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const didDrag = useRef(false);
const svgRef = useRef<SVGSVGElement>(null);
// Escape exits fullscreen
useEffect(() => {
if (!fullscreen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setFullscreen(false); }
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [fullscreen]);
const circuit: ElectronicCircuitState = useMemo(() => ({ components, wires }), [components, wires]);
const simResult = useMemo(() => {
if (!running) return null;
return simulateElectronics(circuit);
}, [circuit, running]);
const sync = useCallback((c: ElectronicComponent[], w: ElectronicWire[]) => {
onCircuitChange({ components: c, wires: w });
}, [onCircuitChange]);
const getSvgPoint = useCallback((e: React.MouseEvent) => {
const svg = svgRef.current;
if (!svg) return { x: 0, y: 0 };
const ctm = svg.getScreenCTM();
if (ctm) {
return {
x: (e.clientX - ctm.e) / ctm.a,
y: (e.clientY - ctm.f) / ctm.d,
};
}
// Fallback
const rect = svg.getBoundingClientRect();
return {
x: ((e.clientX - rect.left) / rect.width) * CANVAS_W,
y: ((e.clientY - rect.top) / rect.height) * CANVAS_H,
};
}, []);
// Get all terminal world positions
const allTerminals = useMemo(() => {
const map = new Map<string, { x: number; y: number }>();
for (const comp of components) {
const terminals = getTerminalPositions(comp.type, comp.x, comp.y, comp.rotation);
for (const [name, pos] of Object.entries(terminals)) {
map.set(`${comp.id}:${name}`, pos);
}
}
return map;
}, [components]);
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (disabled) return;
const pos = getSvgPoint(e);
if (placing) {
const newComp: ElectronicComponent = {
id: `comp-${nextCompId++}`,
type: placing,
x: pos.x - COMP_W / 2,
y: pos.y - COMP_H / 2,
rotation: 0,
value: placing === 'voltage-source' ? 5 : placing === 'resistor' ? 1000 : placing === 'capacitor' ? 100 : undefined,
};
const nc = [...components, newComp];
setComponents(nc);
sync(nc, wires);
setPlacing(null);
return;
}
// Don't deselect if we just finished dragging a component
if (!didDrag.current) {
setSelected(null);
}
didDrag.current = false;
setWiringFrom(null);
}, [disabled, placing, components, wires, getSvgPoint, sync]);
const handleTerminalDragStart = useCallback((termId: string, pos: { x: number; y: number }) => {
if (disabled) return;
setWiringFrom({ id: termId, ...pos });
}, [disabled]);
const handleTerminalDragEnd = useCallback((termId: string) => {
if (disabled || !wiringFrom) return;
if (termId === wiringFrom.id) { setWiringFrom(null); return; }
const existing = wires.find((w) =>
(w.from === wiringFrom.id && w.to === termId) ||
(w.from === termId && w.to === wiringFrom.id)
);
if (!existing) {
const newWire: ElectronicWire = { id: `ew-${Date.now()}`, from: wiringFrom.id, to: termId };
const nw = [...wires, newWire];
setWires(nw);
sync(components, nw);
}
setWiringFrom(null);
}, [disabled, wiringFrom, wires, components, sync]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const pos = getSvgPoint(e);
setMousePos(pos);
if (dragging) {
didDrag.current = true;
const nc = components.map((c) =>
c.id === dragging.id ? { ...c, x: pos.x - dragging.ox, y: pos.y - dragging.oy } : c
);
setComponents(nc);
sync(nc, wires);
}
}, [dragging, components, wires, getSvgPoint, sync]);
const handleMouseUp = useCallback(() => {
setDragging(null);
if (wiringFrom) setWiringFrom(null);
}, [wiringFrom]);
const handleCompMouseDown = useCallback((id: string, e: React.MouseEvent) => {
if (disabled) return;
// Mark as interacted so canvas click doesn't deselect
didDrag.current = true;
setSelected(id);
const comp = components.find((c) => c.id === id);
if (!comp) return;
const pos = getSvgPoint(e);
setDragging({ id, ox: pos.x - comp.x, oy: pos.y - comp.y });
}, [disabled, components, getSvgPoint]);
const deleteComp = useCallback((id: string) => {
if (disabled) return;
const nc = components.filter((c) => c.id !== id);
const nw = wires.filter((w) => !w.from.startsWith(id) && !w.to.startsWith(id));
setComponents(nc);
setWires(nw);
sync(nc, nw);
if (selected === id) setSelected(null);
}, [disabled, components, wires, selected, sync]);
const rotateComp = useCallback((id: string) => {
if (disabled) return;
const nc = components.map((c) =>
c.id === id ? { ...c, rotation: ((c.rotation + 90) % 360) as 0 | 90 | 180 | 270 } : c
);
setComponents(nc);
sync(nc, wires);
}, [disabled, components, wires, sync]);
const changeValue = useCallback((id: string, value: number) => {
const nc = components.map((c) => c.id === id ? { ...c, value } : c);
setComponents(nc);
sync(nc, wires);
}, [components, wires, sync]);
const clearAll = useCallback(() => {
const preplaced = content.preplacedComponents ?? [];
const preWires = content.preplacedWires ?? [];
setComponents(preplaced);
setWires(preWires);
setSelected(null);
setWiringFrom(null);
sync(preplaced, preWires);
}, [content, sync]);
function renderWire(from: { x: number; y: number }, to: { x: number; y: number }, color: string, key: string, dashed = false) {
const dx = Math.max(Math.abs(to.x - from.x) * 0.4, 20);
const d = `M${from.x},${from.y} C${from.x + dx},${from.y} ${to.x - dx},${to.y} ${to.x},${to.y}`;
return <path key={key} d={d} fill="none" stroke={color} strokeWidth="2" strokeDasharray={dashed ? '6 3' : undefined} pointerEvents="none" />;
}
// Color wire by voltage difference
function wireColor(wireObj: ElectronicWire): string {
if (!simResult?.success) return '#555';
const vFrom = simResult.nodeVoltages.get(wireObj.from) ?? 0;
const vTo = simResult.nodeVoltages.get(wireObj.to) ?? 0;
const avg = (vFrom + vTo) / 2;
if (avg > 3) return '#ef4444'; // high voltage = red
if (avg > 0.5) return '#f59e0b'; // medium = amber
return '#555'; // low/ground = gray
}
// Shared palette content
const paletteContent = (
<>
{content.availableComponents.map((type) => (
<button
key={type}
onClick={() => !disabled && setPlacing(placing === type ? null : type)}
className={`px-2 py-1.5 text-[11px] font-medium rounded border transition-colors text-left ${
placing === type
? 'bg-primary/20 border-primary text-primary'
: 'bg-muted border-border text-foreground hover:border-primary/50'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{componentLabel(type)}
</button>
))}
<div className="border-t border-border my-1" />
{selected && (
<div className="space-y-1">
<Button variant="ghost" size="sm" onClick={() => rotateComp(selected)} disabled={disabled} className="w-full text-xs justify-start">
<RotateCw className="w-3 h-3 mr-1" /> Rotar
</Button>
{components.find((c) => c.id === selected)?.type === 'resistor' && (
<div className="px-1">
<label className="text-[10px] text-muted-foreground">Ω</label>
<input type="number" className="w-full px-2 py-1 text-xs bg-muted border border-border rounded"
value={components.find((c) => c.id === selected)?.value ?? 1000}
onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 1000)}
onKeyDown={(e) => e.stopPropagation()} />
</div>
)}
{components.find((c) => c.id === selected)?.type === 'voltage-source' && (
<div className="px-1">
<label className="text-[10px] text-muted-foreground">Voltios</label>
<input type="number" className="w-full px-2 py-1 text-xs bg-muted border border-border rounded"
value={components.find((c) => c.id === selected)?.value ?? 5}
onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 5)}
onKeyDown={(e) => e.stopPropagation()} />
</div>
)}
{components.find((c) => c.id === selected)?.type === 'capacitor' && (
<div className="px-1">
<label className="text-[10px] text-muted-foreground">µF</label>
<input type="number" className="w-full px-2 py-1 text-xs bg-muted border border-border rounded"
value={components.find((c) => c.id === selected)?.value ?? 100}
onChange={(e) => changeValue(selected, parseFloat(e.target.value) || 100)}
onKeyDown={(e) => e.stopPropagation()} />
</div>
)}
<Button variant="ghost" size="sm" onClick={() => deleteComp(selected)} disabled={disabled}
className="w-full text-xs justify-start text-destructive hover:text-destructive">
<Trash2 className="w-3 h-3 mr-1" /> Eliminar
</Button>
</div>
)}
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={clearAll} disabled={disabled}
className="text-xs text-destructive hover:text-destructive">
<Trash2 className="w-3 h-3 mr-1" /> Limpiar
</Button>
</>
);
// Shared SVG canvas
const svgCanvas = (fs: boolean) => (
<svg
ref={svgRef}
viewBox={`0 0 ${CANVAS_W} ${CANVAS_H}`}
className={fs ? 'w-full h-full' : 'w-full'}
preserveAspectRatio={fs ? 'xMidYMid meet' : undefined}
style={{ minHeight: fs ? undefined : 350, cursor: placing ? 'crosshair' : wiringFrom ? 'crosshair' : 'default', userSelect: 'none' }}
onClick={handleCanvasClick}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<defs>
<pattern id="egrid" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="0.5" fill="#333" />
</pattern>
</defs>
<rect width={CANVAS_W} height={CANVAS_H} fill="url(#egrid)" />
{wires.map((wire) => {
const from = allTerminals.get(wire.from);
const to = allTerminals.get(wire.to);
if (!from || !to) return null;
return renderWire(from, to, wireColor(wire), wire.id);
})}
{wiringFrom && mousePos && renderWire(wiringFrom, mousePos, '#6366f1', 'wire-preview', true)}
{placing && mousePos && (
<rect x={mousePos.x - COMP_W / 2} y={mousePos.y - COMP_H / 2}
width={COMP_W} height={COMP_H}
fill="none" stroke="#6366f1" strokeWidth="2" strokeDasharray="4" rx="4" pointerEvents="none" />
)}
{components.map((comp) => (
<ComponentSVG key={comp.id} component={comp} selected={selected === comp.id}
voltage={simResult?.success ? simResult.nodeVoltages : undefined}
meterReading={simResult?.success ? simResult.meterReadings.get(comp.id) : undefined}
onMouseDown={handleCompMouseDown} onDelete={deleteComp}
onTerminalDragStart={handleTerminalDragStart} onTerminalDragEnd={handleTerminalDragEnd} />
))}
</svg>
);
// Shared probe table
const probeTable = content.probes.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden shrink-0">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-muted/50">
<th className="px-3 py-1.5 text-left text-muted-foreground">Medición</th>
<th className="px-3 py-1.5 text-center text-muted-foreground">Esperado</th>
<th className="px-3 py-1.5 text-center text-muted-foreground">Tu resultado</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{content.probes.map((probe, i) => {
// Find meter readings of the matching type
const readings: number[] = [];
if (simResult?.success) {
for (const [, reading] of simResult.meterReadings) {
if ((probe.type === 'voltmeter' && reading.unit === 'V') ||
(probe.type === 'ammeter' && reading.unit === 'mA')) {
readings.push(reading.value);
}
}
}
const match = readings.find((r) => Math.abs(r - probe.expected) <= probe.tolerance);
const hasReading = readings.length > 0;
const pass = match !== undefined;
const instrumentName = probe.type === 'voltmeter' ? 'Voltímetro' : 'Amperímetro';
return (
<tr key={i} className={`border-t border-border ${pass ? 'bg-green-500/5' : ''}`}>
<td className="px-3 py-1.5">
<span className="text-muted-foreground text-[10px]">{instrumentName}:</span>{' '}
{probe.label}
</td>
<td className="px-3 py-1.5 text-center font-bold">{probe.expected}{probe.unit}</td>
<td className="px-3 py-1.5 text-center">
{hasReading
? readings.map((r) => r.toFixed(1) + probe.unit).join(', ')
: <span className="text-muted-foreground">Coloca un {instrumentName.toLowerCase()}</span>
}
</td>
<td className="px-2 text-center">{hasReading && (pass ? '✓' : '✗')}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
// Play/pause + fullscreen toolbar
const toolbar = (fs: boolean) => (
<div className="flex items-center gap-2 shrink-0">
<Button variant={running ? 'default' : 'outline'} size="sm"
onClick={() => setRunning((r) => !r)}
className={running ? 'bg-green-600 hover:bg-green-700 text-white' : ''}>
{running ? <Pause className="w-3.5 h-3.5 mr-1.5" /> : <Play className="w-3.5 h-3.5 mr-1.5" />}
{running ? 'Pausar' : 'Simular'}
</Button>
<span className={`text-xs flex-1 ${running ? 'text-green-400' : 'text-muted-foreground'}`}>
{running ? '● Simulación activa' : '○ Pausada'}
</span>
<Button variant="ghost" size="sm" onClick={() => setFullscreen(!fs)} title={fs ? 'Salir (Esc)' : 'Pantalla completa'}>
{fs ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</Button>
</div>
);
// === FULLSCREEN PORTAL ===
const fullscreenOverlay = fullscreen && createPortal(
<div className="fixed inset-0 z-[500] bg-[#050510] flex animate-fade-in">
{/* Sidebar palette */}
<div className="w-32 shrink-0 flex flex-col gap-1.5 p-3 bg-card/30 border-r border-border overflow-y-auto">
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-muted-foreground font-medium">Componentes</p>
</div>
{paletteContent}
</div>
{/* Main area */}
<div className="flex-1 flex flex-col min-h-0">
<div className="px-3 py-2 border-b border-border bg-card/30">
{toolbar(true)}
</div>
<div className="flex-1 min-h-0 bg-[#0a0a0a]">
{svgCanvas(true)}
</div>
<div className="px-3 py-2 border-t border-border bg-card/30">
{probeTable}
{simResult && !simResult.success && simResult.error && components.length > 0 && (
<p className="text-xs text-amber-500 mt-1">{simResult.error}</p>
)}
</div>
</div>
</div>,
document.body
);
// === INLINE (normal) ===
return (
<div className="space-y-4">
<div className="flex gap-3">
<div className="flex flex-col gap-1.5 shrink-0 min-w-[90px]">
<p className="text-xs text-muted-foreground font-medium mb-1">Componentes</p>
{paletteContent}
</div>
<div className="flex-1 flex flex-col gap-2">
{toolbar(false)}
<div className="flex-1 border border-border rounded-lg overflow-hidden bg-[#0a0a0a]">
{svgCanvas(false)}
</div>
</div>
</div>
{probeTable}
{simResult && !simResult.success && simResult.error && components.length > 0 && (
<p className="text-xs text-amber-500">{simResult.error}</p>
)}
<div className="text-xs text-muted-foreground space-y-1">
<p>Arrastra desde un terminal () a otro para conectar. Clic derecho para eliminar.</p>
<p>Selecciona un componente para rotarlo o cambiar su valor.</p>
</div>
{fullscreenOverlay}
</div>
);
}