diff --git a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts
index a42a943..a9dadf5 100644
--- a/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts
+++ b/frontend/src/components/canvas/edges/__tests__/waypointUtils.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
+import { buildWaypointPath, distToSegment, findInsertIndex, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils'
describe('buildWaypointPath — bezier (default)', () => {
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
@@ -173,3 +173,22 @@ describe('findInsertIndex', () => {
expect(idx).toBe(2)
})
})
+
+describe('getWaypointLabelPosition', () => {
+ it('uses the routed midpoint for a symmetric bezier waypoint path', () => {
+ const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0)
+ expect(point.x).toBeCloseTo(50, 0)
+ expect(point.y).toBeCloseTo(100, 0)
+ })
+
+ it('uses the routed midpoint for a smooth waypoint path', () => {
+ const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
+ expect(point.x).toBeCloseTo(50, 0)
+ expect(point.y).toBeCloseTo(50, 0)
+ })
+
+ it('falls back to the source point when the path is degenerate', () => {
+ const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth')
+ expect(point).toEqual({ x: 10, y: 20 })
+ })
+})
diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx
index fcb05ba..9ed771f 100644
--- a/frontend/src/components/canvas/edges/index.tsx
+++ b/frontend/src/components/canvas/edges/index.tsx
@@ -13,7 +13,7 @@ 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'
+import { buildWaypointPath, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: autoPath
- const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
- const midY = (sourceY + targetY) / 2
+ const labelPosition = hasWaypoints
+ ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
+ : { x: labelX, y: (sourceY + targetY) / 2 }
const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
Waypoint, steps = 24): number {
+ let length = 0
+ let prev = pointAt(0)
+
+ for (let step = 1; step <= steps; step++) {
+ const next = pointAt(step / steps)
+ length += Math.hypot(next.x - prev.x, next.y - prev.y)
+ prev = next
+ }
+
+ return length
+}
+
+type PathSegment = {
+ length: number
+ pointAt: (t: number) => Waypoint
+}
+
+function buildBezierSegments(pts: Waypoint[]): PathSegment[] {
+ if (pts.length < 2) return []
+
+ return pts.slice(0, -1).map((_, 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 cp1 = {
+ x: p1.x + (p2.x - p0.x) / 6,
+ y: p1.y + (p2.y - p0.y) / 6,
+ }
+ const cp2 = {
+ x: p2.x - (p3.x - p1.x) / 6,
+ y: p2.y - (p3.y - p1.y) / 6,
+ }
+ const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t)
+
+ return {
+ length: approximateLength(pointAt),
+ pointAt,
+ }
+ })
+}
+
+function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] {
+ if (pts.length < 2) return []
+ if (pts.length === 2) {
+ const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t)
+ return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }]
+ }
+
+ const segments: PathSegment[] = []
+ let cursor = pts[0]
+
+ 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) {
+ const start = { x: cursor.x, y: cursor.y }
+ const end = { x: curr.x, y: curr.y }
+ const lineToCurr = (t: number) => interpolateLine(start, end, t)
+ segments.push({
+ length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y),
+ pointAt: lineToCurr,
+ })
+ cursor = curr
+ continue
+ }
+
+ const r = Math.min(radius, len1 / 2, len2 / 2)
+ const before = {
+ x: curr.x - (dx1 / len1) * r,
+ y: curr.y - (dy1 / len1) * r,
+ }
+ const after = {
+ x: curr.x + (dx2 / len2) * r,
+ y: curr.y + (dy2 / len2) * r,
+ }
+
+ const lineStart = { x: cursor.x, y: cursor.y }
+ const lineEnd = { x: before.x, y: before.y }
+ const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t)
+ segments.push({
+ length: Math.hypot(before.x - cursor.x, before.y - cursor.y),
+ pointAt: lineToBefore,
+ })
+
+ const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t)
+ segments.push({
+ length: approximateLength(curveAroundCorner),
+ pointAt: curveAroundCorner,
+ })
+
+ cursor = after
+ }
+
+ const targetStart = { x: cursor.x, y: cursor.y }
+ const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
+ const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t)
+ segments.push({
+ length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y),
+ pointAt: lineToTarget,
+ })
+
+ return segments
+}
+
+export function getWaypointLabelPosition(
+ sourceX: number, sourceY: number,
+ waypoints: Waypoint[],
+ targetX: number, targetY: number,
+ pathStyle: string = 'bezier',
+): Waypoint {
+ const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
+ const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts)
+
+ if (segments.length === 0) return pts[0]
+
+ const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0)
+ if (totalLength <= 0) return pts[Math.floor(pts.length / 2)]
+
+ let remaining = totalLength / 2
+ for (const segment of segments) {
+ if (remaining <= segment.length) {
+ const t = segment.length === 0 ? 0 : remaining / segment.length
+ return segment.pointAt(t)
+ }
+ remaining -= segment.length
+ }
+
+ const lastSegment = segments[segments.length - 1]
+ return lastSegment.pointAt(1)
+}
+
// ── 45° snapping ──────────────────────────────────────────────────────────────
/**