From a207e6b4a29ac6258a63b99bfdf35cadaef795fb Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 14 Sep 2026 08:04:31 +0300 Subject: [PATCH] =?UTF-8?q?Plan-graph=20round=2014=20(owner):=20entry-angl?= =?UTF-8?q?e=20clamp=20=E2=80=94=20heads=20enter=20within=20=C2=B140=C2=B0?= =?UTF-8?q?=20of=20the=20face=20perpendicular?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner measured the arrowhead in Photoshop: past ~40° off the perpendicular of the box edge, one barb crosses the tip's level and the silhouette breaks. edgePathD now takes the target box and clamps the final approach: the entry direction may tilt at most ±ENTRY_ANGLE_LIMIT (40°) off the entry face's outward normal — a steeper natural approach swings to the cone edge (same rotation side as the route), the shaft stub and head follow the clamped direction. Docking refined: the tip floats DOCK_GAP past the border along the FACE NORMAL (constant gap at any tilt; perpendicular entries are bit-identical to before — pinned). Faces are derived from where dagre's border point sits on the target box (top/bottom/left/right all supported). Edges without a known target box keep the unclamped geometry. Pinned: cone-edge swing at 40° exactly, normal-gap docking, unclamped shallow entries. --- components/plan-block.tsx | 90 +++++++++++++++++++++++++++----- tests/render-plan-block.test.tsx | 26 +++++++++ 2 files changed, 104 insertions(+), 12 deletions(-) diff --git a/components/plan-block.tsx b/components/plan-block.tsx index 5b3f469..e696ea1 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -223,19 +223,51 @@ const DOCK_GAP = 4; * 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; +/** The tilt limit at the termination point, measured off the entry + * face's perpendicular: past ±40° one of the head's barbs crosses the + * tip's level and the silhouette breaks (owner measured it in + * Photoshop, round 14). The final run swings to the cone edge instead. */ +const ENTRY_ANGLE_LIMIT = (40 * Math.PI) / 180; const edgeLine = line<{ x: number; y: number }>() .x((p) => p.x) .y((p) => p.y) .curve(curveBasis); +/** The entry face's outward unit normal — whichever face of the target + * box dagre's border point sits on. Pure. */ +function faceNormal( + border: { x: number; y: number }, + box: { x: number; y: number; w: number; h: number }, +): { x: number; y: number } { + const d = { top: border.y - box.y, bottom: box.y + box.h - border.y, left: border.x - box.x, right: box.x + box.w - border.x }; + const face = (Object.keys(d) as Array).reduce((a, b) => (d[a] <= d[b] ? a : b)); + switch (face) { + case "top": + return { x: 0, y: -1 }; + case "bottom": + return { x: 0, y: 1 }; + case "left": + return { x: -1, y: 0 }; + default: + return { x: 1, y: 0 }; + } +} + /** 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 + 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 { + * the same recipe dagre-d3's renderer uses). The head docks off the + * target: its TIP floats DOCK_GAP past the border along the FACE NORMAL + * (constant gap at any tilt), its mouth sits at the path's end on a + * straight run (the shaft stub), and — when the target box is given — + * the entry direction is clamped to ±ENTRY_ANGLE_LIMIT of the face's + * perpendicular (owner, round 14): a steeper natural approach swings to + * the 40° cone edge instead of breaking the head's silhouette. Without + * a target box the unclamped geometry applies. Straight collinear + * routes render identically. Pure, exported for tests. */ +export function edgePathD( + points: Array<{ x: number; y: number }>, + target?: { x: number; y: number; w: number; h: number }, +): string { const pts = points.map((p) => ({ ...p })); if (pts.length >= 2) { const last = pts[pts.length - 1]!; @@ -243,11 +275,38 @@ 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 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 }; + let ux = dx / len; + let uy = dy / len; + // The tip: DOCK_GAP outside the face along its normal (or along the + // approach when no box is known). + let tipx: number; + let tipy: number; + if (target) { + const n = faceNormal(last, target); + tipx = last.x + n.x * DOCK_GAP; + tipy = last.y + n.y * DOCK_GAP; + // Clamp the approach to the ±LIMIT cone around the normal: if + // the outgoing direction -u tilts past ENTRY_ANGLE_LIMIT, swing + // it to the cone edge (same rotation side as the route). + const v = { x: -ux, y: -uy }; + const vn = v.x * n.x + v.y * n.y; + if (vn < Math.cos(ENTRY_ANGLE_LIMIT)) { + const t = { x: v.x - vn * n.x, y: v.y - vn * n.y }; + const tl = Math.hypot(t.x, t.y); + const that = tl > 0 ? { x: t.x / tl, y: t.y / tl } : { x: -n.y, y: n.x }; + const c = Math.cos(ENTRY_ANGLE_LIMIT); + const s = Math.sin(ENTRY_ANGLE_LIMIT); + v.x = c * n.x + s * that.x; + v.y = c * n.y + s * that.y; + ux = -v.x; + uy = -v.y; + } + } else { + tipx = last.x - ux * DOCK_GAP; + tipy = last.y - uy * DOCK_GAP; + } + // The mouth: overhang back from the tip along the (clamped) approach. + const M = { x: tipx - ux * HEAD_TIP_OVERHANG, y: tipy - uy * HEAD_TIP_OVERHANG }; // From Q the control points lie on the approach line — the basis // spline renders Q→M perfectly straight (a B-spline span with // collinear control points IS the line), and curveBasis's internal @@ -386,13 +445,20 @@ export function PlanDiagram({ // (dangling endpoints) draw nothing. const pts = layout.edgePts.get(`${e.from}->${e.to}`); if (!pts) return null; + // The target's box — drives the docking gap + entry-angle clamp. + const tx = layout.x.get(e.to); + const ty = layout.y.get(e.to); + const target = + tx !== undefined && ty !== undefined + ? { x: tx, y: ty, w: NODE_W, h: layout.nodeH.get(e.to) ?? NODE_H } + : undefined; // An arrow into a not-yet-visited node is grayed out like the // node (owner, round 6); the in-chat block stays all-white. const dim = !monochrome && statusOf(e.to) === "upcoming"; return ( ${e.to}`} - d={edgePathD(pts)} + d={edgePathD(pts, target)} className={dim ? "fill-none stroke-border" : "fill-none stroke-white"} strokeWidth={1.5} markerEnd={`url(#${markerId}${dim ? "-dim" : ""})`} diff --git a/tests/render-plan-block.test.tsx b/tests/render-plan-block.test.tsx index 2b92766..f48ce7d 100644 --- a/tests/render-plan-block.test.tsx +++ b/tests/render-plan-block.test.tsx @@ -72,6 +72,32 @@ describe("plan block layout (pure)", () => { // 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"); + // A perpendicular entry with the target box is the SAME geometry. + const pd = edgePathD([{ x: 80, y: 44 }, { x: 80, y: 68 }, { x: 80, y: 92 }], { x: 0, y: 92, w: 160, h: 44 }); + assert.equal(pd, d); + }); + + it("edgePathD clamps the entry to ±40° of the face perpendicular (owner, round 14)", () => { + const box = { x: 20, y: 100, w: 160, h: 44 }; + // A 60° tilt into the top face swings to the 40° cone edge: the + // mouth lands at (97.726, 93.289) and the final segment runs at + // exactly 40° (Δx/Δy = tan 40°). The tip floats DOCK_GAP (4) off the + // face along the normal: border (100,100) → tip (100,96). + const clamped = edgePathD([{ x: 70, y: 70 }, { x: 100, y: 100 }], box); + assert.ok(clamped.endsWith("L97.726,93.289"), "swung to the cone edge"); + // final L's from-point is the previous number pair; recompute direction + const nums = [...clamped.matchAll(/(-?[\d.]+)/g)].map((m) => Number(m[1])); + const ax = nums[nums.length - 4]!; + const ay = nums[nums.length - 3]!; + const bx = nums[nums.length - 2]!; + const by = nums[nums.length - 1]!; + assert.ok(Math.abs((bx - ax) / (by - ay) - Math.tan((40 * Math.PI) / 180)) < 0.01, "40° cone edge"); + // Tip = mouth + u'·overhang floats 4 above the face. + const tipY = by + 0.766 * 3.538; + assert.ok(Math.abs(tipY - 96) < 0.01, "docking gap along the face normal"); + // A shallow (~9.5°) tilt is WITHIN the limit: no swing. + const free = edgePathD([{ x: 90, y: 40 }, { x: 100, y: 100 }], box); + assert.ok(free.endsWith("L99.418,92.51"), "unclamped within the cone"); }); it("wrapTitle wraps greedily and NEVER drops content (boxes grow instead)", () => {