@transitrix/diagrams 1.8.18 → 1.8.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,22 @@ export interface ValidateGridOptions {
21
21
  /** Extra template-level rules to run once the base grid is well-formed. */
22
22
  rules?: GridRule[];
23
23
  }
24
+ /**
25
+ * Element-level TYPE registry — IDS_AND_REFERENCES.md §3.1, plus
26
+ * `VERIFICATION` (§3.7): block diagrams cross-reference the full
27
+ * hazard → risk-control → requirement → verification chain, and
28
+ * `VERIFICATION` is the terminal link in that chain.
29
+ *
30
+ * `FACTOR` and `ACTIVITY` are kept alongside their canonical replacements
31
+ * `DRIVER` and `ACTION` — both still-valid grandfathered/deprecated aliases
32
+ * per §3.1 and §6, not yet forced to migrate.
33
+ *
34
+ * `UNIT`/`EMPLOYEE` (replaced by `ACTOR`, removed from canon 2026-05-29) and
35
+ * `ISSUE` (retired 2026-06-07, no replacement TYPE — see methodology
36
+ * `docs/decisions/2026-06-07-retire-model-issue-type.md`) are deliberately
37
+ * not carried forward. `STAGE` was never a canonical TYPE.
38
+ */
39
+ export declare const REGISTERED_ELEMENT_TYPES: Set<string>;
24
40
  export declare function validateNestedBlocks(input: unknown): ValidationResult;
25
41
  /** Matrix subset (§4a): validate a `grid:` root document — BL-021..BL-025. */
26
42
  export declare function validateGrid(input: unknown, options?: ValidateGridOptions): ValidationResult;
@@ -18,23 +18,55 @@ const CANONICAL_ID_RE = /^[A-Z][A-Z0-9_]*(-[A-Z0-9][A-Z0-9_]*)+-\d+$/;
18
18
  * dot, so we recognise it separately.
19
19
  */
20
20
  const CAPABILITY_ID_RE = /^CAPABILITY-[VH]\d+(\.\d+){0,2}$/;
21
- /** Element-level TYPE registry — IDS_AND_REFERENCES.md §3.1. */
22
- const REGISTERED_ELEMENT_TYPES = new Set([
21
+ /**
22
+ * Element-level TYPE registry — IDS_AND_REFERENCES.md §3.1, plus
23
+ * `VERIFICATION` (§3.7): block diagrams cross-reference the full
24
+ * hazard → risk-control → requirement → verification chain, and
25
+ * `VERIFICATION` is the terminal link in that chain.
26
+ *
27
+ * `FACTOR` and `ACTIVITY` are kept alongside their canonical replacements
28
+ * `DRIVER` and `ACTION` — both still-valid grandfathered/deprecated aliases
29
+ * per §3.1 and §6, not yet forced to migrate.
30
+ *
31
+ * `UNIT`/`EMPLOYEE` (replaced by `ACTOR`, removed from canon 2026-05-29) and
32
+ * `ISSUE` (retired 2026-06-07, no replacement TYPE — see methodology
33
+ * `docs/decisions/2026-06-07-retire-model-issue-type.md`) are deliberately
34
+ * not carried forward. `STAGE` was never a canonical TYPE.
35
+ */
36
+ export const REGISTERED_ELEMENT_TYPES = new Set([
37
+ 'DRIVER',
23
38
  'FACTOR',
24
39
  'GOAL',
25
40
  'CHANGE',
41
+ 'ACTION',
26
42
  'ACTIVITY',
27
43
  'CAPABILITY',
28
44
  'PROCESS',
45
+ 'STEP',
29
46
  'PRODUCT',
30
47
  'APPLICATION',
31
48
  'INTEGRATION',
32
49
  'ROLE',
33
- 'UNIT',
34
- 'EMPLOYEE',
50
+ 'ACTOR',
51
+ 'LOCATION',
52
+ 'BUSINESS_SERVICE',
35
53
  'SCENARIO',
36
- 'ISSUE',
37
- 'STAGE',
54
+ 'EQUIPMENT',
55
+ 'NODE',
56
+ 'TECHNOLOGY_SERVICE',
57
+ 'BUSINESS_OBJECT',
58
+ 'RULE',
59
+ 'REGISTRY',
60
+ 'CONSTRAINT',
61
+ 'REQUIREMENT',
62
+ 'STAKEHOLDER',
63
+ 'ASSESSMENT',
64
+ 'HAZARD',
65
+ 'RISK_CONTROL',
66
+ 'TARGET_STATE',
67
+ 'REL',
68
+ 'MILESTONE',
69
+ 'VERIFICATION',
38
70
  ]);
39
71
  /**
40
72
  * Recommended maximum nesting depth (1-indexed). The spec warns at depth 6+;
@@ -20,3 +20,18 @@ export declare const DEFAULT_EDGE_CURVATURE = 1;
20
20
  * - >1 → progressively stronger arc at that end.
21
21
  */
22
22
  export declare function horizontalCubicEdgePath(sx: number, sy: number, tx: number, ty: number, curvature?: number, entryCurvature?: number): string;
23
+ /**
24
+ * Builds the SVG `d` for a "skip" edge that must arc over an intermediate
25
+ * node sitting on the direct (sx,sy)→(tx,ty) line — e.g. a Network/PSND view
26
+ * where a multi-predecessor activity's nearer predecessor shares a row with
27
+ * one of the columns in between, so the direct line would be drawn straight
28
+ * through that node. Both control points target `bowY` instead of each
29
+ * endpoint's own Y, producing a hump that departs/arrives at an angle rather
30
+ * than the flat horizontal tangent of `horizontalCubicEdgePath`.
31
+ *
32
+ * A cubic's actual apex only reaches part-way from an endpoint Y to the
33
+ * control-point Y (75% at the midpoint for a fully symmetric sy=ty curve),
34
+ * so callers must pick `bowY` well past the obstacle for the apex to clear
35
+ * it — see `render-activities.ts`'s `blockingNodes`/`bowY` for the margin.
36
+ */
37
+ export declare function bowedCubicEdgePath(sx: number, sy: number, tx: number, ty: number, bowY: number, curvature?: number): string;
package/dist/edge-path.js CHANGED
@@ -47,3 +47,22 @@ export function horizontalCubicEdgePath(sx, sy, tx, ty, curvature = DEFAULT_EDGE
47
47
  const entryHandle = baseHandle * (entryCurvature ?? curvature);
48
48
  return `M${sx},${sy} C${sx + exitHandle},${sy} ${tx - entryHandle},${ty} ${tx},${ty}`;
49
49
  }
50
+ /**
51
+ * Builds the SVG `d` for a "skip" edge that must arc over an intermediate
52
+ * node sitting on the direct (sx,sy)→(tx,ty) line — e.g. a Network/PSND view
53
+ * where a multi-predecessor activity's nearer predecessor shares a row with
54
+ * one of the columns in between, so the direct line would be drawn straight
55
+ * through that node. Both control points target `bowY` instead of each
56
+ * endpoint's own Y, producing a hump that departs/arrives at an angle rather
57
+ * than the flat horizontal tangent of `horizontalCubicEdgePath`.
58
+ *
59
+ * A cubic's actual apex only reaches part-way from an endpoint Y to the
60
+ * control-point Y (75% at the midpoint for a fully symmetric sy=ty curve),
61
+ * so callers must pick `bowY` well past the obstacle for the apex to clear
62
+ * it — see `render-activities.ts`'s `blockingNodes`/`bowY` for the margin.
63
+ */
64
+ export function bowedCubicEdgePath(sx, sy, tx, ty, bowY, curvature = DEFAULT_EDGE_CURVATURE) {
65
+ const dx = tx - sx;
66
+ const handle = Math.max(EDGE_MIN_HANDLE, Math.min(Math.abs(dx) * DX_FACTOR, MAX_HANDLE)) * curvature;
67
+ return `M${sx},${sy} C${sx + handle},${bowY} ${tx - handle},${bowY} ${tx},${ty}`;
68
+ }
@@ -10,6 +10,18 @@ import { type NodeSizePreset } from '../node-size-presets.js';
10
10
  export declare const ACTIVITIES_NETWORK_CSS = "\n .act-node { fill: var(--ts-layer-activity, #d4edda); stroke: var(--ts-node-stroke, #004d67); stroke-width: 1; }\n .critical-node { fill: var(--ts-brand-orange-tint, #ffeee5); stroke: var(--ts-brand-orange, #ff4d00); stroke-width: 1; }\n .milestone-node { fill: #ecfeff; stroke: var(--ts-text-muted, #64748b); stroke-dasharray: 4 2; }\n .critical-edge { stroke: var(--ts-brand-orange, #ff4d00); stroke-width: 1.5; }\n .arrow-fill-critical { fill: var(--ts-brand-orange, #ff4d00); }\n";
11
11
  /** Arrowhead marker defs shared by both hosts for the network view. */
12
12
  export declare const ACTIVITIES_NETWORK_DEFS = "<defs>\n <marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"8\" refY=\"3\" orient=\"auto\">\n <path d=\"M0,0 L0,6 L8,3 z\" class=\"arrow-fill\"/>\n </marker>\n <marker id=\"arrow-crit\" markerWidth=\"8\" markerHeight=\"8\" refX=\"8\" refY=\"3\" orient=\"auto\">\n <path d=\"M0,0 L0,6 L8,3 z\" class=\"arrow-fill-critical\"/>\n </marker>\n</defs>";
13
+ /**
14
+ * Extra top padding (px) a network-view canvas must add so skip-edge bows
15
+ * (see `blockingNodes`/`bowY` above) never rise above the diagram's own node
16
+ * bounds (`layout.bounds.y`). Node rows shift down by the returned amount to
17
+ * make room for the bow within the diagram area; the title block — drawn
18
+ * separately, above that area, at a fixed position independent of this pad —
19
+ * is left undisturbed, so the bow can never climb into the reserved title
20
+ * zone (the earlier version floored against the canvas's literal y=0, which
21
+ * let a tall bow draw straight through the title text sitting between 0 and
22
+ * the diagram's un-padded top).
23
+ */
24
+ export declare function computeNetworkTopPad(layout: ActivitiesLayout): number;
13
25
  /**
14
26
  * Canonical Network (PSND) body — the node rects and edge paths that go inside
15
27
  * the `<svg>`, shared verbatim by the VS Code preview and the host-neutral
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { layoutActivities } from '../activities/layout.js';
20
20
  import { computeCpm } from '../activities/cpm.js';
21
- import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
21
+ import { horizontalCubicEdgePath, bowedCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
22
22
  import { parseNodeSizePreset, resolveActionNodeSize } from '../node-size-presets.js';
23
23
  import { generateSvgEmbedCss } from '../theme/index.js';
24
24
  import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
@@ -46,6 +46,71 @@ export const ACTIVITIES_NETWORK_DEFS = `<defs>
46
46
  <path d="M0,0 L0,6 L8,3 z" class="arrow-fill-critical"/>
47
47
  </marker>
48
48
  </defs>`;
49
+ /**
50
+ * Nodes horizontally between `source` and `target` (a "skip" edge passes over
51
+ * their column) whose vertical span overlaps the edge's Y band. A
52
+ * straight-tangent cubic (`horizontalCubicEdgePath`) stays within
53
+ * [min(sourceCenterY,targetCenterY), max(...)] by convex hull, so any such
54
+ * node sits directly on the edge's path — the "arrow goes straight through
55
+ * the node" bug reported against a linear PSND chain where a multi-predecessor
56
+ * activity's nearer predecessor shares a row with an intermediate column.
57
+ */
58
+ function blockingNodes(nodes, source, target) {
59
+ const sCenterY = source.y + source.height / 2;
60
+ const tCenterY = target.y + target.height / 2;
61
+ const loY = Math.min(sCenterY, tCenterY);
62
+ const hiY = Math.max(sCenterY, tCenterY);
63
+ const sRight = source.x + source.width;
64
+ const tLeft = target.x;
65
+ return nodes.filter((n) => {
66
+ if (n.id === source.id || n.id === target.id)
67
+ return false;
68
+ if (n.x + n.width <= sRight || n.x >= tLeft)
69
+ return false; // not between the two columns
70
+ return n.y < hiY && n.y + n.height > loY; // vertical overlap with the edge's band
71
+ });
72
+ }
73
+ /**
74
+ * Pre-offset bow-control-point Y for a skip edge arcing over `blockers`: one
75
+ * full node-height of clearance above the topmost blocker's top edge. A
76
+ * cubic's apex only reaches part-way toward its control point (see
77
+ * `bowedCubicEdgePath` doc), so this generous margin is what actually keeps
78
+ * the visible curve clear of the box — verified numerically in
79
+ * `render-activities.test.ts`.
80
+ */
81
+ function bowY(blockers) {
82
+ const topY = Math.min(...blockers.map((n) => n.y));
83
+ const clearance = Math.max(...blockers.map((n) => n.height));
84
+ return topY - clearance;
85
+ }
86
+ /**
87
+ * Extra top padding (px) a network-view canvas must add so skip-edge bows
88
+ * (see `blockingNodes`/`bowY` above) never rise above the diagram's own node
89
+ * bounds (`layout.bounds.y`). Node rows shift down by the returned amount to
90
+ * make room for the bow within the diagram area; the title block — drawn
91
+ * separately, above that area, at a fixed position independent of this pad —
92
+ * is left undisturbed, so the bow can never climb into the reserved title
93
+ * zone (the earlier version floored against the canvas's literal y=0, which
94
+ * let a tall bow draw straight through the title text sitting between 0 and
95
+ * the diagram's un-padded top).
96
+ */
97
+ export function computeNetworkTopPad(layout) {
98
+ const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
99
+ let pad = 0;
100
+ for (const e of layout.edges) {
101
+ const s = nodeMap.get(e.sourceId);
102
+ const t = nodeMap.get(e.targetId);
103
+ if (!s || !t)
104
+ continue;
105
+ const blockers = blockingNodes(layout.nodes, s, t);
106
+ if (blockers.length === 0)
107
+ continue;
108
+ const shortfall = layout.bounds.y - bowY(blockers);
109
+ if (shortfall > pad)
110
+ pad = shortfall;
111
+ }
112
+ return pad;
113
+ }
49
114
  function activityNodeSvg(n, x, y, isCritical, isMilestone, durLabel) {
50
115
  const cls = `diagram-node act-node ${isCritical ? 'critical-node' : ''} ${isMilestone ? 'milestone-node' : ''}`.trim();
51
116
  const specs = layoutCenteredEntityText({
@@ -92,7 +157,11 @@ export function renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entr
92
157
  const ty = t.y + oy + t.height / 2;
93
158
  const cls = e.isCritical ? 'diagram-edge critical-edge' : 'diagram-edge';
94
159
  const marker = `url(#${e.isCritical ? 'arrow-crit' : 'arrow'})`;
95
- return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature)}" class="${cls}" marker-end="${marker}"/>`;
160
+ const blockers = blockingNodes(layout.nodes, s, t);
161
+ const d = blockers.length > 0
162
+ ? bowedCubicEdgePath(sx, sy, tx, ty, bowY(blockers) + oy, curvature)
163
+ : horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
164
+ return `<path d="${d}" class="${cls}" marker-end="${marker}"/>`;
96
165
  })
97
166
  .join('\n');
98
167
  const nodeSvg = layout.nodes
@@ -142,10 +211,12 @@ export function renderActivitiesSvg(doc, options = {}) {
142
211
  }
143
212
  const cpm = computeCpm(renderDoc.activities ?? []);
144
213
  const titleH = title ? 24 : 0;
214
+ const oyBase = -layout.bounds.y + N_PAD + titleH;
215
+ const topPad = computeNetworkTopPad(layout);
145
216
  const w = layout.bounds.width + N_PAD * 2;
146
- const h = layout.bounds.height + N_PAD * 2 + titleH;
217
+ const h = layout.bounds.height + N_PAD * 2 + titleH + topPad;
147
218
  const ox = -layout.bounds.x + N_PAD;
148
- const oy = -layout.bounds.y + N_PAD + titleH;
219
+ const oy = oyBase + topPad;
149
220
  const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
150
221
  const titleSvg = title
151
222
  ? `<text class="text-header" x="${N_PAD}" y="${N_PAD - 6}">${escXml(title)}</text>`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.8.18",
3
+ "version": "1.8.20",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -45,23 +45,55 @@ const CANONICAL_ID_RE = /^[A-Z][A-Z0-9_]*(-[A-Z0-9][A-Z0-9_]*)+-\d+$/;
45
45
  */
46
46
  const CAPABILITY_ID_RE = /^CAPABILITY-[VH]\d+(\.\d+){0,2}$/;
47
47
 
48
- /** Element-level TYPE registry — IDS_AND_REFERENCES.md §3.1. */
49
- const REGISTERED_ELEMENT_TYPES = new Set<string>([
48
+ /**
49
+ * Element-level TYPE registry — IDS_AND_REFERENCES.md §3.1, plus
50
+ * `VERIFICATION` (§3.7): block diagrams cross-reference the full
51
+ * hazard → risk-control → requirement → verification chain, and
52
+ * `VERIFICATION` is the terminal link in that chain.
53
+ *
54
+ * `FACTOR` and `ACTIVITY` are kept alongside their canonical replacements
55
+ * `DRIVER` and `ACTION` — both still-valid grandfathered/deprecated aliases
56
+ * per §3.1 and §6, not yet forced to migrate.
57
+ *
58
+ * `UNIT`/`EMPLOYEE` (replaced by `ACTOR`, removed from canon 2026-05-29) and
59
+ * `ISSUE` (retired 2026-06-07, no replacement TYPE — see methodology
60
+ * `docs/decisions/2026-06-07-retire-model-issue-type.md`) are deliberately
61
+ * not carried forward. `STAGE` was never a canonical TYPE.
62
+ */
63
+ export const REGISTERED_ELEMENT_TYPES = new Set<string>([
64
+ 'DRIVER',
50
65
  'FACTOR',
51
66
  'GOAL',
52
67
  'CHANGE',
68
+ 'ACTION',
53
69
  'ACTIVITY',
54
70
  'CAPABILITY',
55
71
  'PROCESS',
72
+ 'STEP',
56
73
  'PRODUCT',
57
74
  'APPLICATION',
58
75
  'INTEGRATION',
59
76
  'ROLE',
60
- 'UNIT',
61
- 'EMPLOYEE',
77
+ 'ACTOR',
78
+ 'LOCATION',
79
+ 'BUSINESS_SERVICE',
62
80
  'SCENARIO',
63
- 'ISSUE',
64
- 'STAGE',
81
+ 'EQUIPMENT',
82
+ 'NODE',
83
+ 'TECHNOLOGY_SERVICE',
84
+ 'BUSINESS_OBJECT',
85
+ 'RULE',
86
+ 'REGISTRY',
87
+ 'CONSTRAINT',
88
+ 'REQUIREMENT',
89
+ 'STAKEHOLDER',
90
+ 'ASSESSMENT',
91
+ 'HAZARD',
92
+ 'RISK_CONTROL',
93
+ 'TARGET_STATE',
94
+ 'REL',
95
+ 'MILESTONE',
96
+ 'VERIFICATION',
65
97
  ]);
66
98
 
67
99
  /**
package/src/edge-path.ts CHANGED
@@ -62,3 +62,30 @@ export function horizontalCubicEdgePath(
62
62
  const entryHandle = baseHandle * (entryCurvature ?? curvature);
63
63
  return `M${sx},${sy} C${sx + exitHandle},${sy} ${tx - entryHandle},${ty} ${tx},${ty}`;
64
64
  }
65
+
66
+ /**
67
+ * Builds the SVG `d` for a "skip" edge that must arc over an intermediate
68
+ * node sitting on the direct (sx,sy)→(tx,ty) line — e.g. a Network/PSND view
69
+ * where a multi-predecessor activity's nearer predecessor shares a row with
70
+ * one of the columns in between, so the direct line would be drawn straight
71
+ * through that node. Both control points target `bowY` instead of each
72
+ * endpoint's own Y, producing a hump that departs/arrives at an angle rather
73
+ * than the flat horizontal tangent of `horizontalCubicEdgePath`.
74
+ *
75
+ * A cubic's actual apex only reaches part-way from an endpoint Y to the
76
+ * control-point Y (75% at the midpoint for a fully symmetric sy=ty curve),
77
+ * so callers must pick `bowY` well past the obstacle for the apex to clear
78
+ * it — see `render-activities.ts`'s `blockingNodes`/`bowY` for the margin.
79
+ */
80
+ export function bowedCubicEdgePath(
81
+ sx: number,
82
+ sy: number,
83
+ tx: number,
84
+ ty: number,
85
+ bowY: number,
86
+ curvature: number = DEFAULT_EDGE_CURVATURE,
87
+ ): string {
88
+ const dx = tx - sx;
89
+ const handle = Math.max(EDGE_MIN_HANDLE, Math.min(Math.abs(dx) * DX_FACTOR, MAX_HANDLE)) * curvature;
90
+ return `M${sx},${sy} C${sx + handle},${bowY} ${tx - handle},${bowY} ${tx},${ty}`;
91
+ }
@@ -23,7 +23,7 @@ import type {
23
23
  ActivitiesLayout,
24
24
  ActivitiesLayoutOptions,
25
25
  } from '../activities/types.js';
26
- import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
26
+ import { horizontalCubicEdgePath, bowedCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
27
27
  import { parseNodeSizePreset, resolveActionNodeSize, type NodeSizePreset } from '../node-size-presets.js';
28
28
  import { generateSvgEmbedCss } from '../theme/index.js';
29
29
  import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
@@ -55,6 +55,75 @@ export const ACTIVITIES_NETWORK_DEFS = `<defs>
55
55
  </marker>
56
56
  </defs>`;
57
57
 
58
+ type ActivityLayoutNode = ActivitiesLayout['nodes'][number];
59
+
60
+ /**
61
+ * Nodes horizontally between `source` and `target` (a "skip" edge passes over
62
+ * their column) whose vertical span overlaps the edge's Y band. A
63
+ * straight-tangent cubic (`horizontalCubicEdgePath`) stays within
64
+ * [min(sourceCenterY,targetCenterY), max(...)] by convex hull, so any such
65
+ * node sits directly on the edge's path — the "arrow goes straight through
66
+ * the node" bug reported against a linear PSND chain where a multi-predecessor
67
+ * activity's nearer predecessor shares a row with an intermediate column.
68
+ */
69
+ function blockingNodes(
70
+ nodes: ActivityLayoutNode[],
71
+ source: ActivityLayoutNode,
72
+ target: ActivityLayoutNode,
73
+ ): ActivityLayoutNode[] {
74
+ const sCenterY = source.y + source.height / 2;
75
+ const tCenterY = target.y + target.height / 2;
76
+ const loY = Math.min(sCenterY, tCenterY);
77
+ const hiY = Math.max(sCenterY, tCenterY);
78
+ const sRight = source.x + source.width;
79
+ const tLeft = target.x;
80
+ return nodes.filter((n) => {
81
+ if (n.id === source.id || n.id === target.id) return false;
82
+ if (n.x + n.width <= sRight || n.x >= tLeft) return false; // not between the two columns
83
+ return n.y < hiY && n.y + n.height > loY; // vertical overlap with the edge's band
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Pre-offset bow-control-point Y for a skip edge arcing over `blockers`: one
89
+ * full node-height of clearance above the topmost blocker's top edge. A
90
+ * cubic's apex only reaches part-way toward its control point (see
91
+ * `bowedCubicEdgePath` doc), so this generous margin is what actually keeps
92
+ * the visible curve clear of the box — verified numerically in
93
+ * `render-activities.test.ts`.
94
+ */
95
+ function bowY(blockers: ActivityLayoutNode[]): number {
96
+ const topY = Math.min(...blockers.map((n) => n.y));
97
+ const clearance = Math.max(...blockers.map((n) => n.height));
98
+ return topY - clearance;
99
+ }
100
+
101
+ /**
102
+ * Extra top padding (px) a network-view canvas must add so skip-edge bows
103
+ * (see `blockingNodes`/`bowY` above) never rise above the diagram's own node
104
+ * bounds (`layout.bounds.y`). Node rows shift down by the returned amount to
105
+ * make room for the bow within the diagram area; the title block — drawn
106
+ * separately, above that area, at a fixed position independent of this pad —
107
+ * is left undisturbed, so the bow can never climb into the reserved title
108
+ * zone (the earlier version floored against the canvas's literal y=0, which
109
+ * let a tall bow draw straight through the title text sitting between 0 and
110
+ * the diagram's un-padded top).
111
+ */
112
+ export function computeNetworkTopPad(layout: ActivitiesLayout): number {
113
+ const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
114
+ let pad = 0;
115
+ for (const e of layout.edges) {
116
+ const s = nodeMap.get(e.sourceId);
117
+ const t = nodeMap.get(e.targetId);
118
+ if (!s || !t) continue;
119
+ const blockers = blockingNodes(layout.nodes, s, t);
120
+ if (blockers.length === 0) continue;
121
+ const shortfall = layout.bounds.y - bowY(blockers);
122
+ if (shortfall > pad) pad = shortfall;
123
+ }
124
+ return pad;
125
+ }
126
+
58
127
  function activityNodeSvg(
59
128
  n: ActivitiesLayout['nodes'][number],
60
129
  x: number,
@@ -119,7 +188,11 @@ export function renderActivitiesNetworkBody(
119
188
  const ty = t.y + oy + t.height / 2;
120
189
  const cls = e.isCritical ? 'diagram-edge critical-edge' : 'diagram-edge';
121
190
  const marker = `url(#${e.isCritical ? 'arrow-crit' : 'arrow'})`;
122
- return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature)}" class="${cls}" marker-end="${marker}"/>`;
191
+ const blockers = blockingNodes(layout.nodes, s, t);
192
+ const d = blockers.length > 0
193
+ ? bowedCubicEdgePath(sx, sy, tx, ty, bowY(blockers) + oy, curvature)
194
+ : horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
195
+ return `<path d="${d}" class="${cls}" marker-end="${marker}"/>`;
123
196
  })
124
197
  .join('\n');
125
198
 
@@ -207,10 +280,12 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
207
280
 
208
281
  const cpm = computeCpm(renderDoc.activities ?? []);
209
282
  const titleH = title ? 24 : 0;
283
+ const oyBase = -layout.bounds.y + N_PAD + titleH;
284
+ const topPad = computeNetworkTopPad(layout);
210
285
  const w = layout.bounds.width + N_PAD * 2;
211
- const h = layout.bounds.height + N_PAD * 2 + titleH;
286
+ const h = layout.bounds.height + N_PAD * 2 + titleH + topPad;
212
287
  const ox = -layout.bounds.x + N_PAD;
213
- const oy = -layout.bounds.y + N_PAD + titleH;
288
+ const oy = oyBase + topPad;
214
289
 
215
290
  const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
216
291