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 with Monaco code editor and visual simulator with wokwi-elements*
+
+
+
+*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;
+}