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 ? (