feat: implement segment dragging functionality in simulator canvas

pull/47/head
David Montero Crespo 2026-03-14 23:09:32 -03:00
parent 405ff1f017
commit fe89d06787
4 changed files with 209 additions and 63 deletions

View File

@ -6,6 +6,7 @@ import { DynamicComponent, createComponentFromMetadata } from '../DynamicCompone
import { ComponentRegistry } from '../../services/ComponentRegistry';
import { PinSelector } from './PinSelector';
import { WireLayer } from './WireLayer';
import type { SegmentHandle } from './WireLayer';
import { BoardOnCanvas } from './BoardOnCanvas';
import { BoardPickerModal } from './BoardPickerModal';
import { PartSimulationRegistry } from '../../simulation/parts';
@ -14,8 +15,11 @@ import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'
import { autoWireColor, WIRE_KEY_COLORS } from '../../utils/wireUtils';
import {
findWireNearPoint,
findSegmentNearPoint,
computeDragWaypoints,
getRenderedPoints,
getRenderedSegments,
moveSegment,
renderedToWaypoints,
renderedPointsToPath,
} from '../../utils/wireHitDetection';
import type { ComponentMetadata } from '../../types/component-metadata';
import type { BoardKind } from '../../types/board';
@ -125,20 +129,35 @@ export const SimulatorCanvas = () => {
// Wire interaction state (canvas-level hit detection — bypasses SVG pointer-events issues)
const [hoveredWireId, setHoveredWireId] = useState<string | null>(null);
const [wireDragPreview, setWireDragPreview] = useState<{
const [segmentDragPreview, setSegmentDragPreview] = useState<{
wireId: string;
waypoints: { x: number; y: number }[];
overridePath: string;
} | null>(null);
const wireInteractionRef = useRef<{
const segmentDragRef = useRef<{
wireId: string;
startWorld: { x: number; y: number };
segment: import('../../utils/wireHitDetection').RenderedSegment | null;
originalWaypoints: { x: number; y: number }[];
segIndex: number;
axis: 'horizontal' | 'vertical';
renderedPts: { x: number; y: number }[];
isDragging: boolean;
} | null>(null);
/** Set to true during mouseup if a segment drag committed, so onClick can skip selection. */
const segmentDragJustCommittedRef = useRef(false);
const wiresRef = useRef(wires);
wiresRef.current = wires;
// Compute midpoint handles for the selected wire's segments
const segmentHandles = React.useMemo<SegmentHandle[]>(() => {
if (!selectedWireId) return [];
const wire = wires.find((w) => w.id === selectedWireId);
if (!wire) return [];
return getRenderedSegments(wire).map((seg, i) => ({
segIndex: i,
axis: seg.axis,
mx: (seg.x1 + seg.x2) / 2,
my: (seg.y1 + seg.y2) / 2,
}));
}, [selectedWireId, wires]);
// Touch-specific state refs (for single-finger drag and pinch-to-zoom)
const touchDraggedComponentIdRef = useRef<string | null>(null);
const touchDragOffsetRef = useRef({ x: 0, y: 0 });
@ -616,27 +635,15 @@ export const SimulatorCanvas = () => {
return;
}
// Handle wire segment dragging
if (wireInteractionRef.current) {
// Handle segment handle dragging
if (segmentDragRef.current) {
const world = toWorld(e.clientX, e.clientY);
const wi = wireInteractionRef.current;
if (!wi.isDragging) {
const moved = Math.hypot(world.x - wi.startWorld.x, world.y - wi.startWorld.y);
if (moved > 4 / zoomRef.current) {
wi.isDragging = true;
}
}
if (wi.isDragging && wi.segment) {
const newWaypoints = computeDragWaypoints(
wi.originalWaypoints,
wi.segment.storedPairIndex,
world.x,
world.y,
);
setWireDragPreview({ wireId: wi.wireId, waypoints: newWaypoints });
}
const sd = segmentDragRef.current;
sd.isDragging = true;
const newValue = sd.axis === 'horizontal' ? world.y : world.x;
const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
const overridePath = renderedPointsToPath(newPts);
setSegmentDragPreview({ wireId: sd.wireId, overridePath });
return;
}
@ -657,14 +664,19 @@ export const SimulatorCanvas = () => {
return;
}
// Commit wire segment drag
if (wireInteractionRef.current) {
const wi = wireInteractionRef.current;
if (wi.isDragging && wireDragPreview) {
updateWire(wi.wireId, { waypoints: wireDragPreview.waypoints });
// Commit segment handle drag
if (segmentDragRef.current) {
const sd = segmentDragRef.current;
if (sd.isDragging) {
segmentDragJustCommittedRef.current = true;
const world = toWorld(e.clientX, e.clientY);
const newValue = sd.axis === 'horizontal' ? world.y : world.x;
const newPts = moveSegment(sd.renderedPts, sd.segIndex, sd.axis, newValue);
updateWire(sd.wireId, { waypoints: renderedToWaypoints(newPts) });
}
wireInteractionRef.current = null;
setWireDragPreview(null);
segmentDragRef.current = null;
setSegmentDragPreview(null);
return;
}
if (draggedComponentId) {
@ -688,7 +700,7 @@ export const SimulatorCanvas = () => {
}
};
// Start panning on middle-click or right-click; wire drag on left-click near wire
// Start panning on middle-click or right-click
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (e.button === 1 || e.button === 2) {
e.preventDefault();
@ -699,25 +711,32 @@ export const SimulatorCanvas = () => {
panX: panRef.current.x,
panY: panRef.current.y,
};
} else if (e.button === 0 && !wireInProgress) {
// Check if clicking near a wire to start a drag interaction
const world = toWorld(e.clientX, e.clientY);
const threshold = 8 / zoomRef.current;
const wire = findWireNearPoint(wiresRef.current, world.x, world.y, threshold);
if (wire) {
const segment = findSegmentNearPoint(wire, world.x, world.y, threshold);
wireInteractionRef.current = {
wireId: wire.id,
startWorld: world,
segment,
originalWaypoints: [...(wire.waypoints ?? [])],
isDragging: false,
};
// Don't stopPropagation here — allow component drag detection to also run
}
}
};
// Handle mousedown on a segment handle circle (called from WireLayer)
const handleHandleMouseDown = useCallback(
(e: React.MouseEvent, segIndex: number) => {
e.stopPropagation();
e.preventDefault();
if (!selectedWireId) return;
const wire = wiresRef.current.find((w) => w.id === selectedWireId);
if (!wire) return;
const segments = getRenderedSegments(wire);
const seg = segments[segIndex];
if (!seg) return;
const expandedPts = getRenderedPoints(wire);
segmentDragRef.current = {
wireId: wire.id,
segIndex,
axis: seg.axis,
renderedPts: expandedPts,
isDragging: false,
};
},
[selectedWireId],
);
// Zoom centered on cursor
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
@ -1028,8 +1047,11 @@ export const SimulatorCanvas = () => {
addWireWaypoint(world.x, world.y);
return;
}
// If a wire drag just finished, don't also select
if (wireInteractionRef.current?.isDragging) return;
// If a segment handle drag just finished, don't also select
if (segmentDragJustCommittedRef.current) {
segmentDragJustCommittedRef.current = false;
return;
}
// Wire selection via canvas-level hit detection
const world = toWorld(e.clientX, e.clientY);
const threshold = 8 / zoomRef.current;
@ -1065,7 +1087,9 @@ export const SimulatorCanvas = () => {
{/* Wire Layer - Renders below all components */}
<WireLayer
hoveredWireId={hoveredWireId}
wireDragPreview={wireDragPreview}
segmentDragPreview={segmentDragPreview}
segmentHandles={segmentHandles}
onHandleMouseDown={handleHandleMouseDown}
/>
{/* All boards on canvas */}

View File

@ -3,12 +3,29 @@ import { useSimulatorStore } from '../../store/useSimulatorStore';
import { WireRenderer } from './WireRenderer';
import { WireInProgressRenderer } from './WireInProgressRenderer';
interface WireLayerProps {
hoveredWireId: string | null;
wireDragPreview: { wireId: string; waypoints: { x: number; y: number }[] } | null;
export interface SegmentHandle {
segIndex: number;
axis: 'horizontal' | 'vertical';
mx: number; // midpoint X
my: number; // midpoint Y
}
export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPreview }) => {
interface WireLayerProps {
hoveredWireId: string | null;
/** Segment drag preview: overrides the path of a specific wire */
segmentDragPreview: { wireId: string; overridePath: string } | null;
/** Handles to render for the selected wire */
segmentHandles: SegmentHandle[];
/** Called when user starts dragging a handle (passes segIndex) */
onHandleMouseDown: (e: React.MouseEvent, segIndex: number) => void;
}
export const WireLayer: React.FC<WireLayerProps> = ({
hoveredWireId,
segmentDragPreview,
segmentHandles,
onHandleMouseDown,
}) => {
const wires = useSimulatorStore((s) => s.wires);
const wireInProgress = useSimulatorStore((s) => s.wireInProgress);
const selectedWireId = useSimulatorStore((s) => s.selectedWireId);
@ -24,7 +41,7 @@ export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPre
height: '100%',
overflow: 'visible',
pointerEvents: 'none',
zIndex: 1,
zIndex: 20,
}}
>
{wires.map((wire) => (
@ -33,12 +50,29 @@ export const WireLayer: React.FC<WireLayerProps> = ({ hoveredWireId, wireDragPre
wire={wire}
isSelected={wire.id === selectedWireId}
isHovered={wire.id === hoveredWireId}
previewWaypoints={
wireDragPreview?.wireId === wire.id ? wireDragPreview.waypoints : undefined
overridePath={
segmentDragPreview?.wireId === wire.id
? segmentDragPreview.overridePath
: undefined
}
/>
))}
{/* Segment handles for the selected wire */}
{segmentHandles.map((handle) => (
<circle
key={handle.segIndex}
cx={handle.mx}
cy={handle.my}
r={7}
fill="white"
stroke="#007acc"
strokeWidth={2}
style={{ pointerEvents: 'all', cursor: handle.axis === 'horizontal' ? 'ns-resize' : 'ew-resize' }}
onMouseDown={(e) => onHandleMouseDown(e, handle.segIndex)}
/>
))}
{wireInProgress && (
<WireInProgressRenderer wireInProgress={wireInProgress} />
)}

View File

@ -13,6 +13,8 @@ interface WireRendererProps {
isHovered: boolean;
/** Temporary waypoints used during drag preview */
previewWaypoints?: { x: number; y: number }[];
/** Override the full SVG path string (used during segment drag preview) */
overridePath?: string;
}
export const WireRenderer: React.FC<WireRendererProps> = ({
@ -20,9 +22,10 @@ export const WireRenderer: React.FC<WireRendererProps> = ({
isSelected,
isHovered,
previewWaypoints,
overridePath,
}) => {
const waypoints = previewWaypoints ?? wire.waypoints;
const path = generateOrthogonalPath(wire.start, waypoints, wire.end);
const path = overridePath ?? generateOrthogonalPath(wire.start, waypoints, wire.end);
if (!path) return null;

View File

@ -145,3 +145,88 @@ export function computeDragWaypoints(
...originalWaypoints.slice(storedPairIndex),
];
}
/**
* Move an entire rendered segment perpendicularly.
* - horizontal segment moves up/down (change Y of both endpoints)
* - vertical segment moves left/right (change X of both endpoints)
* If the segment is the first or last, inserts connector points to keep
* the wire connected to its fixed start/end.
*/
export function moveSegment(
renderedPts: { x: number; y: number }[],
segIndex: number,
axis: 'horizontal' | 'vertical',
newValue: number,
): { x: number; y: number }[] {
const n = renderedPts.length;
const numSegs = n - 1;
const pts = renderedPts.map((p) => ({ ...p }));
if (axis === 'horizontal') {
if (segIndex === 0 && numSegs > 0) {
// First segment: keep start fixed, insert connector
pts.splice(1, 0, { x: pts[0].x, y: newValue }, { x: pts[1].x, y: newValue });
pts.splice(3, 1); // remove original pts[1] copy
} else if (segIndex === numSegs - 1 && numSegs > 0) {
// Last segment: keep end fixed, insert connector
const last = pts[n - 1];
pts.splice(n - 1, 0, { x: pts[n - 2].x, y: newValue }, { x: last.x, y: newValue });
} else {
pts[segIndex].y = newValue;
pts[segIndex + 1].y = newValue;
}
} else {
// vertical
if (segIndex === 0 && numSegs > 0) {
pts.splice(1, 0, { x: newValue, y: pts[0].y }, { x: newValue, y: pts[1].y });
pts.splice(3, 1);
} else if (segIndex === numSegs - 1 && numSegs > 0) {
const last = pts[n - 1];
pts.splice(n - 1, 0, { x: newValue, y: pts[n - 2].y }, { x: newValue, y: last.y });
} else {
pts[segIndex].x = newValue;
pts[segIndex + 1].x = newValue;
}
}
return pts;
}
/**
* Convert a list of rendered (expanded) points back to wire waypoints.
* Waypoints are the interior corner/bend points (excludes start and end).
* Consecutive collinear points are collapsed so only actual corners remain.
*/
export function renderedToWaypoints(
renderedPts: { x: number; y: number }[],
): { x: number; y: number }[] {
if (renderedPts.length <= 2) return [];
const waypoints: { x: number; y: number }[] = [];
for (let i = 1; i < renderedPts.length - 1; i++) {
const prev = renderedPts[i - 1];
const curr = renderedPts[i];
const next = renderedPts[i + 1];
const d1x = Math.sign(curr.x - prev.x);
const d1y = Math.sign(curr.y - prev.y);
const d2x = Math.sign(next.x - curr.x);
const d2y = Math.sign(next.y - curr.y);
// Keep only direction-change points (actual corners)
if (d1x !== d2x || d1y !== d2y) {
waypoints.push({ x: curr.x, y: curr.y });
}
}
return waypoints;
}
/**
* Build an SVG path string from an ordered list of rendered points (straight segments).
*/
export function renderedPointsToPath(pts: { x: number; y: number }[]): string {
if (pts.length < 2) return '';
return (
`M ${pts[0].x} ${pts[0].y}` +
pts.slice(1).map((p) => ` L ${p.x} ${p.y}`).join('')
);
}