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>
This commit is contained in:
453
src/components/workbench/modules/electronics/ElectronicsLab.tsx
Normal file
453
src/components/workbench/modules/electronics/ElectronicsLab.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user