diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4856096..71595ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "axios": "^1.13.6", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", "zustand": "^5.0.11" }, "devDependencies": { @@ -2127,6 +2128,19 @@ "dev": true, "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3296,6 +3310,44 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3322,6 +3374,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 08bb428..32623db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "axios": "^1.13.6", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-router-dom": "^7.13.1", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index e7d1177..687bd8c 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -48,6 +48,11 @@ export const DynamicComponent: React.FC = ({ const mountedRef = useRef(false); 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 @@ -179,11 +184,8 @@ export const DynamicComponent: React.FC = ({ 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; if (logic && logic.attachEvents) { - console.log(`[DynamicComponent] Attaching events for ${id} (${metadata.id})`); // We need AVRSimulator instance. We can grab it from store. const simulator = useSimulatorStore.getState().simulator; if (simulator) { @@ -225,7 +227,7 @@ export const DynamicComponent: React.FC = ({ position: 'absolute', left: `${x}px`, top: `${y}px`, - cursor: 'move', + cursor: running && isInteractive ? 'pointer' : 'move', border: isSelected ? '2px dashed #007acc' : '2px solid transparent', borderRadius: '4px', padding: '4px', diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index e3ce381..56d2a92 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -303,6 +303,20 @@ export const SimulatorCanvas = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [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[] = []; + + // 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 const renderComponent = (component: any) => { const metadata = registry.getById(component.metadataId); @@ -325,21 +339,29 @@ export const SimulatorCanvas = () => { y={component.y} isSelected={isSelected} onMouseDown={(e) => { - handleComponentMouseDown(component.id, e); + // Only handle UI events when simulation is NOT running + if (!running) { + handleComponentMouseDown(component.id, 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 */} + {!running && ( + + )} ); }; diff --git a/frontend/src/components/simulator/WireLayer.tsx b/frontend/src/components/simulator/WireLayer.tsx index bd6bb2b..81868ae 100644 --- a/frontend/src/components/simulator/WireLayer.tsx +++ b/frontend/src/components/simulator/WireLayer.tsx @@ -3,16 +3,36 @@ * * SVG layer that renders all wires below components. * 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 { WireRenderer } from './WireRenderer'; import { WireInProgressRenderer } from './WireInProgressRenderer'; +import { calculateWireOffsets, applyOffsetToWire } from '../../utils/wireOffsetCalculator'; export const WireLayer: React.FC = () => { 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 ( { style={{ pointerEvents: 'none' }} /> - {/* Render all wires */} - {wires.map((wire) => ( + {/* Render all wires with automatic offsets */} + {offsetWires.map((wire, index) => ( ((set, get) => { // Recalculate all wire positions from actual DOM pinInfo recalculateAllWirePositions: () => { const state = get(); + const updatedWires = state.wires.map((wire) => { const updated = { ...wire }; const startComp = state.components.find((c) => c.id === wire.start.componentId); diff --git a/frontend/src/types/wire.ts b/frontend/src/types/wire.ts index d0f8c37..ae91efb 100644 --- a/frontend/src/types/wire.ts +++ b/frontend/src/types/wire.ts @@ -28,6 +28,9 @@ export interface Wire { // Visual properties color: string; // Computed from signal type + // Automatic offset management (for overlapping wires) + manualOffset?: number; // User-defined offset (overrides automatic calculation) + // Metadata signalType: WireSignalType | null; // For validation and coloring isValid: boolean; // Connection validation result (Phase 3) diff --git a/frontend/src/utils/wireOffsetCalculator.ts b/frontend/src/utils/wireOffsetCalculator.ts new file mode 100644 index 0000000..ec65898 --- /dev/null +++ b/frontend/src/utils/wireOffsetCalculator.ts @@ -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(); + + 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 { + const offsets = new Map(); + + // 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; +}