From 847caf48d375551990bf7d15add630ddbab7206e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 07:53:54 +0300 Subject: [PATCH] Plan-graph round 12 (owner): docking gap + straight shaft stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- components/plan-block.tsx | 31 ++++++++++++++++++++++++++----- tests/render-plan-block.test.tsx | 13 +++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index dd8ff7d..014b3e3 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -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) ?? ""; } diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 8fca43f..2b92766 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -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)", () => {