23 KiB
Plan: Visual Wiring System for Wokwi Clone (Incremental Implementation)
Vision
Implement a complete visual wiring system similar to Wokwi, with draggable connections, automatic colors based on signal type, electrical connection validation, and intelligent routing that avoids components.
User Requirements (from Q&A)
- Wire Colors: Automatic based on signal type (Red=VCC, Black=GND, Blue=Analog, etc.)
- Connection Validation: Strict validation - prevents electrically invalid connections
- Routing: Intelligent automatic with A* algorithm to avoid components
- Implementation: Incremental phases (Phase 1: Basic rendering → Phase 2: Editing → Phase 3: Validation)
Architecture Overview
Current State
- React + TypeScript + Vite + Zustand
- Canvas: Absolute positioning, 20px grid, coordinates in pixels
- Components: wokwi-elements web components (LED, Arduino, Resistor, etc.)
- NO wiring infrastructure currently exists
Key Discovery: Pin Information API
All wokwi-elements expose pinInfo: ElementPin[]:
interface ElementPin {
name: string; // e.g., 'A', 'C', 'GND.1', '13'
x: number; // X coordinate in millimeters (relative to element origin)
y: number; // Y coordinate in millimeters
signals: PinSignalInfo[]; // Signal types (power, analog, i2c, etc.)
}
Example - LED pins:
get pinInfo(): ElementPin[] {
return [
{ name: 'A', x: 25, y: 42, signals: [], description: 'Anode' },
{ name: 'C', x: 15, y: 42, signals: [], description: 'Cathode' },
];
}
Critical Challenge: Coordinate Systems
There are 3 coordinate systems:
- Element Space (mm):
pinInfouses millimeters relative to element origin - SVG Viewport: wokwi-elements use SVG with internal viewBox
- Canvas Space (pixels): Absolute positioning on canvas
Required conversion: 1mm = 3.7795275591 pixels (standard 96 DPI)
PHASE 1: Basic Wire Rendering (MVP)
Objetivos
- Dibujar cables estáticos en el canvas
- Colores automáticos según tipo de señal
- Paths SVG simples (forma de L)
- Crear cables manualmente via interfaz
Data Model
Archivo nuevo: frontend/src/types/wire.ts
export interface WireEndpoint {
componentId: string; // ID del componente (e.g., 'led-123', 'arduino-uno')
pinName: string; // Nombre del pin (e.g., 'A', 'GND.1', '13')
x: number; // Posición absoluta en canvas (pixels)
y: number;
}
export interface Wire {
id: string;
start: WireEndpoint;
end: WireEndpoint;
controlPoints: { x: number; y: number; id: string }[];
color: string; // Calculado automáticamente del tipo de señal
signalType: WireSignalType | null;
isValid: boolean;
}
export type WireSignalType =
| 'power-vcc' | 'power-gnd' | 'analog' | 'digital'
| 'pwm' | 'i2c' | 'spi' | 'usart';
Zustand Store Extension
Modificar: frontend/src/store/useSimulatorStore.ts
Agregar al SimulatorState:
interface SimulatorState {
// ... existing properties ...
wires: Wire[];
selectedWireId: string | null;
wireInProgress: WireInProgress | null;
addWire: (wire: Wire) => void;
removeWire: (wireId: string) => void;
updateWire: (wireId: string, updates: Partial<Wire>) => void;
setSelectedWire: (wireId: string | null) => void;
startWireCreation: (endpoint: WireEndpoint) => void;
finishWireCreation: (endpoint: WireEndpoint) => void;
cancelWireCreation: () => void;
updateWirePositions: (componentId: string) => void;
}
Pin Position Calculator (CRÍTICO)
Archivo nuevo: frontend/src/utils/pinPositionCalculator.ts
Esta es la pieza MÁS IMPORTANTE - convierte coordenadas de pines (mm) a coordenadas de canvas (pixels):
const MM_TO_PX = 3.7795275591; // Conversión estándar 96 DPI
export function calculatePinPosition(
componentId: string,
pinName: string,
componentX: number, // Posición del componente en canvas
componentY: number
): { x: number; y: number } | null {
const element = document.getElementById(componentId);
if (!element) return null;
const pinInfo = (element as any).pinInfo as ElementPin[];
const pin = pinInfo.find(p => p.name === pinName);
if (!pin) return null;
// Convertir mm a pixels y sumar posición del componente
return {
x: componentX + (pin.x * MM_TO_PX),
y: componentY + (pin.y * MM_TO_PX),
};
}
export function getAllPinPositions(
componentId: string,
componentX: number,
componentY: number
): Array<{ name: string; x: number; y: number; signals: PinSignalInfo[] }> {
// Retorna todos los pines con posiciones absolutas
}
export function findClosestPin(
componentId: string,
componentX: number,
componentY: number,
targetX: number,
targetY: number,
maxDistance: number = 20
): { name: string; x: number; y: number } | null {
// Encuentra el pin más cercano a una posición (para snapping)
}
Wire Colors
Archivo nuevo: frontend/src/utils/wireColors.ts
export const WIRE_COLORS = {
'power-vcc': '#ff0000', // Rojo
'power-gnd': '#000000', // Negro
'analog': '#4169e1', // Azul
'digital': '#00ff00', // Verde
'pwm': '#8b5cf6', // Morado
'i2c': '#ffd700', // Amarillo
'spi': '#ff8c00', // Naranja
'usart': '#00ced1', // Cyan
};
export function determineSignalType(signals: PinSignalInfo[]): WireSignalType | null {
// Prioridad: power > protocolos especializados > PWM > analog > digital
if (signals.find(s => s.type === 'power' && s.signal === 'VCC')) return 'power-vcc';
if (signals.find(s => s.type === 'power' && s.signal === 'GND')) return 'power-gnd';
if (signals.find(s => s.type === 'i2c')) return 'i2c';
// ... etc
return 'digital';
}
export function getWireColor(signalType: WireSignalType | null): string {
return signalType ? WIRE_COLORS[signalType] : '#00ff00';
}
Wire Path Generation (Phase 1: Simple)
Archivo nuevo: frontend/src/utils/wirePathGenerator.ts
export function generateWirePath(wire: Wire): string {
const { start, end, controlPoints } = wire;
if (controlPoints.length === 0) {
// Phase 1: Forma de L simple
return generateSimplePath(start.x, start.y, end.x, end.y);
}
// Phase 2 y 3 usarán controlPoints
}
function generateSimplePath(x1: number, y1: number, x2: number, y2: number): string {
const midX = x1 + (x2 - x1) / 2;
// L-shape: horizontal primero, luego vertical
return `M ${x1} ${y1} L ${midX} ${y1} L ${midX} ${y2} L ${x2} ${y2}`;
}
Wire Rendering Components
Archivo nuevo: frontend/src/components/simulator/WireLayer.tsx
export const WireLayer: React.FC = () => {
const { wires, wireInProgress, selectedWireId } = useSimulatorStore();
return (
<svg
className="wire-layer"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1, // Debajo de componentes
}}
>
{wires.map(wire => (
<WireRenderer
key={wire.id}
wire={wire}
isSelected={wire.id === selectedWireId}
/>
))}
{wireInProgress && (
<WireInProgressRenderer wireInProgress={wireInProgress} />
)}
</svg>
);
};
Archivo nuevo: frontend/src/components/simulator/WireRenderer.tsx
export const WireRenderer: React.FC<{ wire: Wire; isSelected: boolean }> = ({ wire, isSelected }) => {
const { setSelectedWire } = useSimulatorStore();
const path = useMemo(() => generateWirePath(wire), [wire]);
return (
<g className="wire-group">
{/* Path invisible para click fácil */}
<path
d={path}
stroke="transparent"
strokeWidth="10"
fill="none"
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onClick={() => setSelectedWire(wire.id)}
/>
{/* Path visible */}
<path
d={path}
stroke={wire.isValid ? wire.color : '#ff4444'}
strokeWidth="2"
fill="none"
strokeDasharray={wire.isValid ? undefined : '5,5'}
/>
{/* Endpoints */}
<circle cx={wire.start.x} cy={wire.start.y} r="3" fill={wire.color} />
<circle cx={wire.end.x} cy={wire.end.y} r="3" fill={wire.color} />
</g>
);
};
Integration with Canvas
Modificar: frontend/src/components/simulator/SimulatorCanvas.tsx
// Agregar import
import { WireLayer } from './WireLayer';
// En el JSX, agregar wire layer DEBAJO de componentes:
<div className="canvas-content" ...>
{/* Wire layer - z-index: 1 */}
<WireLayer />
{/* Arduino Uno - z-index: 2 */}
<ArduinoUno ... />
{/* Components */}
<div className="components-area">
{components.map(renderComponent)}
</div>
</div>
Phase 1 Implementation Steps
- ✅ Crear
types/wire.ts - ✅ Extender Zustand store con wire state
- ✅ Implementar
pinPositionCalculator.ts - ✅ Implementar
wireColors.ts - ✅ Implementar
wirePathGenerator.ts(simple) - ✅ Crear
WireLayer.tsx - ✅ Crear
WireRenderer.tsx - ✅ Integrar
WireLayerenSimulatorCanvas.tsx - ✅ Agregar cables de prueba al store
- ✅ Verificar rendering y colores
PHASE 2: Wire Editing & Interaction
Objetivos
- Crear cables haciendo click en pines
- Editar cables con puntos de control violetas
- Arrastrar puntos de control (horizontal ↔ vertical)
- Seleccionar y eliminar cables
Wire Creation System
Archivo nuevo: frontend/src/components/simulator/PinOverlay.tsx
Muestra marcadores clickeables en todos los pines cuando se está creando un cable:
export const PinOverlay: React.FC = () => {
const { components, wireInProgress } = useSimulatorStore();
if (!wireInProgress) return null;
return (
<div className="pin-overlay" style={{ zIndex: 10 }}>
{components.map(component => {
const pins = getAllPinPositions(component.id, component.x, component.y);
return pins.map(pin => (
<div
key={`${component.id}-${pin.name}`}
className="pin-marker"
style={{
position: 'absolute',
left: pin.x - 6,
top: pin.y - 6,
width: 12,
height: 12,
borderRadius: '50%',
border: '2px solid #00ff00',
backgroundColor: 'rgba(0, 255, 0, 0.3)',
pointerEvents: 'all',
cursor: 'crosshair',
}}
onClick={(e) => handlePinClick(component.id, pin.name, e)}
/>
));
})}
</div>
);
};
Archivo nuevo: frontend/src/components/simulator/WireCreationHandler.tsx
Hook para manejar la creación de cables:
export const useWireCreation = () => {
const {
components,
wireInProgress,
startWireCreation,
finishWireCreation,
cancelWireCreation,
addWire,
} = useSimulatorStore();
const handlePinClick = useCallback((
componentId: string,
pinName: string,
event: React.MouseEvent
) => {
event.stopPropagation();
const component = components.find(c => c.id === componentId);
if (!component) return;
const pinPosition = calculatePinPosition(
componentId, pinName, component.x, component.y
);
if (!wireInProgress) {
// Iniciar nuevo cable
startWireCreation({ componentId, pinName, ...pinPosition });
} else {
// Completar cable
const element = document.getElementById(componentId) as any;
const pinInfo = element?.pinInfo?.find((p: any) => p.name === pinName);
const signalType = determineSignalType(pinInfo?.signals || []);
const newWire = {
id: uuidv4(),
start: wireInProgress.startEndpoint,
end: { componentId, pinName, ...pinPosition },
controlPoints: [],
color: getWireColor(signalType),
signalType,
isValid: true, // Phase 3 agregará validación
};
addWire(newWire);
cancelWireCreation();
}
}, [wireInProgress, components]);
// Mouse move para mostrar preview
const handleMouseMove = useCallback((event: MouseEvent) => {
if (!wireInProgress) return;
const canvas = document.querySelector('.canvas-content');
const rect = canvas.getBoundingClientRect();
updateWireInProgress(
event.clientX - rect.left,
event.clientY - rect.top
);
}, [wireInProgress]);
// ESC para cancelar
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape' && wireInProgress) {
cancelWireCreation();
}
}, [wireInProgress]);
return { handlePinClick };
};
Control Points
Archivo nuevo: frontend/src/components/simulator/ControlPoint.tsx
export const ControlPoint: React.FC<{
x: number;
y: number;
onDrag: (newX: number, newY: number) => void;
}> = ({ x, y, onDrag }) => {
const [isDragging, setIsDragging] = useState(false);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging) return;
const canvas = document.querySelector('.canvas-content');
const rect = canvas.getBoundingClientRect();
const newX = e.clientX - rect.left;
const newY = e.clientY - rect.top;
// Snap to grid (20px)
const snappedX = Math.round(newX / 20) * 20;
const snappedY = Math.round(newY / 20) * 20;
onDrag(snappedX, snappedY);
}, [isDragging, onDrag]);
return (
<circle
cx={x}
cy={y}
r="6"
fill="#8b5cf6" // Morado como en Wokwi
stroke="white"
strokeWidth="2"
style={{ cursor: 'move', pointerEvents: 'all' }}
onMouseDown={() => setIsDragging(true)}
/>
);
};
Multi-segment Path Generation
Actualizar wirePathGenerator.ts:
function generateMultiSegmentPath(
start: { x: number; y: number },
controlPoints: WireControlPoint[],
end: { x: number; y: number }
): string {
let path = `M ${start.x} ${start.y}`;
// Agregar control points con restricción ortogonal
for (let i = 0; i < controlPoints.length; i++) {
const cp = controlPoints[i];
const prev = i === 0 ? start : controlPoints[i - 1];
// Forzar segmentos horizontales o verticales
if (Math.abs(cp.x - prev.x) > Math.abs(cp.y - prev.y)) {
path += ` L ${cp.x} ${prev.y} L ${cp.x} ${cp.y}`;
} else {
path += ` L ${prev.x} ${cp.y} L ${cp.x} ${cp.y}`;
}
}
// Conectar al endpoint
const lastPoint = controlPoints[controlPoints.length - 1];
if (Math.abs(end.x - lastPoint.x) > Math.abs(end.y - lastPoint.y)) {
path += ` L ${end.x} ${lastPoint.y} L ${end.x} ${end.y}`;
} else {
path += ` L ${lastPoint.x} ${end.y} L ${end.x} ${end.y}`;
}
return path;
}
Phase 2 Implementation Steps
- ✅ Implementar
useWireCreationhook - ✅ Crear
PinOverlay.tsx - ✅ Crear
WireInProgressRenderer.tsx - ✅ Crear
ControlPoint.tsx - ✅ Actualizar
WireRenderer.tsxpara mostrar control points - ✅ Implementar selección de cables
- ✅ Agregar keyboard handlers (ESC, Delete)
- ✅ Implementar drag de control points con grid snapping
- ✅ Actualizar
generateMultiSegmentPath() - ✅ Agregar botón para insertar control points en el medio
PHASE 3: Smart Routing & Validation
Objetivos
- A* pathfinding para evitar componentes
- Validación estricta de conexiones eléctricas
- Feedback visual para conexiones inválidas
- Auto-rerouting cuando componentes se mueven
Connection Validation
Archivo nuevo: frontend/src/utils/connectionValidator.ts
export interface ValidationResult {
isValid: boolean;
error?: string;
warning?: string;
}
export function validateConnection(
startSignals: PinSignalInfo[],
endSignals: PinSignalInfo[]
): ValidationResult {
const startType = determineSignalType(startSignals);
const endType = determineSignalType(endSignals);
// Regla 1: No conectar VCC a VCC
if (startType === 'power-vcc' && endType === 'power-vcc') {
return { isValid: false, error: 'Cannot connect VCC to VCC directly' };
}
// Regla 2: No conectar GND a GND
if (startType === 'power-gnd' && endType === 'power-gnd') {
return { isValid: false, error: 'Cannot connect GND to GND directly' };
}
// Regla 3: No hacer cortocircuito VCC-GND
if (startType === 'power-vcc' && endType === 'power-gnd') {
return { isValid: false, error: 'Cannot short VCC to GND' };
}
// Regla 4: Compatibilidad de señales
if (!areSignalsCompatible(startType, endType)) {
return {
isValid: false,
error: `Incompatible signal types: ${startType} and ${endType}`,
};
}
// Regla 5: Validación de buses (I2C, SPI)
const busError = validateBusProtocols(startSignals, endSignals);
if (busError) {
return { isValid: false, error: busError };
}
return { isValid: true };
}
function areSignalsCompatible(
type1: WireSignalType,
type2: WireSignalType
): boolean {
// Power puede conectar a digital/analog pero no a otro power
// Digital es universal
// PWM compatible con digital y analog
// Protocolos específicos deben coincidir
}
A* Pathfinding
Archivo nuevo: frontend/src/utils/pathfinding.ts
const GRID_SIZE = 20; // Coincidir con grid del canvas
export function findWirePath(
startX: number,
startY: number,
endX: number,
endY: number,
components: Component[],
excludeComponents: string[] = []
): { x: number; y: number }[] {
// Snap to grid
const gridStartX = Math.round(startX / GRID_SIZE);
const gridStartY = Math.round(startY / GRID_SIZE);
const gridEndX = Math.round(endX / GRID_SIZE);
const gridEndY = Math.round(endY / GRID_SIZE);
// Build obstacle map
const obstacles = buildObstacleMap(components, excludeComponents);
// Run A*
const path = aStarSearch(
{ x: gridStartX, y: gridStartY },
{ x: gridEndX, y: gridEndY },
obstacles
);
// Convert back to pixels
return path.map(p => ({
x: p.x * GRID_SIZE,
y: p.y * GRID_SIZE,
}));
}
function aStarSearch(
start: { x: number; y: number },
end: { x: number; y: number },
obstacles: Set<string>
): { x: number; y: number }[] {
// Algoritmo A* estándar
// Heurística: Manhattan distance
// Vecinos: 4-directional (arriba, abajo, izq, der)
// Retorna path simplificado (sin puntos colineales)
}
Archivo nuevo: frontend/src/utils/componentBounds.ts
export function getComponentBoundingBox(componentId: string): BoundingBox | null {
const element = document.getElementById(componentId);
if (!element) return null;
const rect = element.getBoundingClientRect();
const canvas = element.closest('.canvas-content');
const canvasRect = canvas.getBoundingClientRect();
return {
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
width: rect.width,
height: rect.height,
};
}
Auto-rerouting
Actualizar store:
updateComponent: (id, updates) => {
set((state) => ({
components: state.components.map(c =>
c.id === id ? { ...c, ...updates } : c
),
}));
// Re-calcular posiciones de cables conectados
get().updateWirePositions(id);
},
updateWirePositions: (componentId: string) => {
set((state) => {
const component = state.components.find(c => c.id === componentId);
if (!component) return state;
const updatedWires = state.wires.map(wire => {
let updated = { ...wire };
// Actualizar start endpoint
if (wire.start.componentId === componentId) {
const pos = calculatePinPosition(
componentId, wire.start.pinName, component.x, component.y
);
if (pos) updated.start = { ...wire.start, ...pos };
}
// Actualizar end endpoint
if (wire.end.componentId === componentId) {
const pos = calculatePinPosition(
componentId, wire.end.pinName, component.x, component.y
);
if (pos) updated.end = { ...wire.end, ...pos };
}
// OPCIONAL: Re-calcular path con A* si hay componentes en el camino
return updated;
});
return { wires: updatedWires };
});
},
Phase 3 Implementation Steps
- ✅ Implementar
connectionValidator.ts - ✅ Integrar validación en wire creation
- ✅ Mostrar errores de validación (línea roja punteada + tooltip)
- ✅ Implementar
componentBounds.ts - ✅ Implementar
pathfinding.tscon A* - ✅ Integrar A* en wire creation (hacer toggleable)
- ✅ Agregar botón "Re-route" para cables existentes
- ✅ Implementar auto-rerouting cuando componentes se mueven
- ✅ Agregar tooltip de validación en hover
- ✅ Panel de settings para toggle smart routing
Critical Files Summary
Archivos Nuevos (17 archivos)
Tipos & Utilidades:
frontend/src/types/wire.ts- Data structuresfrontend/src/utils/pinPositionCalculator.ts- CRÍTICO - Conversión coordenadasfrontend/src/utils/componentBounds.ts- Bounding boxesfrontend/src/utils/wirePathGenerator.ts- Generación SVG pathsfrontend/src/utils/wireColors.ts- Mapeo de coloresfrontend/src/utils/connectionValidator.ts- Validación eléctricafrontend/src/utils/pathfinding.ts- A* algorithm
Componentes:
8. frontend/src/components/simulator/WireLayer.tsx - Contenedor SVG
9. frontend/src/components/simulator/WireRenderer.tsx - CRÍTICO - Render individual
10. frontend/src/components/simulator/WireInProgressRenderer.tsx - Preview durante creación
11. frontend/src/components/simulator/ControlPoint.tsx - Puntos violetas
12. frontend/src/components/simulator/PinOverlay.tsx - Marcadores de pines
13. frontend/src/components/simulator/WireCreationHandler.tsx - Hook de creación
Archivos a Modificar (2 archivos)
frontend/src/store/useSimulatorStore.ts- CRÍTICO - Wire state managementfrontend/src/components/simulator/SimulatorCanvas.tsx- Integración WireLayer
CSS
frontend/src/components/simulator/SimulatorCanvas.css- Wire styles
Performance Optimizations
- React.memo en WireRenderer con custom comparator
- useMemo para path generation
- useCallback para event handlers
- Debounce (16ms) para control point updates
- Virtual rendering si hay >100 cables
Edge Cases & Error Handling
- Component deletion: Eliminar cables conectados
- Invalid pin references: Marcar cable como inválido
- Overlapping wires: Offset visual
- Moving components: Auto-update wire positions
- Undo/Redo: Guardar historial de cambios
Testing Strategy
Phase 1:
- Unit tests para
calculatePinPosition() - Unit tests para
determineSignalType() - Unit tests para
generateSimplePath()
Phase 2:
- Integration tests para wire creation flow
- Test control point dragging
- Test keyboard shortcuts
Phase 3:
- Unit tests para
validateConnection() - Unit tests para A* pathfinding
- Performance tests con 50+ cables
Success Criteria
Phase 1 Done When:
- ✅ Cables se renderizan con colores correctos
- ✅ Posiciones actualizadas cuando componentes se mueven
- ✅ Puede crear cables manualmente via código
Phase 2 Done When:
- ✅ Click pin → drag → click pin crea cable
- ✅ Puntos de control violetas aparecen al seleccionar
- ✅ Drag puntos reshapes cable
- ✅ Delete elimina cable seleccionado
Phase 3 Done When:
- ✅ Conexiones inválidas son bloqueadas
- ✅ A* encuentra paths que evitan componentes
- ✅ Mover componente re-route cables automáticamente
- ✅ Tooltips muestran errores de validación