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 ────────────────────────────────────────────────────────────── /**