Plan-graph round 14 (owner): entry-angle clamp — heads enter within ±40° of the face perpendicular
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.
This commit is contained in:
+78
-12
@@ -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<keyof typeof d>).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 (
|
||||
<path
|
||||
key={`${e.from}->${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" : ""})`}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user