feat: add interactive wire segment editing and utility functions

pull/10/head
David Montero Crespo 2026-03-03 21:14:01 -03:00
parent 217736c7cd
commit a8bb0b6ad9
4 changed files with 428 additions and 59 deletions

View File

@ -14,6 +14,16 @@ Local Arduino emulator with code editor and visual simulator.
- ⏳ Full emulation with avr8js (in progress)
- ⏳ SQLite persistence (coming soon)
## Screenshots
<img src="doc/img1.png" alt="Arduino Emulator - Editor and Simulator" width="800"/>
*Arduino emulator with Monaco code editor and visual simulator with wokwi-elements*
<img src="doc/img2.png" alt="Arduino Emulator - Component Properties and Wire Editing" width="800"/>
*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

View File

@ -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",

View File

@ -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<WireRendererProps> = ({ wire, isSelected }) => {
const { setSelectedWire, updateWire } = useSimulatorStore();
const [draggedCPId, setDraggedCPId] = useState<string | null>(null);
const [hoveredSegment, setHoveredSegment] = useState<WireSegment | null>(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<Array<{ x: number; y: number }> | null>(null);
const svgRef = useRef<SVGGElement>(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<WireRendererProps> = ({ 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 (
<g
@ -76,7 +196,7 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
className="wire-group"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
{/* Invisible thick path for easier clicking */}
<path
@ -91,11 +211,12 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
{/* Visible wire path */}
<path
d={path}
stroke={wire.isValid ? wire.color : '#ff4444'} // Red for invalid (Phase 3)
strokeWidth="2"
stroke={wire.isValid ? wire.color : '#ff4444'}
strokeWidth={isSelected ? '3' : '2'}
fill="none"
strokeDasharray={wire.isValid ? undefined : '5,5'} // Dashed for invalid
strokeDasharray={wire.isValid ? undefined : '5,5'}
style={{ pointerEvents: 'none' }}
opacity={isSelected ? '1' : '0.8'}
/>
{/* Endpoint markers */}
@ -106,19 +227,13 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
fill={wire.color}
style={{ pointerEvents: 'none' }}
/>
<circle
cx={wire.end.x}
cy={wire.end.y}
r="3"
fill={wire.color}
style={{ pointerEvents: 'none' }}
/>
<circle cx={wire.end.x} cy={wire.end.y} r="3" fill={wire.color} style={{ pointerEvents: 'none' }} />
{/* Selection indicator */}
{isSelected && (
<path
d={path}
stroke="#00ffff" // Cyan highlight for selected wire
stroke="#00ffff"
strokeWidth="3"
fill="none"
strokeDasharray="10,5"
@ -127,24 +242,55 @@ export const WireRenderer: React.FC<WireRendererProps> = ({ wire, isSelected })
/>
)}
{/* Control points - draggable when wire is selected */}
{isSelected && wire.controlPoints.length > 0 && (
<>
{wire.controlPoints.map((cp) => (
<circle
key={cp.id}
cx={cp.x}
cy={cp.y}
r="6"
fill={draggedCPId === cp.id ? '#a78bfa' : '#8b5cf6'} // Lighter purple when dragging
stroke="white"
strokeWidth="2"
style={{ cursor: 'move', pointerEvents: 'all' }}
onMouseDown={(e) => handleControlPointMouseDown(cp.id, e)}
{/* Segment interaction overlays - only when selected */}
{isSelected &&
segments.map((segment) => (
<g key={segment.id}>
{/* Invisible thick hitbox for easier interaction */}
<line
x1={segment.startPoint.x}
y1={segment.startPoint.y}
x2={segment.endPoint.x}
y2={segment.endPoint.y}
stroke="transparent"
strokeWidth="16"
style={{
cursor:
segment.orientation === 'horizontal' ? 'ns-resize' : 'ew-resize',
pointerEvents: 'stroke',
}}
onMouseDown={(e) => handleSegmentMouseDown(segment, e)}
/>
))}
</>
)}
{/* Visual drag handle at midpoint when hovering */}
{(hoveredSegment?.id === segment.id || dragState?.segment.id === segment.id) && (
<>
{/* Highlight the segment */}
<line
x1={segment.startPoint.x}
y1={segment.startPoint.y}
x2={segment.endPoint.x}
y2={segment.endPoint.y}
stroke="#a78bfa"
strokeWidth="4"
style={{ pointerEvents: 'none' }}
opacity="0.8"
/>
{/* Drag handle circle */}
<circle
cx={segment.midPoint.x}
cy={segment.midPoint.y}
r="5"
fill="#8b5cf6"
stroke="white"
strokeWidth="2"
style={{ pointerEvents: 'none' }}
/>
</>
)}
</g>
))}
</g>
);
};

View File

@ -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;
}