Plan-graph round 12 (owner): docking gap + straight shaft stub

Two edge-drawing refinements (owner), both pure post-processing in
edgePathD on dagre's waypoints:

- Docking gap (4 units ≈ half a head length): the head's tip floats
  short of the target face instead of touching it — the mouth pulls
  back by tip overhang + gap, so the head kisses nothing and tilted
  entries stop biting into the box.
- Shaft stub (12 units ≈ 1.5 head lengths): from Q, every control
  point sits exactly on the approach line, so the basis spline renders
  that stretch perfectly straight and the head lands on a straight
  shaft; the corner where the route squares up is rounded by the
  curve, and the repeated mouth point makes the end tangent exactly
  the approach direction. Collinear routes render identically
  (pinned: on-axis start to mouth).

Angle alignment/perpendicular entry deliberately NOT touched yet —
owner wants to evaluate this first (round 13 candidate).
This commit is contained in:
amosblomqvist
2026-09-14 07:53:54 +03:00
parent 0f6b2d5c1e
commit 847caf48d3
2 changed files with 35 additions and 9 deletions
+26 -5
View File
@@ -213,6 +213,16 @@ const HEAD_W = 50.2 * HEAD_SCALE;
* overhangs the line's end by this much, so edges shorten by it to land
* the tip exactly on the target node's border. */
const HEAD_TIP_OVERHANG = (50.2 - 27.2) * HEAD_SCALE;
/** The head floats this far off the target face — the tip kisses nothing
* (owner, round 12). ~half a head length: clearly docked-off, still
* visually attached. */
const DOCK_GAP = 4;
/** The shaft stub — the straight run before the head (owner, round 12):
* control points from Q on sit exactly on the approach line, so the
* basis spline renders that stretch perfectly straight and the head
* lands on a straight shaft; the corner where the route squares up onto
* the line is rounded by the curve. ~1.5 head lengths of shaft. */
const STRAIGHT_RUN = 12;
const edgeLine = line<{ x: number; y: number }>()
.x((p) => p.x)
.y((p) => p.y)
@@ -221,9 +231,10 @@ const edgeLine = line<{ x: number; y: number }>()
/** The edge path — mermaid's own line drawing: dagre's border-intersected
* waypoints through d3's curveBasis (mermaid's default flowchart curve —
* the same recipe dagre-d3's renderer uses). The final point pulls back
* along the last segment by the tip overhang so the head's TIP lands on
* the target border while the mouth sits at the path's end. Pure,
* exported for tests. */
* along the last segment by the tip overhang + the docking gap so the
* head's TIP floats DOCK_GAP short of the target border with its mouth
* at the path's end, on a straight run (the shaft stub). Straight
* collinear routes render identically. Pure, exported for tests. */
export function edgePathD(points: Array<{ x: number; y: number }>): string {
const pts = points.map((p) => ({ ...p }));
if (pts.length >= 2) {
@@ -232,8 +243,18 @@ export function edgePathD(points: Array<{ x: number; y: number }>): string {
const dx = last.x - prev.x;
const dy = last.y - prev.y;
const len = Math.hypot(dx, dy) || 1;
const t = Math.min(HEAD_TIP_OVERHANG / len, 0.5);
pts[pts.length - 1] = { x: last.x - dx * t, y: last.y - dy * t };
const ux = dx / len;
const uy = dy / len;
// The mouth: tip overhang + the docking gap back from the border.
const back = HEAD_TIP_OVERHANG + DOCK_GAP;
const M = { x: last.x - ux * back, y: last.y - uy * back };
// From Q every control point lies on the approach line — the basis
// spline renders Q→M perfectly straight (a B-spline span with
// collinear control points IS the line), and the repeated M makes
// the end tangent exactly the approach direction, so the head
// squares onto its shaft.
const Q = { x: M.x - ux * STRAIGHT_RUN, y: M.y - uy * STRAIGHT_RUN };
pts.splice(pts.length - 1, 1, Q, M, M);
}
return edgeLine(pts) ?? "";
}
+9 -4
View File
@@ -61,12 +61,17 @@ describe("plan block layout (pure)", () => {
it("edgePathD draws dagre's route through curveBasis — mouth pulled back from the border", () => {
// Straight 3-point route (source border → mid → target border): the
// basis curve starts exactly at the border point and the END pulls
// back by HEAD_TIP_OVERHANG so the arrowhead's TIP lands on the
// border while its mouth sits at the path's end (owner, round 11).
// basis curve starts exactly at the border point; the END pulls
// back by HEAD_TIP_OVERHANG + the docking gap (92 − 3.538 − 4) so the
// head's TIP floats 4 short of the border with its mouth at the
// path's end, on a straight shaft (owner, rounds 11–12).
const d = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }]);
assert.ok(d.startsWith("M80,44"), "starts at the source border point");
assert.ok(d.endsWith("L80,88.462"), "ends pulled back by the tip overhang");
assert.ok(d.endsWith("L80,84.462"), "mouth pulled back; tip floats 4 off the border");
// The whole run stays on-axis (every x is 80): a collinear route
// renders straight — the shaft stub never bends a straight edge.
const xs = [...d.matchAll(/(?:[MLC]|,)(-?[\d.]+),/g)].map((m) => m[1]);
assert.ok(xs.length > 0 && xs.every((v) => v === "80"), "collinear route renders on-axis");
});
it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {