From 9e8bab5deccced120a200109bb9fe20651e0b393 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 8 Apr 2026 22:42:18 +0200 Subject: [PATCH] feat: add interactive edge waypoints with smooth path editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drag waypoints to reshape edges; double-click a waypoint to remove it - + handles at segment midpoints to insert new waypoints - Bezier style: catmull-rom smooth curves through waypoints - Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px) - First + handle biased to source axis for perpendicular node exit - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously - Clear path button in EdgeModal when waypoints exist - Waypoints serialised/deserialised with canvas state --- frontend/src/App.tsx | 8 + .../edges/__tests__/waypointUtils.test.ts | 175 +++++++++++++ .../src/components/canvas/edges/index.tsx | 244 +++++++++++++++++- .../components/canvas/edges/waypointUtils.ts | 167 ++++++++++++ frontend/src/components/modals/EdgeModal.tsx | 13 +- .../modals/__tests__/EdgeModal.test.tsx | 51 ++++ frontend/src/types/index.ts | 6 + .../utils/__tests__/canvasSerializer.test.ts | 30 +++ frontend/src/utils/canvasSerializer.ts | 4 +- 9 files changed, 687 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts create mode 100644 frontend/src/components/canvas/edges/waypointUtils.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54dbfb5..a36b9d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -357,6 +357,13 @@ export default function App() { setEditEdgeId(null) }, [editEdgeId, deleteEdge, snapshotHistory]) + const handleClearWaypoints = useCallback(() => { + if (!editEdgeId) return + snapshotHistory() + updateEdge(editEdgeId, { waypoints: [] }) + setEditEdgeId(null) + }, [editEdgeId, updateEdge, snapshotHistory]) + const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null @@ -446,6 +453,7 @@ export default function App() { onClose={() => setEditEdgeId(null)} onSubmit={handleEdgeUpdate} onDelete={handleEdgeDelete} + onClearWaypoints={handleClearWaypoints} initial={editEdge?.data} title="Edit Link" /> diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts new file mode 100644 index 0000000..a42a943 --- /dev/null +++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from 'vitest' +import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' + +describe('buildWaypointPath — bezier (default)', () => { + it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { + // With only 2 pts (src + target), catmull-rom = cubic bezier + const path = buildWaypointPath(0, 0, [], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + }) + + it('routes through a single waypoint with smooth curve', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + // Should not be a straight polyline + expect(path).not.toContain(' L ') + }) + + it('routes through multiple waypoints', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100) + expect(path).toMatch(/^M 0 0 C/) + }) +}) + +describe('buildWaypointPath — smooth style', () => { + it('builds a direct straight line with no waypoints (no bend)', () => { + // Only 2 points → no intermediate vertex → no rounding needed + expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100') + }) + + it('routes through a single waypoint with straight lines (no intermediate bend)', () => { + // 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth') + // Should start at source and end at target + expect(path).toMatch(/^M 0 0/) + expect(path).toMatch(/100 100$/) + // Should contain a quadratic bezier at the waypoint corner + expect(path).toContain('Q') + }) + + it('routes through multiple waypoints with rounded corners', () => { + const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') + expect(path).toMatch(/^M 0 0/) + expect(path).toMatch(/100 100$/) + expect(path).toContain('Q') + }) + + it('does not round corners when segment is too short (r clamped to 0)', () => { + // Adjacent waypoints very close together — r → 0, falls back to L + const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth') + expect(path).toMatch(/^M 0 0/) + }) +}) + +describe('snap45', () => { + // Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px) + it('snaps horizontal direction when close (deviation < threshold)', () => { + // (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps + const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 }) + expect(r.y).toBe(0) + expect(r.x).toBeGreaterThan(0) + }) + + it('snaps vertical direction when close', () => { + const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 }) + expect(r.x).toBe(0) + expect(r.y).toBeGreaterThan(0) + }) + + it('snaps 45° diagonal when close', () => { + // (80, 83) — nearly 45°, deviation ≈ 2px → snaps + const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 }) + expect(r.x).toBe(r.y) + }) + + it('does NOT snap when deviation exceeds threshold', () => { + // (100, 40) — deviation from 0° is ~40px > 15 → no snap + const pos = { x: 100, y: 40 } + const r = snap45({ x: 0, y: 0 }, pos) + expect(r).toEqual(pos) + }) + + it('returns pos unchanged when distance < 1', () => { + const pos = { x: 5, y: 5 } + expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos) + }) + + it('preserves distance from origin when snapping', () => { + const from = { x: 0, y: 0 } + const pos = { x: 100, y: 3 } // close to horizontal + const r = snap45(from, pos) + const origDist = Math.hypot(pos.x - from.x, pos.y - from.y) + const snapDist = Math.hypot(r.x - from.x, r.y - from.y) + expect(snapDist).toBeCloseTo(origDist, 0) + }) +}) + +describe('snap45both', () => { + it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => { + // prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both + const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 }) + // Result must be on a 45°-ray from (0,0) + const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) + expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) + // Result must be on a 45°-ray from (100,100) + const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4) + expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) + }) + + it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => { + // prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each) + // pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100) + const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 }) + const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) + expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) + const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4) + expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) + }) + + it('returns raw pos when beyond threshold', () => { + // pos=(100,80) is 20px from nearest intersection (100,100) → no snap + const pos = { x: 100, y: 80 } + const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos) + expect(r).toEqual(pos) + }) + + it('falls back gracefully when prev === next', () => { + // No valid intersection → fallback to snap45 + const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 }) + expect(r).toBeDefined() + }) +}) + +describe('distToSegment', () => { + it('returns 0 when point is on the segment', () => { + expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0) + }) + + it('returns perpendicular distance when point is beside segment', () => { + expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10) + }) + + it('returns distance to nearest endpoint when point is past the segment', () => { + expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100) + }) + + it('handles zero-length segment (a === b)', () => { + expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5) + }) +}) + +describe('findInsertIndex', () => { + it('returns 0 when there are no waypoints (only one segment)', () => { + expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0) + }) + + it('inserts before first waypoint when click is on first segment', () => { + const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 }) + expect(idx).toBe(0) + }) + + it('inserts after first waypoint when click is on second segment', () => { + const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 }) + expect(idx).toBe(1) + }) + + it('picks the closest segment among multiple', () => { + const idx = findInsertIndex( + 0, 0, + [{ x: 100, y: 0 }, { x: 100, y: 100 }], + 200, 100, + { x: 150, y: 105 }, + ) + expect(idx).toBe(2) + }) +}) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 9917d25..23ac77c 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -1,15 +1,19 @@ +import { useCallback } from 'react' import { BaseEdge, EdgeLabelRenderer, getBezierPath, getSmoothStepPath, + useReactFlow, useStore, type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType } from '@/types' +import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' +import { buildWaypointPath, snap45, snap45both } from './waypointUtils' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } +// ── Waypoint drag handle ───────────────────────────────────────────────────── + +interface WaypointHandleProps { + edgeId: string + index: number + waypoint: Waypoint + waypoints: Waypoint[] + color: string + pathStyle?: string + prevPoint: Waypoint + nextPoint: Waypoint +} + +function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) { + const { screenToFlowPosition } = useReactFlow() + const updateEdge = useCanvasStore((s) => s.updateEdge) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (e.buttons !== 1) return + let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY }) + if (pathStyle === 'smooth') { + // Find the intersection of 45°-rays from both adjacent points so that + // ALL segments (prev→this and this→next) snap to 45° simultaneously. + pos = snap45both(prevPoint, nextPoint, pos) + } + const next = [...waypoints] + next[index] = pos + updateEdge(edgeId, { waypoints: next }) + }, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint]) + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + e.currentTarget.releasePointerCapture(e.pointerId) + }, []) + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) }) + }, [edgeId, waypoints, index, updateEdge]) + + return ( +
+ ) +} + +// ── Add waypoint handle (+ button at segment midpoints) ────────────────────── + +interface AddWaypointHandleProps { + edgeId: string + insertIndex: number + x: number + y: number + waypoints: Waypoint[] + color: string + pathStyle?: string + prevPoint: Waypoint +} + +function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) { + const updateEdge = useCanvasStore((s) => s.updateEdge) + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + let pos = { x, y } + if (pathStyle === 'smooth') pos = snap45(prevPoint, pos) + const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)] + updateEdge(edgeId, { waypoints: next }) + }, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint]) + + return ( +
+ + +
+ ) +} + +// ── Segment midpoints ──────────────────────────────────────────────────────── + +/** + * Compute + handle positions for each path segment. + * For smooth style: bias the first + handle to the source handle axis and the + * last + handle to the target handle axis, so clicking always gives a clean + * perpendicular exit/entry (no diagonal guesswork near the nodes). + */ +function segmentMidpoints( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle?: string, + sourcePosition?: string, +): { x: number; y: number; insertIndex: number }[] { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + const isSmooth = pathStyle === 'smooth' + + return pts.slice(0, -1).map((a, i) => { + const b = pts[i + 1] + let mx = (a.x + b.x) / 2 + const my = (a.y + b.y) / 2 + + // For smooth style with no existing waypoints, bias the single + handle onto + // the source handle axis so clicking it creates a perpendicular exit. + // Only applies to bottom/top handles (vertical exits) and only when the edge + // has no waypoints yet — once waypoints exist, all + handles stay at the + // real segment midpoint so they remain visually on the edge. + if (isSmooth && i === 0 && pts.length === 2) { + const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top' + if (vertSrc) mx = a.x // same X as source → + sits directly below/above node + } + + return { x: mx, y: my, insertIndex: i } + }) +} + +// ── Main edge component ────────────────────────────────────────────────────── + export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] @@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t const targetType = useStore((s) => s.nodeLookup.get(target)?.type) const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' + const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 + ? data.waypoints as Waypoint[] + : [] + + const hasWaypoints = waypoints.length > 0 + + const pathStyle = data?.path_style as string | undefined + const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } - const [edgePath, labelX] = data?.path_style === 'smooth' + const [autoPath, labelX] = pathStyle === 'smooth' ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) : getBezierPath(pathArgs) + const edgePath = hasWaypoints + ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) + : autoPath + + const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX + const midY = (sourceY + targetY) / 2 + const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors @@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t } const customColor = data?.custom_color as string | undefined + const strokeColor: string = selected + ? theme.colors.edgeSelectedColor + : customColor + ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) + const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), @@ -50,16 +233,20 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } - // Normalize animated value — supports legacy boolean (true → 'snake') const animMode: 'none' | 'snake' | 'flow' = data?.animated === true || data?.animated === 'snake' ? 'snake' : data?.animated === 'flow' ? 'flow' : 'none' const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) + const midpoints = selected + ? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition) + : [] + return ( <> - + + {animMode === 'snake' && ( )} - {data?.label && ( - + + {data?.label && (
{data.label as string}
-
- )} + )} + + {/* Existing waypoint drag handles */} + {selected && waypoints.map((wp, idx) => { + const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1] + const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1] + return ( + + ) + })} + + {/* + handles at segment midpoints to add new waypoints */} + {selected && midpoints.map((mp) => { + const prevPoint = mp.insertIndex === 0 + ? { x: sourceX, y: sourceY } + : waypoints[mp.insertIndex - 1] + return ( + + ) + })} +
) } diff --git a/frontend/src/components/canvas/edges/waypointUtils.ts b/frontend/src/components/canvas/edges/waypointUtils.ts new file mode 100644 index 0000000..9dc5066 --- /dev/null +++ b/frontend/src/components/canvas/edges/waypointUtils.ts @@ -0,0 +1,167 @@ +import type { Waypoint } from '@/types' + +// ── Path builders ───────────────────────────────────────────────────────────── + +/** Catmull-Rom → cubic bezier for smooth curves through waypoints */ +function buildCatmullRomPath(pts: Waypoint[]): string { + if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}` + let d = `M ${pts[0].x} ${pts[0].y}` + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)] + const p1 = pts[i] + const p2 = pts[i + 1] + const p3 = pts[Math.min(i + 2, pts.length - 1)] + const cp1x = p1.x + (p2.x - p0.x) / 6 + const cp1y = p1.y + (p2.y - p0.y) / 6 + const cp2x = p2.x - (p3.x - p1.x) / 6 + const cp2y = p2.y - (p3.y - p1.y) / 6 + d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}` + } + return d +} + +/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */ +function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string { + if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}` + if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}` + + let d = `M ${pts[0].x} ${pts[0].y}` + + for (let i = 1; i < pts.length - 1; i++) { + const prev = pts[i - 1] + const curr = pts[i] + const next = pts[i + 1] + + const dx1 = curr.x - prev.x + const dy1 = curr.y - prev.y + const len1 = Math.hypot(dx1, dy1) + + const dx2 = next.x - curr.x + const dy2 = next.y - curr.y + const len2 = Math.hypot(dx2, dy2) + + if (len1 < 1 || len2 < 1) { + d += ` L ${curr.x} ${curr.y}` + continue + } + + const r = Math.min(radius, len1 / 2, len2 / 2) + + // Approach point (on segment prev→curr, r units before corner) + const bx = curr.x - (dx1 / len1) * r + const by = curr.y - (dy1 / len1) * r + + // Departure point (on segment curr→next, r units after corner) + const ax = curr.x + (dx2 / len2) * r + const ay = curr.y + (dy2 / len2) * r + + d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}` + } + + d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}` + return d +} + +export function buildWaypointPath( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + pathStyle: string = 'bezier', +): string { + const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts) +} + +// ── 45° snapping ────────────────────────────────────────────────────────────── + +/** + * Snap `pos` to the nearest 45°-multiple direction from `from`. + * Only snaps when within SNAP_THRESHOLD px of a 45° position. + */ +export function snap45(from: Waypoint, pos: Waypoint): Waypoint { + const dx = pos.x - from.x + const dy = pos.y - from.y + const dist = Math.hypot(dx, dy) + if (dist < 1) return pos + const angle = Math.atan2(dy, dx) + const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) + const candidate = { + x: Math.round(from.x + dist * Math.cos(snapped)), + y: Math.round(from.y + dist * Math.sin(snapped)), + } + const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y) + return deviation <= SNAP_THRESHOLD ? candidate : pos +} + +/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */ +const SNAP_THRESHOLD = 15 + +/** + * Find the position closest to `pos` that lies simultaneously on a 45°-ray + * from `prev` AND on a 45°-ray from `next`. + * + * Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px — + * outside that zone the raw drag position is returned, allowing free placement. + */ +export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint { + let best: Waypoint | null = null + let bestDist = Infinity + + for (let i = 0; i < 8; i++) { + const a1 = i * Math.PI / 4 + const c1 = Math.cos(a1), s1 = Math.sin(a1) + + for (let j = 0; j < 8; j++) { + const a2 = j * Math.PI / 4 + const c2 = Math.cos(a2), s2 = Math.sin(a2) + + const dx = next.x - prev.x + const dy = next.y - prev.y + const det = -c1 * s2 + c2 * s1 + if (Math.abs(det) < 1e-6) continue + + const t = (-dx * s2 + c2 * dy) / det + const s = (c1 * dy - s1 * dx) / det + if (t < -1e-6 || s < -1e-6) continue + + const ix = prev.x + t * c1 + const iy = prev.y + t * s1 + const d = Math.hypot(ix - pos.x, iy - pos.y) + if (d < bestDist) { + bestDist = d + best = { x: Math.round(ix), y: Math.round(iy) } + } + } + } + + // Only snap if close enough — otherwise let the waypoint move freely + if (best === null || bestDist > SNAP_THRESHOLD) return pos + return best +} + +// ── Geometry helpers ────────────────────────────────────────────────────────── + +export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number { + const dx = b.x - a.x + const dy = b.y - a.y + const lenSq = dx * dx + dy * dy + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y) + const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)) + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)) +} + +export function findInsertIndex( + sourceX: number, sourceY: number, + waypoints: Waypoint[], + targetX: number, targetY: number, + point: Waypoint, +): number { + const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }] + let minDist = Infinity + let best = 0 + for (let i = 0; i < allPts.length - 1; i++) { + const d = distToSegment(point, allPts[i], allPts[i + 1]) + if (d < minDist) { minDist = d; best = i } + } + return best +} diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 95d07b7..f2f95ea 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -23,11 +23,12 @@ interface EdgeModalProps { onClose: () => void onSubmit: (data: EdgeData) => void onDelete?: () => void + onClearWaypoints?: () => void initial?: Partial title?: string } -export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) { +export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) { const [type, setType] = useState(initial?.type ?? 'ethernet') const [label, setLabel] = useState(initial?.label ?? '') const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '') @@ -175,6 +176,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
+ {onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && ( + + )} +
{onDelete ? (