diff --git a/src/components/custom-edge/curvePathMarkerTangents.test.ts b/src/components/custom-edge/curvePathMarkerTangents.test.ts new file mode 100644 index 00000000..245af249 --- /dev/null +++ b/src/components/custom-edge/curvePathMarkerTangents.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeMarkerTangents } from './curvePathMarkerTangents'; +import { readPathEndTangent, readPathStartTangent } from './curvePathMarkerTangents.testHelpers'; + +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.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 new file mode 100644 index 00000000..92a33c8e --- /dev/null +++ b/src/components/custom-edge/curvePathMarkerTangents.ts @@ -0,0 +1,167 @@ +/** + * 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. + */ + +export interface Point { + x: number; + y: number; +} + +type CommandType = 'M' | 'L' | 'C'; + +export 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; +} + +export 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; +} + +export 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); +} diff --git a/src/components/custom-edge/edgeCurve.test.ts b/src/components/custom-edge/edgeCurve.test.ts index b49f8041..9cfbf002 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.testHelpers'; 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..a07540cf 100644 --- a/src/components/custom-edge/pathUtils.ts +++ b/src/components/custom-edge/pathUtils.ts @@ -38,10 +38,85 @@ import { const EDGE_ROUTING_FAST_PATH_THRESHOLD = 600; +/** + * 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. */ +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, + }); + + 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} ` + + `${bezierPath.slice(curveStart)} L${targetX},${targetY}`, + labelX, + labelY, + }; +} + function shouldKeepMermaidBranchSpread( preserveMermaidEndpoints: boolean, shape: string | undefined @@ -304,16 +379,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') {