diff --git a/README.md b/README.md index 8810f3d..f9993b7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ Local Arduino emulator with code editor and visual simulator. - ⏳ Full emulation with avr8js (in progress) - ⏳ SQLite persistence (coming soon) +## Screenshots + +Arduino Emulator - Editor and Simulator + +*Arduino emulator with Monaco code editor and visual simulator with wokwi-elements* + +Arduino Emulator - Component Properties and Wire Editing + +*Interactive component properties dialog and segment-based wire editing* + ## Prerequisites ### 1. Node.js @@ -245,3 +255,4 @@ MIT - [wokwi-elements](https://github.com/wokwi/wokwi-elements) - Web components - [arduino-cli](https://github.com/arduino/arduino-cli) - Arduino compiler - [Monaco Editor](https://microsoft.github.io/monaco-editor/) - Code editor + diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index bf0f125..438ba1d 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "generatedAt": "2026-03-03T20:10:47.821Z", + "generatedAt": "2026-03-04T00:10:03.051Z", "components": [ { "id": "arduino-mega", diff --git a/frontend/src/components/simulator/WireRenderer.tsx b/frontend/src/components/simulator/WireRenderer.tsx index 45234bf..0364dc2 100644 --- a/frontend/src/components/simulator/WireRenderer.tsx +++ b/frontend/src/components/simulator/WireRenderer.tsx @@ -1,18 +1,26 @@ /** * WireRenderer Component * - * Renders an individual wire with the following features: - * - Invisible thick path for easy clicking - * - Visible colored path based on signal type - * - Endpoint markers (circles) - * - Control points when selected (Phase 2) - * - Dashed line for invalid connections (Phase 3) + * Renders wires with segment-based editing: + * - Click to select wire + * - Hover over segments to see drag handles + * - Drag horizontal segments up/down + * - Drag vertical segments left/right */ -import React, { useMemo, useCallback, useState, useRef } from 'react'; +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; import type { Wire } from '../../types/wire'; import { useSimulatorStore } from '../../store/useSimulatorStore'; import { generateWirePath } from '../../utils/wirePathGenerator'; +import { + computeSegments, + findSegmentUnderCursor, + getPathPoints, + generateOrthogonalPoints, + updateOrthogonalPointsForSegmentDrag, + orthogonalPointsToControlPoints, + type WireSegment, +} from '../../utils/wireSegments'; interface WireRendererProps { wire: Wire; @@ -21,14 +29,38 @@ interface WireRendererProps { export const WireRenderer: React.FC = ({ wire, isSelected }) => { const { setSelectedWire, updateWire } = useSimulatorStore(); - const [draggedCPId, setDraggedCPId] = useState(null); + const [hoveredSegment, setHoveredSegment] = useState(null); + const [dragState, setDragState] = useState<{ + segment: WireSegment; + startMousePos: { x: number; y: number }; + originalOrthoPoints: Array<{ x: number; y: number }>; + } | null>(null); + + // Local preview path during drag (for smooth performance) + const [previewOrthoPoints, setPreviewOrthoPoints] = useState | null>(null); + const svgRef = useRef(null); // Generate SVG path (memoized for performance) + // Use preview points during drag, actual wire points otherwise const path = useMemo(() => { + if (previewOrthoPoints) { + // Generate path from preview points during drag + let pathD = `M ${previewOrthoPoints[0].x} ${previewOrthoPoints[0].y}`; + for (let i = 1; i < previewOrthoPoints.length; i++) { + pathD += ` L ${previewOrthoPoints[i].x} ${previewOrthoPoints[i].y}`; + } + return pathD; + } return generateWirePath(wire); - }, [wire.start.x, wire.start.y, wire.end.x, wire.end.y, wire.controlPoints]); + }, [wire, previewOrthoPoints]); + // Compute segments (memoized) + const segments = useMemo(() => { + return computeSegments(wire); + }, [wire]); + + // Handle wire selection const handleWireClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -37,38 +69,126 @@ export const WireRenderer: React.FC = ({ wire, isSelected }) [wire.id, setSelectedWire] ); - const handleControlPointMouseDown = useCallback( - (cpId: string, e: React.MouseEvent) => { - e.stopPropagation(); - setDraggedCPId(cpId); - }, - [] - ); - + // Handle segment hover const handleMouseMove = useCallback( (e: React.MouseEvent) => { - if (!draggedCPId || !svgRef.current) return; + if (dragState) { + // Handle dragging - use local state for smooth updates + const svg = svgRef.current?.ownerSVGElement; + if (!svg) return; - const svg = svgRef.current.ownerSVGElement; + const svgRect = svg.getBoundingClientRect(); + const mouseX = e.clientX - svgRect.left; + const mouseY = e.clientY - svgRect.top; + + const { segment, startMousePos, originalOrthoPoints } = dragState; + + // Calculate offset perpendicular to segment + let offset = 0; + if (segment.orientation === 'horizontal') { + offset = mouseY - startMousePos.y; + } else { + offset = mouseX - startMousePos.x; + } + + // No grid snapping during drag for smooth movement + // Grid snapping will be applied on mouse up + + // Update orthogonal points (local preview) + const newOrthoPoints = updateOrthogonalPointsForSegmentDrag( + originalOrthoPoints, + segment, + offset + ); + + // Update preview state (doesn't touch the store) + setPreviewOrthoPoints(newOrthoPoints); + } else if (isSelected) { + // Update hovered segment + const svg = svgRef.current?.ownerSVGElement; + if (!svg) return; + + const svgRect = svg.getBoundingClientRect(); + const mouseX = e.clientX - svgRect.left; + const mouseY = e.clientY - svgRect.top; + + const segment = findSegmentUnderCursor(segments, mouseX, mouseY); + setHoveredSegment(segment); + } + }, + [dragState, isSelected, segments, wire, updateWire] + ); + + const handleSegmentMouseDown = useCallback( + (segment: WireSegment, e: React.MouseEvent) => { + e.stopPropagation(); + + const svg = svgRef.current?.ownerSVGElement; if (!svg) return; - // Get SVG bounding rect and convert mouse position to SVG coordinates const svgRect = svg.getBoundingClientRect(); - const x = e.clientX - svgRect.left; - const y = e.clientY - svgRect.top; + const mouseX = e.clientX - svgRect.left; + const mouseY = e.clientY - svgRect.top; - const updatedControlPoints = wire.controlPoints.map((cp) => - cp.id === draggedCPId ? { ...cp, x, y } : cp - ); + // Get current orthogonal points + const pathPoints = getPathPoints(wire); + const orthoPoints = generateOrthogonalPoints(pathPoints); - updateWire(wire.id, { controlPoints: updatedControlPoints }); + setDragState({ + segment, + startMousePos: { x: mouseX, y: mouseY }, + originalOrthoPoints: orthoPoints, + }); }, - [draggedCPId, wire.id, wire.controlPoints, updateWire] + [wire] ); const handleMouseUp = useCallback(() => { - setDraggedCPId(null); - }, []); + if (dragState && previewOrthoPoints) { + // Apply grid snapping to final position + const GRID_SIZE = 20; + const snappedPoints = previewOrthoPoints.map((p) => ({ + x: Math.round(p.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(p.y / GRID_SIZE) * GRID_SIZE, + })); + + // Convert back to control points + const newControlPoints = orthogonalPointsToControlPoints( + snappedPoints, + wire.start, + wire.end + ); + + // Update store only once at the end + updateWire(wire.id, { controlPoints: newControlPoints }); + } + + // Clear drag state and preview + setDragState(null); + setPreviewOrthoPoints(null); + }, [dragState, previewOrthoPoints, wire, updateWire]); + + const handleMouseLeave = useCallback(() => { + if (!dragState) { + setHoveredSegment(null); + } + }, [dragState]); + + // Update cursor based on hovered segment + useEffect(() => { + const svg = svgRef.current?.ownerSVGElement; + if (!svg) return; + + if (dragState) { + svg.style.cursor = + dragState.segment.orientation === 'horizontal' ? 'ns-resize' : 'ew-resize'; + } else if (hoveredSegment) { + svg.style.cursor = + hoveredSegment.orientation === 'horizontal' ? 'ns-resize' : 'ew-resize'; + } else { + svg.style.cursor = 'pointer'; + } + }, [hoveredSegment, dragState]); return ( = ({ wire, isSelected }) className="wire-group" onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} + onMouseLeave={handleMouseLeave} > {/* Invisible thick path for easier clicking */} = ({ wire, isSelected }) {/* Visible wire path */} {/* Endpoint markers */} @@ -106,19 +227,13 @@ export const WireRenderer: React.FC = ({ wire, isSelected }) fill={wire.color} style={{ pointerEvents: 'none' }} /> - + {/* Selection indicator */} {isSelected && ( = ({ wire, isSelected }) /> )} - {/* Control points - draggable when wire is selected */} - {isSelected && wire.controlPoints.length > 0 && ( - <> - {wire.controlPoints.map((cp) => ( - handleControlPointMouseDown(cp.id, e)} + {/* Segment interaction overlays - only when selected */} + {isSelected && + segments.map((segment) => ( + + {/* Invisible thick hitbox for easier interaction */} + handleSegmentMouseDown(segment, e)} /> - ))} - - )} + + {/* Visual drag handle at midpoint when hovering */} + {(hoveredSegment?.id === segment.id || dragState?.segment.id === segment.id) && ( + <> + {/* Highlight the segment */} + + + {/* Drag handle circle */} + + + )} + + ))} ); }; diff --git a/frontend/src/utils/wireSegments.ts b/frontend/src/utils/wireSegments.ts new file mode 100644 index 0000000..0486813 --- /dev/null +++ b/frontend/src/utils/wireSegments.ts @@ -0,0 +1,212 @@ +/** + * Wire Segment Utilities + * + * Handles computation and manipulation of wire segments for interactive editing. + * Segments are the straight horizontal/vertical lines between path points. + */ + +import type { Wire, WireControlPoint } from '../types/wire'; + +export interface WireSegment { + id: string; + startPoint: { x: number; y: number }; + endPoint: { x: number; y: number }; + orientation: 'horizontal' | 'vertical'; + midPoint: { x: number; y: number }; + length: number; + startIndex: number; // Index in orthoPoints array + endIndex: number; // Index in orthoPoints array +} + +/** + * Get all path points (start + control points + end) + */ +export function getPathPoints(wire: Wire): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + + points.push({ x: wire.start.x, y: wire.start.y }); + + for (const cp of wire.controlPoints) { + points.push({ x: cp.x, y: cp.y }); + } + + points.push({ x: wire.end.x, y: wire.end.y }); + + return points; +} + +/** + * Generate orthogonal path points from control points + * Converts diagonal connections to L-shapes (horizontal then vertical or vice versa) + */ +export function generateOrthogonalPoints( + points: Array<{ x: number; y: number }> +): Array<{ x: number; y: number }> { + const result: Array<{ x: number; y: number }> = []; + + for (let i = 0; i < points.length - 1; i++) { + const current = points[i]; + const next = points[i + 1]; + + result.push(current); + + // If points are not aligned, add intermediate point + if (current.x !== next.x && current.y !== next.y) { + const dx = Math.abs(next.x - current.x); + const dy = Math.abs(next.y - current.y); + + if (dx > dy) { + // Go horizontal first + result.push({ x: next.x, y: current.y }); + } else { + // Go vertical first + result.push({ x: current.x, y: next.y }); + } + } + } + + result.push(points[points.length - 1]); + + return result; +} + +/** + * Compute all segments from a wire + */ +export function computeSegments(wire: Wire): WireSegment[] { + const pathPoints = getPathPoints(wire); + const orthoPoints = generateOrthogonalPoints(pathPoints); + const segments: WireSegment[] = []; + + for (let i = 0; i < orthoPoints.length - 1; i++) { + const start = orthoPoints[i]; + const end = orthoPoints[i + 1]; + + // Skip zero-length segments + if (start.x === end.x && start.y === end.y) continue; + + const orientation = start.y === end.y ? 'horizontal' : 'vertical'; + const length = + orientation === 'horizontal' + ? Math.abs(end.x - start.x) + : Math.abs(end.y - start.y); + + segments.push({ + id: `${wire.id}-seg-${i}`, + startPoint: start, + endPoint: end, + orientation, + midPoint: { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + }, + length, + startIndex: i, + endIndex: i + 1, + }); + } + + return segments; +} + +/** + * Find which segment is under the cursor + */ +export function findSegmentUnderCursor( + segments: WireSegment[], + mouseX: number, + mouseY: number, + threshold: number = 8 // 8px tolerance +): WireSegment | null { + for (const segment of segments) { + if (segment.orientation === 'horizontal') { + const minX = Math.min(segment.startPoint.x, segment.endPoint.x); + const maxX = Math.max(segment.startPoint.x, segment.endPoint.x); + const lineY = segment.startPoint.y; + + if ( + mouseX >= minX && + mouseX <= maxX && + Math.abs(mouseY - lineY) <= threshold + ) { + return segment; + } + } else { + const minY = Math.min(segment.startPoint.y, segment.endPoint.y); + const maxY = Math.max(segment.startPoint.y, segment.endPoint.y); + const lineX = segment.startPoint.x; + + if ( + mouseY >= minY && + mouseY <= maxY && + Math.abs(mouseX - lineX) <= threshold + ) { + return segment; + } + } + } + + return null; +} + +/** + * Update orthogonal points when dragging a segment + */ +export function updateOrthogonalPointsForSegmentDrag( + orthoPoints: Array<{ x: number; y: number }>, + segment: WireSegment, + offset: number +): Array<{ x: number; y: number }> { + const newPoints = orthoPoints.map((p) => ({ ...p })); + + const { startIndex, endIndex, orientation } = segment; + + if (orientation === 'horizontal') { + // Move horizontal segment up/down (change Y) + newPoints[startIndex].y += offset; + newPoints[endIndex].y += offset; + } else { + // Move vertical segment left/right (change X) + newPoints[startIndex].x += offset; + newPoints[endIndex].x += offset; + } + + return newPoints; +} + +/** + * Convert orthogonal points back to control points + * Removes start/end points and intermediate points that are redundant + */ +export function orthogonalPointsToControlPoints( + orthoPoints: Array<{ x: number; y: number }>, + start: { x: number; y: number }, + end: { x: number; y: number } +): WireControlPoint[] { + // Remove first and last points (those are start/end endpoints) + const innerPoints = orthoPoints.slice(1, -1); + + // Remove redundant points (those that are collinear with neighbors) + const controlPoints: WireControlPoint[] = []; + + for (let i = 0; i < innerPoints.length; i++) { + const current = innerPoints[i]; + const prev = i === 0 ? start : innerPoints[i - 1]; + const next = i === innerPoints.length - 1 ? end : innerPoints[i + 1]; + + // Check if current point is a corner (changes direction) + const isCorner = + (prev.x === current.x && current.y === next.y) || + (prev.y === current.y && current.x === next.x); + + if (isCorner) { + controlPoints.push({ + id: `cp-${Date.now()}-${i}`, + x: current.x, + y: current.y, + }); + } + } + + return controlPoints; +}