@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.
- package/dist/blocks/validate.d.ts +16 -0
- package/dist/blocks/validate.js +38 -6
- package/dist/edge-path.d.ts +15 -0
- package/dist/edge-path.js +19 -0
- package/dist/webview/render-activities.d.ts +12 -0
- package/dist/webview/render-activities.js +75 -4
- package/package.json +1 -1
- package/src/blocks/validate.ts +38 -6
- package/src/edge-path.ts +27 -0
- package/src/webview/render-activities.ts +79 -4
|
@@ -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;
|
package/dist/blocks/validate.js
CHANGED
|
@@ -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
|
-
/**
|
|
22
|
-
|
|
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
|
-
'
|
|
34
|
-
'
|
|
50
|
+
'ACTOR',
|
|
51
|
+
'LOCATION',
|
|
52
|
+
'BUSINESS_SERVICE',
|
|
35
53
|
'SCENARIO',
|
|
36
|
-
'
|
|
37
|
-
'
|
|
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+;
|
package/dist/edge-path.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
package/src/blocks/validate.ts
CHANGED
|
@@ -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
|
-
/**
|
|
49
|
-
|
|
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
|
-
'
|
|
61
|
-
'
|
|
77
|
+
'ACTOR',
|
|
78
|
+
'LOCATION',
|
|
79
|
+
'BUSINESS_SERVICE',
|
|
62
80
|
'SCENARIO',
|
|
63
|
-
'
|
|
64
|
-
'
|
|
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
|
-
|
|
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 =
|
|
288
|
+
const oy = oyBase + topPad;
|
|
214
289
|
|
|
215
290
|
const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
|
|
216
291
|
|