From 3f63ed964bf9cbd8975ec464812b84f7496beab8 Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Mon, 24 Aug 2026 11:43:00 +0200 Subject: [PATCH 1/2] fix(edges): align arrow markers with the stroke they terminate Arrowheads pointed somewhere the edge did not, and the stroke poked out of the triangle's side. Two independent causes, both about the path tangent an `orient="auto"` SVG marker reads at its vertex. 1. `getBezierPath` whips into the target handle's normal over the final few pixels: on a real template edge the tangent turns 42 degrees within the last 12px. The marker takes the exact endpoint tangent, and `refX` puts the tip on the endpoint, so the whole triangle sits over curve running a different way. Reserve a straight lead as long as the arrow marker at both endpoints, so the stroke stops where the arrowhead begins and both share one direction. The leads are tangent-continuous with the curve because `getBezierPath` already leaves and enters along the same handle normals. Skipped on edges shorter than 48px. 2. `buildCurvedPath` anchors smooth splines by duplicating the first and last point, which makes d3 emit zero-length commands and cubics whose control points collapse onto the endpoint. The tangent is then zero and browsers fall back to 0 degrees, so ELK-routed and manual-waypoint edges pointed right no matter which way they ran. `normalizeMarkerTangents` strips the degenerate commands and restores a readable tangent; a fully collapsed cubic traces its own chord, so it becomes a `lineTo`. Endpoints are untouched and `basis` output now matches d3's un-anchored tail. --- .../curvePathMarkerTangents.test.ts | 73 +++++++ .../custom-edge/curvePathMarkerTangents.ts | 193 ++++++++++++++++++ src/components/custom-edge/edgeCurve.test.ts | 41 ++++ src/components/custom-edge/edgeCurve.ts | 8 +- src/components/custom-edge/pathUtils.test.ts | 69 +++++++ src/components/custom-edge/pathUtils.ts | 72 ++++++- 6 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 src/components/custom-edge/curvePathMarkerTangents.test.ts create mode 100644 src/components/custom-edge/curvePathMarkerTangents.ts diff --git a/src/components/custom-edge/curvePathMarkerTangents.test.ts b/src/components/custom-edge/curvePathMarkerTangents.test.ts new file mode 100644 index 00000000..7231ffa4 --- /dev/null +++ b/src/components/custom-edge/curvePathMarkerTangents.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeMarkerTangents, + readPathEndTangent, + readPathStartTangent, +} from './curvePathMarkerTangents'; + +describe('curvePathMarkerTangents', () => { + // Real d3 `curveBasis` output for the repo's duplicated-endpoint anchoring. + const ANCHORED_BASIS_PATH = + 'M0,0L0,0C0,0,0,0,0,0C0,0,0,0,6.667,10C13.333,20,26.667,40,46.667,73.333' + + 'C66.667,106.667,93.333,153.333,120,180C146.667,206.667,173.333,213.333,186.667,216.667' + + 'C200,220,200,220,200,220C200,220,200,220,200,220L200,220'; + + it('reports a zero end tangent for the anchored path (the marker bug)', () => { + expect(readPathEndTangent(ANCHORED_BASIS_PATH)).toEqual({ x: 0, y: 0 }); + }); + + it('gives the anchored path a non-zero end tangent along the incoming chord', () => { + const tangent = readPathEndTangent(normalizeMarkerTangents(ANCHORED_BASIS_PATH)); + expect(tangent).not.toBeNull(); + expect(Math.hypot(tangent!.x, tangent!.y)).toBeGreaterThan(0); + // Incoming chord 186.667,216.667 -> 200,220 points down-right at ~14deg. + const angle = (Math.atan2(tangent!.y, tangent!.x) * 180) / Math.PI; + expect(angle).toBeGreaterThan(5); + expect(angle).toBeLessThan(25); + }); + + it('gives the anchored path a non-zero start tangent', () => { + const tangent = readPathStartTangent(normalizeMarkerTangents(ANCHORED_BASIS_PATH)); + expect(tangent).not.toBeNull(); + expect(Math.hypot(tangent!.x, tangent!.y)).toBeGreaterThan(0); + // Outgoing chord 0,0 -> 6.667,10 points down-right at ~56deg. + const angle = (Math.atan2(tangent!.y, tangent!.x) * 180) / Math.PI; + expect(angle).toBeGreaterThan(45); + expect(angle).toBeLessThan(70); + }); + + it('keeps the first and last point exactly where they were', () => { + const normalized = normalizeMarkerTangents(ANCHORED_BASIS_PATH); + expect(normalized.startsWith('M0,0')).toBe(true); + expect(normalized.endsWith('200,220')).toBe(true); + }); + + it('drops the zero-length head and tail commands', () => { + const normalized = normalizeMarkerTangents(ANCHORED_BASIS_PATH); + expect(normalized).not.toContain('C0,0,0,0,0,0'); + expect(normalized).not.toContain('C200,220,200,220,200,220'); + }); + + it('repairs a cubic whose second control point sits on the endpoint', () => { + // Real d3 `curveCatmullRom` tail: c2 === endpoint, so the tangent collapses. + const path = 'M0,0C0,0,24.863,35.508,40,60C143.499,217.802,200,220,200,220'; + expect(readPathEndTangent(path)).toEqual({ x: 0, y: 0 }); + + const tangent = readPathEndTangent(normalizeMarkerTangents(path)); + expect(Math.hypot(tangent!.x, tangent!.y)).toBeGreaterThan(0); + // Limiting tangent follows c1 -> endpoint: 143.499,217.802 -> 200,220. + const angle = (Math.atan2(tangent!.y, tangent!.x) * 180) / Math.PI; + expect(angle).toBeCloseTo(2.226, 1); + }); + + it('leaves an already well-formed path untouched', () => { + const path = 'M0,0C13.333,19.444,26.667,38.889,40,60C66.667,102.222,93.333,186.667,120,200'; + expect(normalizeMarkerTangents(path)).toBe(path); + }); + + it('returns the input unchanged when it is not a simple M/L/C path', () => { + expect(normalizeMarkerTangents('M0,0A10,10 0 0 1 20,20')).toBe('M0,0A10,10 0 0 1 20,20'); + expect(normalizeMarkerTangents('')).toBe(''); + expect(normalizeMarkerTangents('M0,0')).toBe('M0,0'); + }); +}); diff --git a/src/components/custom-edge/curvePathMarkerTangents.ts b/src/components/custom-edge/curvePathMarkerTangents.ts new file mode 100644 index 00000000..5a35dcac --- /dev/null +++ b/src/components/custom-edge/curvePathMarkerTangents.ts @@ -0,0 +1,193 @@ +/** + * SVG `marker-end` / `marker-start` with `orient="auto"` take their angle from the + * path tangent at the vertex they sit on. d3's curve generators — and the duplicated + * endpoint anchoring in `buildCurvedPath` — emit zero-length commands and cubics whose + * control points collapse onto the endpoint, which makes that tangent zero. Browsers + * then fall back to 0deg, so arrowheads point right no matter which way the edge runs. + * + * These helpers rewrite such a path into a geometrically equivalent one with a + * well-defined tangent at both ends. + */ + +interface Point { + x: number; + y: number; +} + +type CommandType = 'M' | 'L' | 'C'; + +interface Command { + type: CommandType; + /** `C`: [control1, control2, end]. `M` / `L`: [end]. */ + points: Point[]; +} + +const COMMAND_PATTERN = /([A-Za-z])([^A-Za-z]*)/g; +const COORDS_PER_COMMAND: Record = { M: 1, L: 1, C: 3 }; +/** Fraction of the incoming handle kept when pulling a collapsed control point off the endpoint. */ +const HANDLE_PULL = 0.02; +const EPSILON = 1e-6; + +function samePoint(a: Point, b: Point): boolean { + return Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON; +} + +function parsePath(path: string): Command[] | null { + const trimmed = path.trim(); + if (!trimmed.startsWith('M')) return null; + + const commands: Command[] = []; + COMMAND_PATTERN.lastIndex = 0; + let match = COMMAND_PATTERN.exec(trimmed); + let consumed = 0; + + while (match) { + const type = match[1] as CommandType; + if (type !== 'M' && type !== 'L' && type !== 'C') return null; + + const numbers = match[2] + .split(/[,\s]+/) + .filter((value) => value.length > 0) + .map(Number); + if (numbers.some((value) => !Number.isFinite(value))) return null; + if (numbers.length !== COORDS_PER_COMMAND[type] * 2) return null; + + const points: Point[] = []; + for (let i = 0; i < numbers.length; i += 2) { + points.push({ x: numbers[i], y: numbers[i + 1] }); + } + commands.push({ type, points }); + + consumed = match.index + match[0].length; + match = COMMAND_PATTERN.exec(trimmed); + } + + if (consumed !== trimmed.length) return null; + if (commands.length === 0 || commands[0].type !== 'M') return null; + return commands; +} + +function endOf(command: Command): Point { + return command.points[command.points.length - 1]; +} + +/** A command that neither moves nor draws — safe to delete. */ +function isNoOp(command: Command, start: Point): boolean { + if (command.type === 'M') return false; + return command.points.every((point) => samePoint(point, start)); +} + +function formatNumber(value: number): string { + const rounded = Math.round(value * 1000) / 1000; + return String(Object.is(rounded, -0) ? 0 : rounded); +} + +function serialize(commands: Command[]): string { + return commands + .map( + (command) => + command.type + + command.points.map((point) => `${formatNumber(point.x)},${formatNumber(point.y)}`).join(',') + ) + .join(''); +} + +function lerp(from: Point, to: Point, t: number): Point { + return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }; +} + +/** + * A cubic whose two control points both sit on one of its endpoints traces exactly the + * straight chord between its endpoints, so it can be replaced by a `lineTo`. + */ +function isCollapsedCubic(command: Command, start: Point): boolean { + if (command.type !== 'C') return false; + const [c1, c2, end] = command.points; + if (!samePoint(c1, c2)) return false; + return samePoint(c1, start) || samePoint(c1, end); +} + +function repairTail(command: Command, start: Point): Command { + if (command.type !== 'C') return command; + if (isCollapsedCubic(command, start)) return { type: 'L', points: [endOf(command)] }; + + const [c1, c2, end] = command.points; + if (!samePoint(c2, end)) return command; + + // As t -> 1 the tangent direction is (end - c1); pull c2 back along it so the + // browser can read that direction off the last control point. + const reference = samePoint(c1, end) ? start : c1; + if (samePoint(reference, end)) return command; + return { type: 'C', points: [c1, lerp(end, reference, HANDLE_PULL), end] }; +} + +function repairHead(command: Command, start: Point): Command { + if (command.type !== 'C') return command; + if (isCollapsedCubic(command, start)) return { type: 'L', points: [endOf(command)] }; + + const [c1, c2, end] = command.points; + if (!samePoint(c1, start)) return command; + + const reference = samePoint(c2, start) ? end : c2; + if (samePoint(reference, start)) return command; + return { type: 'C', points: [lerp(start, reference, HANDLE_PULL), c2, end] }; +} + +/** + * Strip degenerate head/tail commands and give the first and last drawing command a + * readable tangent, so `orient="auto"` markers follow the edge direction. + * Returns the input untouched when it is not a plain M/L/C path. + */ +export function normalizeMarkerTangents(path: string): string { + const commands = parsePath(path); + if (!commands) return path; + + const kept: Command[] = [commands[0]]; + let cursor = endOf(commands[0]); + for (let i = 1; i < commands.length; i += 1) { + const command = commands[i]; + if (isNoOp(command, cursor)) continue; + kept.push(command); + cursor = endOf(command); + } + if (kept.length < 2) return path; + + const startPoints: Point[] = []; + let running = endOf(kept[0]); + for (let i = 1; i < kept.length; i += 1) { + startPoints[i] = running; + running = endOf(kept[i]); + } + + kept[1] = repairHead(kept[1], startPoints[1]); + const last = kept.length - 1; + kept[last] = repairTail(kept[last], startPoints[last]); + + return serialize(kept); +} + +function tangentAt(command: Command, start: Point, at: 'start' | 'end'): Point | null { + if (command.type === 'M') return null; + if (command.type === 'L') { + const end = endOf(command); + return { x: end.x - start.x, y: end.y - start.y }; + } + const [c1, c2, end] = command.points; + if (at === 'start') return { x: c1.x - start.x, y: c1.y - start.y }; + return { x: end.x - c2.x, y: end.y - c2.y }; +} + +/** Tangent a browser reads for `marker-end` — zero means the arrowhead angle collapses. */ +export function readPathEndTangent(path: string): Point | null { + const commands = parsePath(path); + if (!commands || commands.length < 2) return null; + const last = commands.length - 1; + return tangentAt(commands[last], endOf(commands[last - 1]), 'end'); +} + +/** Tangent a browser reads for `marker-start`. */ +export function readPathStartTangent(path: string): Point | null { + const commands = parsePath(path); + if (!commands || commands.length < 2) return null; + return tangentAt(commands[1], endOf(commands[0]), 'start'); +} diff --git a/src/components/custom-edge/edgeCurve.test.ts b/src/components/custom-edge/edgeCurve.test.ts index b49f8041..371290c0 100644 --- a/src/components/custom-edge/edgeCurve.test.ts +++ b/src/components/custom-edge/edgeCurve.test.ts @@ -6,6 +6,7 @@ import { isOrthogonalStepCurve, isSmoothCurve, } from './edgeCurve'; +import { readPathEndTangent, readPathStartTangent } from './curvePathMarkerTangents'; describe('edgeCurve', () => { it('classifies smooth and orthogonal curves correctly', () => { @@ -53,6 +54,46 @@ describe('edgeCurve', () => { expect(curveFromLegacyVariant('straight')).toBe('linear'); }); + it('leaves every curve with a readable tangent for orient="auto" markers', () => { + const points = [ + { x: 0, y: 0 }, + { x: 40, y: 60 }, + { x: 120, y: 200 }, + { x: 200, y: 220 }, + ]; + const curves = [ + 'basis', 'linear', 'step', 'stepBefore', 'stepAfter', + 'monotoneX', 'monotoneY', 'natural', 'cardinal', 'catmullRom', + 'bumpX', 'bumpY', + ] as const; + + for (const curve of curves) { + const path = buildCurvedPath(points, curve); + expect(path, curve).not.toBeNull(); + + const end = readPathEndTangent(path!); + expect(end, curve).not.toBeNull(); + expect(Math.hypot(end!.x, end!.y), `${curve} end tangent`).toBeGreaterThan(0); + + const start = readPathStartTangent(path!); + expect(start, curve).not.toBeNull(); + expect(Math.hypot(start!.x, start!.y), `${curve} start tangent`).toBeGreaterThan(0); + } + }); + + it('keeps the exact source and target endpoints for smooth curves', () => { + const path = buildCurvedPath( + [ + { x: 12, y: 34 }, + { x: 90, y: 10 }, + { x: 178, y: 96 }, + ], + 'basis' + ); + expect(path!.startsWith('M12,34')).toBe(true); + expect(path!.endsWith('178,96')).toBe(true); + }); + it('produces a linear path that traces every waypoint exactly', () => { const path = buildCurvedPath( [ diff --git a/src/components/custom-edge/edgeCurve.ts b/src/components/custom-edge/edgeCurve.ts index 363a4589..ca854e50 100644 --- a/src/components/custom-edge/edgeCurve.ts +++ b/src/components/custom-edge/edgeCurve.ts @@ -14,6 +14,7 @@ import { line as d3Line, type CurveFactory, } from 'd3-shape'; +import { normalizeMarkerTangents } from './curvePathMarkerTangents'; export type EdgeCurve = | 'basis' @@ -87,6 +88,10 @@ function dedupeConsecutive(points: Point[]): Point[] { * For smooth curves we anchor the endpoints by duplicating them so the resulting * spline passes through the actual source/target (B-spline / curveBasis otherwise * floats the endpoints inward). + * + * The result is run through `normalizeMarkerTangents` because that anchoring — and + * d3's own endpoint handling for cardinal/catmullRom — leaves degenerate commands at + * both ends, which collapses the tangent `orient="auto"` arrow markers rely on. */ export function buildCurvedPath(points: Point[], curve: EdgeCurve): string | null { if (curve === 'smoothstep') return null; // handled elsewhere via getSmoothStepPath @@ -99,7 +104,8 @@ export function buildCurvedPath(points: Point[], curve: EdgeCurve): string | nul : cleaned; const generator = DEFAULT_LINE_GENERATOR.curve(factory); - return generator(anchored); + const path = generator(anchored); + return path === null ? null : normalizeMarkerTangents(path); } const VALID_CURVES = new Set([ diff --git a/src/components/custom-edge/pathUtils.test.ts b/src/components/custom-edge/pathUtils.test.ts index 374ee0c3..55f3dd6d 100644 --- a/src/components/custom-edge/pathUtils.test.ts +++ b/src/components/custom-edge/pathUtils.test.ts @@ -1026,4 +1026,73 @@ describe('buildEdgePath', () => { expect(first.labelY).toBeLessThan(70); expect(first.labelY).toBeGreaterThan(60); }); + + describe('arrow lead', () => { + const BEZIER_NODES = [ + { id: 'src', position: { x: 0, y: 0 }, width: 0, height: 0 }, + { id: 'dst', position: { x: 400, y: 300 }, width: 0, height: 0 }, + ]; + + function buildBezier( + sourcePosition: Position, + targetPosition: Position, + targetX = 400, + targetY = 300 + ): string { + return buildEdgePath( + { + id: 'edge-1', + source: 'src', + target: 'dst', + sourceX: 0, + sourceY: 0, + targetX, + targetY, + sourcePosition, + targetPosition, + sourceHandleId: null, + targetHandleId: null, + }, + [{ id: 'edge-1', source: 'src', target: 'dst' }], + BEZIER_NODES, + 'bezier', + { curve: 'basis' } + ).edgePath; + } + + it('ends with a straight run as long as the arrow marker', () => { + const path = buildBezier(Position.Right, Position.Left); + + // Target handle is Left, so the arrow approaches along +x: the final segment must + // be the 12px straight lead ending exactly on the handle. + expect(path.endsWith('388,300 L400,300')).toBe(true); + }); + + it('starts with a straight run out of the source handle', () => { + const path = buildBezier(Position.Right, Position.Left); + + expect(path.startsWith('M0,0 L12,0 C')).toBe(true); + }); + + it('orients the lead along the handle normal for every side', () => { + expect(buildBezier(Position.Bottom, Position.Top).endsWith('L400,300')).toBe(true); + expect(buildBezier(Position.Bottom, Position.Top)).toContain('400,288'); + expect(buildBezier(Position.Top, Position.Bottom)).toContain('400,312'); + expect(buildBezier(Position.Left, Position.Right)).toContain('412,300'); + }); + + it('keeps the exact source and target endpoints', () => { + const path = buildBezier(Position.Right, Position.Left); + + expect(path.startsWith('M0,0 ')).toBe(true); + expect(path.endsWith(' L400,300')).toBe(true); + }); + + it('skips the lead on very short edges so it cannot dominate the path', () => { + const path = buildBezier(Position.Right, Position.Left, 30, 0); + + expect(path.startsWith('M0,0 C')).toBe(true); + expect(path).not.toContain('L12,0'); + }); + }); }); diff --git a/src/components/custom-edge/pathUtils.ts b/src/components/custom-edge/pathUtils.ts index 8b286fa9..132249ca 100644 --- a/src/components/custom-edge/pathUtils.ts +++ b/src/components/custom-edge/pathUtils.ts @@ -38,10 +38,70 @@ import { const EDGE_ROUTING_FAST_PATH_THRESHOLD = 600; +/** + * Length of straight path reserved at each endpoint, in flow units. Matches the + * standard arrow marker length so the arrowhead sits on a segment whose direction + * equals the stroke's — see `withEndpointArrowLead`. + */ +const ARROW_LEAD_PX = 12; +/** Below this endpoint distance the lead would dominate the edge, so it is skipped. */ +const MIN_LENGTH_FOR_ARROW_LEAD = ARROW_LEAD_PX * 4; + function isDecisionLikeShape(shape: string | undefined): boolean { return shape === 'diamond'; } +/** + * Cubic bezier with a straight lead reserved at both endpoints. + * + * `getBezierPath` alone whips into the handle normal over the last few pixels, so an + * `orient="auto"` arrow marker — which reads the exact tangent at the endpoint — ends up + * pointing somewhere the visible stroke is not, and the stroke pokes out of the + * arrowhead's side. Reserving a straight run as long as the arrowhead means the stroke + * stops where the arrowhead begins and both share one direction. + * + * The leads are tangent-continuous with the curve because `getBezierPath` already + * leaves and enters along the same handle normals, so no kink is introduced. + */ +function buildBezierPathWithArrowLeads( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + sourcePosition: Position, + targetPosition: Position, + curvature: number +): { path: string; labelX: number; labelY: number } { + const lead = Math.hypot(targetX - sourceX, targetY - sourceY) >= MIN_LENGTH_FOR_ARROW_LEAD + ? ARROW_LEAD_PX + : 0; + const curveSource = applyAnchorClearance({ x: sourceX, y: sourceY }, sourcePosition, lead); + const curveTarget = applyAnchorClearance({ x: targetX, y: targetY }, targetPosition, lead); + + const [bezierPath, labelX, labelY] = getBezierPath({ + sourceX: curveSource.x, + sourceY: curveSource.y, + sourcePosition, + targetX: curveTarget.x, + targetY: curveTarget.y, + targetPosition, + curvature, + }); + + const curveStart = lead > 0 ? bezierPath.indexOf('C') : -1; + if (curveStart < 0) { + return { path: bezierPath, labelX, labelY }; + } + + // Keep React Flow's own path style: comma between coordinates, space between commands. + return { + path: `M${sourceX},${sourceY} L${curveSource.x},${curveSource.y} ` + + `${bezierPath.slice(curveStart)} L${targetX},${targetY}`, + labelX, + labelY, + }; +} + function shouldKeepMermaidBranchSpread( preserveMermaidEndpoints: boolean, shape: string | undefined @@ -304,16 +364,16 @@ export function buildEdgePath( } // For Mermaid-parity we use a slightly looser cubic bezier than the // React Flow default; mirrors Mermaid's `curveBasis` softness. - const [edgePath, labelX, labelY] = getBezierPath({ + const { path, labelX, labelY } = buildBezierPathWithArrowLeads( sourceX, sourceY, - sourcePosition: params.sourcePosition, targetX, targetY, - targetPosition: params.targetPosition, - curvature: 0.35, - }); - return withBundledLabelOffset(edgePath, labelX, labelY, params, labelBundleOffset); + params.sourcePosition, + params.targetPosition, + 0.35 + ); + return withBundledLabelOffset(path, labelX, labelY, params, labelBundleOffset); } if (resolvedCurve === 'linear') { From 5322965c6c913d1b4d2a95bf1d8c2b0602c1523c Mon Sep 17 00:00:00 2001 From: Ken van der Eerden Date: Mon, 24 Aug 2026 13:01:45 +0200 Subject: [PATCH 2/2] refactor(edges): clean up marker-alignment internals - move test-only tangent readers to a testHelpers module - rebuild plain bezier when the lead splice finds no cubic, so the fallback path still touches the real handles - correct ARROW_LEAD_PX comment: marker lengths vary, 12 approximates --- .../curvePathMarkerTangents.test.ts | 7 ++-- .../curvePathMarkerTangents.testHelpers.ts | 33 ++++++++++++++++++ .../custom-edge/curvePathMarkerTangents.ts | 34 +++---------------- src/components/custom-edge/edgeCurve.test.ts | 2 +- src/components/custom-edge/pathUtils.ts | 25 +++++++++++--- 5 files changed, 60 insertions(+), 41 deletions(-) create mode 100644 src/components/custom-edge/curvePathMarkerTangents.testHelpers.ts diff --git a/src/components/custom-edge/curvePathMarkerTangents.test.ts b/src/components/custom-edge/curvePathMarkerTangents.test.ts index 7231ffa4..245af249 100644 --- a/src/components/custom-edge/curvePathMarkerTangents.test.ts +++ b/src/components/custom-edge/curvePathMarkerTangents.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - normalizeMarkerTangents, - readPathEndTangent, - readPathStartTangent, -} from './curvePathMarkerTangents'; +import { normalizeMarkerTangents } from './curvePathMarkerTangents'; +import { readPathEndTangent, readPathStartTangent } from './curvePathMarkerTangents.testHelpers'; describe('curvePathMarkerTangents', () => { // Real d3 `curveBasis` output for the repo's duplicated-endpoint anchoring. diff --git a/src/components/custom-edge/curvePathMarkerTangents.testHelpers.ts b/src/components/custom-edge/curvePathMarkerTangents.testHelpers.ts new file mode 100644 index 00000000..0dea9d59 --- /dev/null +++ b/src/components/custom-edge/curvePathMarkerTangents.testHelpers.ts @@ -0,0 +1,33 @@ +/** + * Test-only readers for the endpoint tangents a browser derives for + * `orient="auto"` markers — a zero tangent is the marker-alignment bug the + * production module repairs. Not imported by shipped code. + */ + +import { endOf, parsePath, type Command, type Point } from './curvePathMarkerTangents'; + +function tangentAt(command: Command, start: Point, at: 'start' | 'end'): Point | null { + if (command.type === 'M') return null; + if (command.type === 'L') { + const end = endOf(command); + return { x: end.x - start.x, y: end.y - start.y }; + } + const [c1, c2, end] = command.points; + if (at === 'start') return { x: c1.x - start.x, y: c1.y - start.y }; + return { x: end.x - c2.x, y: end.y - c2.y }; +} + +/** Tangent a browser reads for `marker-end` — zero means the arrowhead angle collapses. */ +export function readPathEndTangent(path: string): Point | null { + const commands = parsePath(path); + if (!commands || commands.length < 2) return null; + const last = commands.length - 1; + return tangentAt(commands[last], endOf(commands[last - 1]), 'end'); +} + +/** Tangent a browser reads for `marker-start`. */ +export function readPathStartTangent(path: string): Point | null { + const commands = parsePath(path); + if (!commands || commands.length < 2) return null; + return tangentAt(commands[1], endOf(commands[0]), 'start'); +} diff --git a/src/components/custom-edge/curvePathMarkerTangents.ts b/src/components/custom-edge/curvePathMarkerTangents.ts index 5a35dcac..92a33c8e 100644 --- a/src/components/custom-edge/curvePathMarkerTangents.ts +++ b/src/components/custom-edge/curvePathMarkerTangents.ts @@ -9,14 +9,14 @@ * well-defined tangent at both ends. */ -interface Point { +export interface Point { x: number; y: number; } type CommandType = 'M' | 'L' | 'C'; -interface Command { +export interface Command { type: CommandType; /** `C`: [control1, control2, end]. `M` / `L`: [end]. */ points: Point[]; @@ -32,7 +32,7 @@ function samePoint(a: Point, b: Point): boolean { return Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON; } -function parsePath(path: string): Command[] | null { +export function parsePath(path: string): Command[] | null { const trimmed = path.trim(); if (!trimmed.startsWith('M')) return null; @@ -67,7 +67,7 @@ function parsePath(path: string): Command[] | null { return commands; } -function endOf(command: Command): Point { +export function endOf(command: Command): Point { return command.points[command.points.length - 1]; } @@ -165,29 +165,3 @@ export function normalizeMarkerTangents(path: string): string { return serialize(kept); } - -function tangentAt(command: Command, start: Point, at: 'start' | 'end'): Point | null { - if (command.type === 'M') return null; - if (command.type === 'L') { - const end = endOf(command); - return { x: end.x - start.x, y: end.y - start.y }; - } - const [c1, c2, end] = command.points; - if (at === 'start') return { x: c1.x - start.x, y: c1.y - start.y }; - return { x: end.x - c2.x, y: end.y - c2.y }; -} - -/** Tangent a browser reads for `marker-end` — zero means the arrowhead angle collapses. */ -export function readPathEndTangent(path: string): Point | null { - const commands = parsePath(path); - if (!commands || commands.length < 2) return null; - const last = commands.length - 1; - return tangentAt(commands[last], endOf(commands[last - 1]), 'end'); -} - -/** Tangent a browser reads for `marker-start`. */ -export function readPathStartTangent(path: string): Point | null { - const commands = parsePath(path); - if (!commands || commands.length < 2) return null; - return tangentAt(commands[1], endOf(commands[0]), 'start'); -} diff --git a/src/components/custom-edge/edgeCurve.test.ts b/src/components/custom-edge/edgeCurve.test.ts index 371290c0..9cfbf002 100644 --- a/src/components/custom-edge/edgeCurve.test.ts +++ b/src/components/custom-edge/edgeCurve.test.ts @@ -6,7 +6,7 @@ import { isOrthogonalStepCurve, isSmoothCurve, } from './edgeCurve'; -import { readPathEndTangent, readPathStartTangent } from './curvePathMarkerTangents'; +import { readPathEndTangent, readPathStartTangent } from './curvePathMarkerTangents.testHelpers'; describe('edgeCurve', () => { it('classifies smooth and orthogonal curves correctly', () => { diff --git a/src/components/custom-edge/pathUtils.ts b/src/components/custom-edge/pathUtils.ts index 132249ca..a07540cf 100644 --- a/src/components/custom-edge/pathUtils.ts +++ b/src/components/custom-edge/pathUtils.ts @@ -39,9 +39,9 @@ import { const EDGE_ROUTING_FAST_PATH_THRESHOLD = 600; /** - * Length of straight path reserved at each endpoint, in flow units. Matches the - * standard arrow marker length so the arrowhead sits on a segment whose direction - * equals the stroke's — see `withEndpointArrowLead`. + * Length of straight path reserved at each endpoint, in flow units. Approximates the + * rendered arrow marker length (marker defs vary: 8–12 units, some scaled by stroke + * width) so the arrowhead sits on a segment whose direction equals the stroke's. */ const ARROW_LEAD_PX = 12; /** Below this endpoint distance the lead would dominate the edge, so it is skipped. */ @@ -88,11 +88,26 @@ function buildBezierPathWithArrowLeads( curvature, }); - const curveStart = lead > 0 ? bezierPath.indexOf('C') : -1; - if (curveStart < 0) { + if (lead === 0) { return { path: bezierPath, labelX, labelY }; } + const curveStart = bezierPath.indexOf('C'); + if (curveStart < 0) { + // Without a recognizable cubic the lead splice below would leave a path that + // starts and ends off the real handles, so rebuild without leads instead. + const [plainPath, plainLabelX, plainLabelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature, + }); + return { path: plainPath, labelX: plainLabelX, labelY: plainLabelY }; + } + // Keep React Flow's own path style: comma between coordinates, space between commands. return { path: `M${sourceX},${sourceY} L${curveSource.x},${curveSource.y} `