Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/components/custom-edge/curvePathMarkerTangents.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
33 changes: 33 additions & 0 deletions src/components/custom-edge/curvePathMarkerTangents.testHelpers.ts
Original file line number Diff line number Diff line change
@@ -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');
}
167 changes: 167 additions & 0 deletions src/components/custom-edge/curvePathMarkerTangents.ts
Original file line number Diff line number Diff line change
@@ -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<CommandType, number> = { 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);
}
41 changes: 41 additions & 0 deletions src/components/custom-edge/edgeCurve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isOrthogonalStepCurve,
isSmoothCurve,
} from './edgeCurve';
import { readPathEndTangent, readPathStartTangent } from './curvePathMarkerTangents.testHelpers';

describe('edgeCurve', () => {
it('classifies smooth and orthogonal curves correctly', () => {
Expand Down Expand Up @@ -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(
[
Expand Down
8 changes: 7 additions & 1 deletion src/components/custom-edge/edgeCurve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
line as d3Line,
type CurveFactory,
} from 'd3-shape';
import { normalizeMarkerTangents } from './curvePathMarkerTangents';

export type EdgeCurve =
| 'basis'
Expand Down Expand Up @@ -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
Expand All @@ -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<EdgeCurve>([
Expand Down
Loading