@transitrix/diagrams 1.8.18 → 1.8.19

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.
@@ -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,15 @@ 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 draw above the SVG's y=0 and get
16
+ * clipped. `oyBase` is the canvas's Y offset *before* this padding — the
17
+ * historical `-bounds.y + N_PAD + titleH` both hosts compute — since a bow
18
+ * near the topmost row can reach above the diagram's own node bounds.
19
+ * Callers add the returned value to both `oyBase` and the canvas height.
20
+ */
21
+ export declare function computeNetworkTopPad(layout: ActivitiesLayout, oyBase: number): number;
13
22
  /**
14
23
  * Canonical Network (PSND) body — the node rects and edge paths that go inside
15
24
  * 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,68 @@ 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 draw above the SVG's y=0 and get
89
+ * clipped. `oyBase` is the canvas's Y offset *before* this padding — the
90
+ * historical `-bounds.y + N_PAD + titleH` both hosts compute — since a bow
91
+ * near the topmost row can reach above the diagram's own node bounds.
92
+ * Callers add the returned value to both `oyBase` and the canvas height.
93
+ */
94
+ export function computeNetworkTopPad(layout, oyBase) {
95
+ const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
96
+ let pad = 0;
97
+ for (const e of layout.edges) {
98
+ const s = nodeMap.get(e.sourceId);
99
+ const t = nodeMap.get(e.targetId);
100
+ if (!s || !t)
101
+ continue;
102
+ const blockers = blockingNodes(layout.nodes, s, t);
103
+ if (blockers.length === 0)
104
+ continue;
105
+ const shortfall = -(oyBase + bowY(blockers));
106
+ if (shortfall > pad)
107
+ pad = shortfall;
108
+ }
109
+ return pad;
110
+ }
49
111
  function activityNodeSvg(n, x, y, isCritical, isMilestone, durLabel) {
50
112
  const cls = `diagram-node act-node ${isCritical ? 'critical-node' : ''} ${isMilestone ? 'milestone-node' : ''}`.trim();
51
113
  const specs = layoutCenteredEntityText({
@@ -92,7 +154,11 @@ export function renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entr
92
154
  const ty = t.y + oy + t.height / 2;
93
155
  const cls = e.isCritical ? 'diagram-edge critical-edge' : 'diagram-edge';
94
156
  const marker = `url(#${e.isCritical ? 'arrow-crit' : 'arrow'})`;
95
- return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature)}" class="${cls}" marker-end="${marker}"/>`;
157
+ const blockers = blockingNodes(layout.nodes, s, t);
158
+ const d = blockers.length > 0
159
+ ? bowedCubicEdgePath(sx, sy, tx, ty, bowY(blockers) + oy, curvature)
160
+ : horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
161
+ return `<path d="${d}" class="${cls}" marker-end="${marker}"/>`;
96
162
  })
97
163
  .join('\n');
98
164
  const nodeSvg = layout.nodes
@@ -142,10 +208,12 @@ export function renderActivitiesSvg(doc, options = {}) {
142
208
  }
143
209
  const cpm = computeCpm(renderDoc.activities ?? []);
144
210
  const titleH = title ? 24 : 0;
211
+ const oyBase = -layout.bounds.y + N_PAD + titleH;
212
+ const topPad = computeNetworkTopPad(layout, oyBase);
145
213
  const w = layout.bounds.width + N_PAD * 2;
146
- const h = layout.bounds.height + N_PAD * 2 + titleH;
214
+ const h = layout.bounds.height + N_PAD * 2 + titleH + topPad;
147
215
  const ox = -layout.bounds.x + N_PAD;
148
- const oy = -layout.bounds.y + N_PAD + titleH;
216
+ const oy = oyBase + topPad;
149
217
  const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
150
218
  const titleSvg = title
151
219
  ? `<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.19",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
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,72 @@ 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 draw above the SVG's y=0 and get
104
+ * clipped. `oyBase` is the canvas's Y offset *before* this padding — the
105
+ * historical `-bounds.y + N_PAD + titleH` both hosts compute — since a bow
106
+ * near the topmost row can reach above the diagram's own node bounds.
107
+ * Callers add the returned value to both `oyBase` and the canvas height.
108
+ */
109
+ export function computeNetworkTopPad(layout: ActivitiesLayout, oyBase: number): number {
110
+ const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
111
+ let pad = 0;
112
+ for (const e of layout.edges) {
113
+ const s = nodeMap.get(e.sourceId);
114
+ const t = nodeMap.get(e.targetId);
115
+ if (!s || !t) continue;
116
+ const blockers = blockingNodes(layout.nodes, s, t);
117
+ if (blockers.length === 0) continue;
118
+ const shortfall = -(oyBase + bowY(blockers));
119
+ if (shortfall > pad) pad = shortfall;
120
+ }
121
+ return pad;
122
+ }
123
+
58
124
  function activityNodeSvg(
59
125
  n: ActivitiesLayout['nodes'][number],
60
126
  x: number,
@@ -119,7 +185,11 @@ export function renderActivitiesNetworkBody(
119
185
  const ty = t.y + oy + t.height / 2;
120
186
  const cls = e.isCritical ? 'diagram-edge critical-edge' : 'diagram-edge';
121
187
  const marker = `url(#${e.isCritical ? 'arrow-crit' : 'arrow'})`;
122
- return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature)}" class="${cls}" marker-end="${marker}"/>`;
188
+ const blockers = blockingNodes(layout.nodes, s, t);
189
+ const d = blockers.length > 0
190
+ ? bowedCubicEdgePath(sx, sy, tx, ty, bowY(blockers) + oy, curvature)
191
+ : horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature);
192
+ return `<path d="${d}" class="${cls}" marker-end="${marker}"/>`;
123
193
  })
124
194
  .join('\n');
125
195
 
@@ -207,10 +277,12 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
207
277
 
208
278
  const cpm = computeCpm(renderDoc.activities ?? []);
209
279
  const titleH = title ? 24 : 0;
280
+ const oyBase = -layout.bounds.y + N_PAD + titleH;
281
+ const topPad = computeNetworkTopPad(layout, oyBase);
210
282
  const w = layout.bounds.width + N_PAD * 2;
211
- const h = layout.bounds.height + N_PAD * 2 + titleH;
283
+ const h = layout.bounds.height + N_PAD * 2 + titleH + topPad;
212
284
  const ox = -layout.bounds.x + N_PAD;
213
- const oy = -layout.bounds.y + N_PAD + titleH;
285
+ const oy = oyBase + topPad;
214
286
 
215
287
  const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
216
288