feat: add react-router-dom for routing and enhance wire rendering with automatic offsets

pull/10/head
David Montero Crespo 2026-03-04 18:03:54 -03:00
parent 1269550e8a
commit c2f07665b4
9 changed files with 390 additions and 22 deletions

View File

@ -14,6 +14,7 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@ -2127,6 +2128,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3296,6 +3310,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -3322,6 +3374,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@ -17,6 +17,7 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {

View File

@ -48,6 +48,11 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
const mountedRef = useRef(false); const mountedRef = useRef(false);
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent); const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
const running = useSimulatorStore((s) => s.running);
// Check if component is interactive (has simulation logic with attachEvents)
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
const isInteractive = logic?.attachEvents !== undefined;
/** /**
* Sync React properties to Web Component * Sync React properties to Web Component
@ -179,11 +184,8 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]); // Fallback if id is like led-1 const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]); // Fallback if id is like led-1
console.log(`[DynamicComponent] Component ${id} (${metadata.id}): Logic found =`, !!logic);
let cleanupSimulationEvents: (() => void) | undefined; let cleanupSimulationEvents: (() => void) | undefined;
if (logic && logic.attachEvents) { if (logic && logic.attachEvents) {
console.log(`[DynamicComponent] Attaching events for ${id} (${metadata.id})`);
// We need AVRSimulator instance. We can grab it from store. // We need AVRSimulator instance. We can grab it from store.
const simulator = useSimulatorStore.getState().simulator; const simulator = useSimulatorStore.getState().simulator;
if (simulator) { if (simulator) {
@ -225,7 +227,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
position: 'absolute', position: 'absolute',
left: `${x}px`, left: `${x}px`,
top: `${y}px`, top: `${y}px`,
cursor: 'move', cursor: running && isInteractive ? 'pointer' : 'move',
border: isSelected ? '2px dashed #007acc' : '2px solid transparent', border: isSelected ? '2px dashed #007acc' : '2px solid transparent',
borderRadius: '4px', borderRadius: '4px',
padding: '4px', padding: '4px',

View File

@ -303,6 +303,20 @@ export const SimulatorCanvas = () => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [wireInProgress, cancelWireCreation]); }, [wireInProgress, cancelWireCreation]);
// Recalculate wire positions when components change (e.g., when loading an example)
useEffect(() => {
// Wait for components to render and pinInfo to be available
// Use multiple retries to ensure pinInfo is ready
const timers: ReturnType<typeof setTimeout>[] = [];
// Try at 100ms, 300ms, and 500ms to ensure all components have rendered
timers.push(setTimeout(() => recalculateAllWirePositions(), 100));
timers.push(setTimeout(() => recalculateAllWirePositions(), 300));
timers.push(setTimeout(() => recalculateAllWirePositions(), 500));
return () => timers.forEach(t => clearTimeout(t));
}, [components, recalculateAllWirePositions]);
// Render component using dynamic renderer // Render component using dynamic renderer
const renderComponent = (component: any) => { const renderComponent = (component: any) => {
const metadata = registry.getById(component.metadataId); const metadata = registry.getById(component.metadataId);
@ -325,21 +339,29 @@ export const SimulatorCanvas = () => {
y={component.y} y={component.y}
isSelected={isSelected} isSelected={isSelected}
onMouseDown={(e) => { onMouseDown={(e) => {
handleComponentMouseDown(component.id, e); // Only handle UI events when simulation is NOT running
if (!running) {
handleComponentMouseDown(component.id, e);
}
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
handleComponentDoubleClick(component.id, e); // Only handle UI events when simulation is NOT running
if (!running) {
handleComponentDoubleClick(component.id, e);
}
}} }}
/> />
{/* Pin overlay for wire creation */} {/* Pin overlay for wire creation - hide when running */}
<PinOverlay {!running && (
componentId={component.id} <PinOverlay
componentX={component.x} componentId={component.id}
componentY={component.y} componentX={component.x}
onPinClick={handlePinClick} componentY={component.y}
showPins={showPinsForComponent} onPinClick={handlePinClick}
/> showPins={showPinsForComponent}
/>
)}
</React.Fragment> </React.Fragment>
); );
}; };

View File

@ -3,16 +3,36 @@
* *
* SVG layer that renders all wires below components. * SVG layer that renders all wires below components.
* Positioned absolutely with full canvas coverage. * Positioned absolutely with full canvas coverage.
*
* Features:
* - Automatic offset calculation for overlapping wires
* - Visual separation of parallel wires
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { useSimulatorStore } from '../../store/useSimulatorStore'; import { useSimulatorStore } from '../../store/useSimulatorStore';
import { WireRenderer } from './WireRenderer'; import { WireRenderer } from './WireRenderer';
import { WireInProgressRenderer } from './WireInProgressRenderer'; import { WireInProgressRenderer } from './WireInProgressRenderer';
import { calculateWireOffsets, applyOffsetToWire } from '../../utils/wireOffsetCalculator';
export const WireLayer: React.FC = () => { export const WireLayer: React.FC = () => {
const { wires, wireInProgress, selectedWireId } = useSimulatorStore(); const { wires, wireInProgress, selectedWireId } = useSimulatorStore();
// Calculate automatic offsets for overlapping wires
const wireOffsets = useMemo(() => {
return calculateWireOffsets(wires);
}, [wires]);
// Apply offsets to wires for rendering
// Priority: manual offset > automatic offset > 0
const offsetWires = useMemo(() => {
return wires.map(wire => {
const automaticOffset = wireOffsets.get(wire.id) || 0;
const finalOffset = wire.manualOffset !== undefined ? wire.manualOffset : automaticOffset;
return applyOffsetToWire(wire, finalOffset);
});
}, [wires, wireOffsets]);
return ( return (
<svg <svg
className="wire-layer" className="wire-layer"
@ -34,8 +54,8 @@ export const WireLayer: React.FC = () => {
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
/> />
{/* Render all wires */} {/* Render all wires with automatic offsets */}
{wires.map((wire) => ( {offsetWires.map((wire, index) => (
<WireRenderer <WireRenderer
key={wire.id} key={wire.id}
wire={wire} wire={wire}

View File

@ -202,7 +202,7 @@ void loop() {
{ {
id: 'wire-button', id: 'wire-button',
start: { componentId: 'arduino-uno', pinName: '2' }, start: { componentId: 'arduino-uno', pinName: '2' },
end: { componentId: 'button-1', pinName: '1.L' }, end: { componentId: 'button-1', pinName: '1.l' },
color: '#00aaff', color: '#00aaff',
}, },
{ {
@ -570,25 +570,25 @@ void loop() {
{ {
id: 'wire-button-red', id: 'wire-button-red',
start: { componentId: 'arduino-uno', pinName: '2' }, start: { componentId: 'arduino-uno', pinName: '2' },
end: { componentId: 'button-red', pinName: '1.L' }, end: { componentId: 'button-red', pinName: '1.l' },
color: '#00aaff', color: '#00aaff',
}, },
{ {
id: 'wire-button-green', id: 'wire-button-green',
start: { componentId: 'arduino-uno', pinName: '3' }, start: { componentId: 'arduino-uno', pinName: '3' },
end: { componentId: 'button-green', pinName: '1.L' }, end: { componentId: 'button-green', pinName: '1.l' },
color: '#00aaff', color: '#00aaff',
}, },
{ {
id: 'wire-button-blue', id: 'wire-button-blue',
start: { componentId: 'arduino-uno', pinName: '4' }, start: { componentId: 'arduino-uno', pinName: '4' },
end: { componentId: 'button-blue', pinName: '1.L' }, end: { componentId: 'button-blue', pinName: '1.l' },
color: '#00aaff', color: '#00aaff',
}, },
{ {
id: 'wire-button-yellow', id: 'wire-button-yellow',
start: { componentId: 'arduino-uno', pinName: '5' }, start: { componentId: 'arduino-uno', pinName: '5' },
end: { componentId: 'button-yellow', pinName: '1.L' }, end: { componentId: 'button-yellow', pinName: '1.l' },
color: '#00aaff', color: '#00aaff',
}, },
], ],

View File

@ -367,6 +367,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// Recalculate all wire positions from actual DOM pinInfo // Recalculate all wire positions from actual DOM pinInfo
recalculateAllWirePositions: () => { recalculateAllWirePositions: () => {
const state = get(); const state = get();
const updatedWires = state.wires.map((wire) => { const updatedWires = state.wires.map((wire) => {
const updated = { ...wire }; const updated = { ...wire };
const startComp = state.components.find((c) => c.id === wire.start.componentId); const startComp = state.components.find((c) => c.id === wire.start.componentId);

View File

@ -28,6 +28,9 @@ export interface Wire {
// Visual properties // Visual properties
color: string; // Computed from signal type color: string; // Computed from signal type
// Automatic offset management (for overlapping wires)
manualOffset?: number; // User-defined offset (overrides automatic calculation)
// Metadata // Metadata
signalType: WireSignalType | null; // For validation and coloring signalType: WireSignalType | null; // For validation and coloring
isValid: boolean; // Connection validation result (Phase 3) isValid: boolean; // Connection validation result (Phase 3)

View File

@ -0,0 +1,261 @@
/**
* Wire Offset Calculator
*
* Automatically calculates visual offsets for overlapping wires to prevent
* them from rendering on top of each other (similar to Fritzing/TinkerCAD).
*
* Algorithm:
* 1. Detect wire segments that are parallel and overlapping
* 2. Group overlapping segments
* 3. Apply perpendicular offset to each wire in the group
* 4. Distribute offsets evenly around the center line
*/
import type { Wire } from '../types/wire';
export const WIRE_SPACING = 6; // Pixels between parallel wires
const OVERLAP_TOLERANCE = 5; // Pixels tolerance for considering wires as overlapping
/**
* Represents a wire segment (portion of a wire between two bends)
*/
interface WireSegment {
wireId: string;
isVertical: boolean;
start: { x: number; y: number };
end: { x: number; y: number };
centerLine: number; // X position for vertical, Y position for horizontal
}
/**
* Group of overlapping wire segments
*/
interface SegmentGroup {
segments: WireSegment[];
isVertical: boolean;
centerLine: number;
overlapStart: number; // Start of overlapping region
overlapEnd: number; // End of overlapping region
}
/**
* Extract all segments from a wire's path
*/
function extractSegments(wire: Wire): WireSegment[] {
const segments: WireSegment[] = [];
// Start point
let currentPoint = { x: wire.start.x, y: wire.start.y };
// Add segments through control points
if (wire.controlPoints && wire.controlPoints.length > 0) {
for (const controlPoint of wire.controlPoints) {
const nextPoint = { x: controlPoint.x, y: controlPoint.y };
// Determine if segment is vertical or horizontal
const isVertical = Math.abs(nextPoint.x - currentPoint.x) < Math.abs(nextPoint.y - currentPoint.y);
const centerLine = isVertical ? currentPoint.x : currentPoint.y;
segments.push({
wireId: wire.id,
isVertical,
start: { ...currentPoint },
end: { ...nextPoint },
centerLine,
});
currentPoint = nextPoint;
}
}
// Final segment to end point
const endPoint = { x: wire.end.x, y: wire.end.y };
const isVertical = Math.abs(endPoint.x - currentPoint.x) < Math.abs(endPoint.y - currentPoint.y);
const centerLine = isVertical ? currentPoint.x : currentPoint.y;
segments.push({
wireId: wire.id,
isVertical,
start: { ...currentPoint },
end: { ...endPoint },
centerLine,
});
return segments;
}
/**
* Check if two segments overlap
*/
function segmentsOverlap(seg1: WireSegment, seg2: WireSegment): boolean {
// Must be same orientation
if (seg1.isVertical !== seg2.isVertical) return false;
// Must be on similar center line (within tolerance)
if (Math.abs(seg1.centerLine - seg2.centerLine) > OVERLAP_TOLERANCE) return false;
// Check if ranges overlap
if (seg1.isVertical) {
// Vertical: check Y range overlap
const seg1MinY = Math.min(seg1.start.y, seg1.end.y);
const seg1MaxY = Math.max(seg1.start.y, seg1.end.y);
const seg2MinY = Math.min(seg2.start.y, seg2.end.y);
const seg2MaxY = Math.max(seg2.start.y, seg2.end.y);
return !(seg1MaxY < seg2MinY || seg2MaxY < seg1MinY);
} else {
// Horizontal: check X range overlap
const seg1MinX = Math.min(seg1.start.x, seg1.end.x);
const seg1MaxX = Math.max(seg1.start.x, seg1.end.x);
const seg2MinX = Math.min(seg2.start.x, seg2.end.x);
const seg2MaxX = Math.max(seg2.start.x, seg2.end.x);
return !(seg1MaxX < seg2MinX || seg2MaxX < seg1MinX);
}
}
/**
* Group overlapping segments
*/
function groupOverlappingSegments(segments: WireSegment[]): SegmentGroup[] {
const groups: SegmentGroup[] = [];
const processed = new Set<string>();
for (let i = 0; i < segments.length; i++) {
const seg1 = segments[i];
const key1 = `${seg1.wireId}-${i}`;
if (processed.has(key1)) continue;
// Find all segments that overlap with seg1
const group: WireSegment[] = [seg1];
processed.add(key1);
for (let j = i + 1; j < segments.length; j++) {
const seg2 = segments[j];
const key2 = `${seg2.wireId}-${j}`;
if (processed.has(key2)) continue;
// Check if seg2 overlaps with any segment in the current group
if (group.some(seg => segmentsOverlap(seg, seg2))) {
group.push(seg2);
processed.add(key2);
}
}
// Only create a group if there are at least 2 overlapping segments
if (group.length > 1) {
const isVertical = group[0].isVertical;
const centerLine = group.reduce((sum, seg) => sum + seg.centerLine, 0) / group.length;
// Calculate overlap region
let overlapStart: number;
let overlapEnd: number;
if (isVertical) {
overlapStart = Math.max(...group.map(seg => Math.min(seg.start.y, seg.end.y)));
overlapEnd = Math.min(...group.map(seg => Math.max(seg.start.y, seg.end.y)));
} else {
overlapStart = Math.max(...group.map(seg => Math.min(seg.start.x, seg.end.x)));
overlapEnd = Math.min(...group.map(seg => Math.max(seg.start.x, seg.end.x)));
}
groups.push({
segments: group,
isVertical,
centerLine,
overlapStart,
overlapEnd,
});
}
}
return groups;
}
/**
* Calculate offset for each wire based on overlapping groups
*/
export function calculateWireOffsets(wires: Wire[]): Map<string, number> {
const offsets = new Map<string, number>();
// Initialize all offsets to 0
wires.forEach(wire => offsets.set(wire.id, 0));
// Extract all segments from all wires
const allSegments: WireSegment[] = [];
wires.forEach(wire => {
allSegments.push(...extractSegments(wire));
});
// Group overlapping segments
const groups = groupOverlappingSegments(allSegments);
// Calculate offsets for each group
groups.forEach(group => {
const numWires = group.segments.length;
// Get unique wire IDs in this group
const wireIds = [...new Set(group.segments.map(seg => seg.wireId))];
// Calculate offset for each wire
wireIds.forEach((wireId, index) => {
// Distribute offsets symmetrically around center
// For n wires: offsets are [-spacing*(n-1)/2, ..., 0, ..., +spacing*(n-1)/2]
const offset = (index - (numWires - 1) / 2) * WIRE_SPACING;
// Store the maximum absolute offset for this wire
// (in case wire participates in multiple groups)
const currentOffset = offsets.get(wireId) || 0;
if (Math.abs(offset) > Math.abs(currentOffset)) {
offsets.set(wireId, offset);
}
});
});
return offsets;
}
/**
* Apply offset to wire points (perpendicular to wire direction)
*/
export function applyOffsetToWire(wire: Wire, offset: number): Wire {
if (offset === 0) return wire;
// Clone the wire
const offsetWire: Wire = {
...wire,
start: { ...wire.start },
end: { ...wire.end },
controlPoints: wire.controlPoints ? [...wire.controlPoints] : [],
};
// Apply offset to start and end points
// Determine primary direction (first segment)
const firstPoint = offsetWire.start;
const secondPoint = offsetWire.controlPoints && offsetWire.controlPoints.length > 0
? offsetWire.controlPoints[0]
: offsetWire.end;
const isVertical = Math.abs(secondPoint.x - firstPoint.x) < Math.abs(secondPoint.y - firstPoint.y);
// Apply offset perpendicular to direction
if (isVertical) {
// Vertical wire: offset in X
offsetWire.start.x += offset;
offsetWire.end.x += offset;
offsetWire.controlPoints?.forEach(point => {
point.x += offset;
});
} else {
// Horizontal wire: offset in Y
offsetWire.start.y += offset;
offsetWire.end.y += offset;
offsetWire.controlPoints?.forEach(point => {
point.y += offset;
});
}
return offsetWire;
}