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>
454 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|