bug: fixed an issue with the label and a bezier path with multiple points
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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)', () => {
|
describe('buildWaypointPath — bezier (default)', () => {
|
||||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||||
@@ -173,3 +173,22 @@ describe('findInsertIndex', () => {
|
|||||||
expect(idx).toBe(2)
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
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']
|
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)
|
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
: autoPath
|
: autoPath
|
||||||
|
|
||||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
const labelPosition = hasWaypoints
|
||||||
const midY = (sourceY + targetY) / 2
|
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
|
: { x: labelX, y: (sourceY + targetY) / 2 }
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
const edgeColors = theme.colors.edgeColors
|
const edgeColors = theme.colors.edgeColors
|
||||||
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: theme.colors.edgeLabelColor,
|
color: theme.colors.edgeLabelColor,
|
||||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
|
|||||||
@@ -72,6 +72,172 @@ export function buildWaypointPath(
|
|||||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
|
||||||
|
return {
|
||||||
|
x: a.x + (b.x - a.x) * t,
|
||||||
|
y: a.y + (b.y - a.y) * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
|
||||||
|
const mt = 1 - t
|
||||||
|
return {
|
||||||
|
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
|
||||||
|
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
|
||||||
|
const mt = 1 - t
|
||||||
|
return {
|
||||||
|
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
|
||||||
|
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateLength(pointAt: (t: number) => 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 ──────────────────────────────────────────────────────────────
|
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user