@transitrix/diagrams 1.3.0 → 1.5.0
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/__tests__/yaml-normalize.test.js +9 -9
- package/dist/activities/__tests__/gantt.test.js +39 -0
- package/dist/activities/__tests__/validate.test.js +77 -74
- package/dist/activities/validate.js +5 -12
- package/dist/activity-card/__tests__/example.test.js +2 -2
- package/dist/activity-card/__tests__/validate.test.js +29 -18
- package/dist/activity-card/validate.js +3 -10
- package/dist/actor/__tests__/validate.test.js +6 -0
- package/dist/actor/types.d.ts +1 -1
- package/dist/actor/types.js +2 -0
- package/dist/applications/types.d.ts +7 -0
- package/dist/applications/validate.js +18 -0
- package/dist/blocks/__tests__/layout.test.js +1 -1
- package/dist/blocks/layout.js +1 -1
- package/dist/business-service/__tests__/validate.test.d.ts +1 -0
- package/dist/business-service/__tests__/validate.test.js +135 -0
- package/dist/business-service/index.d.ts +3 -0
- package/dist/business-service/index.js +2 -0
- package/dist/business-service/types.d.ts +20 -0
- package/dist/business-service/types.js +3 -0
- package/dist/business-service/validate.d.ts +2 -0
- package/dist/business-service/validate.js +74 -0
- package/dist/fgca/__tests__/parse-canonical.test.js +10 -10
- package/dist/fgca/__tests__/resolver.test.js +7 -7
- package/dist/fgca/__tests__/validate.test.js +11 -11
- package/dist/fgca/parse-canonical.js +7 -11
- package/dist/fgca/resolver.d.ts +2 -2
- package/dist/fgca/resolver.js +5 -5
- package/dist/fgca/validate.js +4 -13
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/integration/__tests__/validate.test.d.ts +1 -0
- package/dist/integration/__tests__/validate.test.js +169 -0
- package/dist/integration/index.d.ts +2 -0
- package/dist/integration/index.js +1 -0
- package/dist/integration/types.d.ts +27 -0
- package/dist/integration/types.js +5 -0
- package/dist/integration/validate.d.ts +2 -0
- package/dist/integration/validate.js +94 -0
- package/dist/location/__tests__/validate.test.d.ts +1 -0
- package/dist/location/__tests__/validate.test.js +107 -0
- package/dist/location/index.d.ts +3 -0
- package/dist/location/index.js +2 -0
- package/dist/location/types.d.ts +20 -0
- package/dist/location/types.js +3 -0
- package/dist/location/validate.d.ts +2 -0
- package/dist/location/validate.js +63 -0
- package/dist/node/__tests__/validate.test.d.ts +1 -0
- package/dist/node/__tests__/validate.test.js +100 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +1 -0
- package/dist/node/types.d.ts +18 -0
- package/dist/node/types.js +3 -0
- package/dist/node/validate.d.ts +2 -0
- package/dist/node/validate.js +53 -0
- package/dist/repo-validate/__tests__/validate-repo.test.js +444 -0
- package/dist/repo-validate/validate-repo.js +224 -10
- package/dist/technology-service/__tests__/validate.test.d.ts +1 -0
- package/dist/technology-service/__tests__/validate.test.js +121 -0
- package/dist/technology-service/index.d.ts +2 -0
- package/dist/technology-service/index.js +1 -0
- package/dist/technology-service/types.d.ts +18 -0
- package/dist/technology-service/types.js +3 -0
- package/dist/technology-service/validate.d.ts +2 -0
- package/dist/technology-service/validate.js +64 -0
- package/dist/webview/__tests__/entry-notations.test.js +2 -4
- package/dist/webview/__tests__/render-activities.test.d.ts +1 -0
- package/dist/webview/__tests__/render-activities.test.js +91 -0
- package/dist/webview/__tests__/render-blocks.test.d.ts +1 -0
- package/dist/webview/__tests__/render-blocks.test.js +178 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +8 -15
- package/dist/webview/notation-style.d.ts +11 -0
- package/dist/webview/notation-style.js +11 -0
- package/dist/webview/render-activities.d.ts +12 -0
- package/dist/webview/render-activities.js +9 -3
- package/dist/webview/render-blocks.js +102 -19
- package/dist/webview/render-goals.js +2 -1
- package/package.json +1 -1
- package/src/__tests__/yaml-normalize.test.ts +11 -11
- package/src/activities/__tests__/gantt.test.ts +45 -0
- package/src/activities/__tests__/validate.test.ts +77 -74
- package/src/activities/validate.ts +5 -12
- package/src/activity-card/__tests__/example.test.ts +2 -2
- package/src/activity-card/__tests__/validate.test.ts +30 -18
- package/src/activity-card/validate.ts +3 -9
- package/src/actor/__tests__/validate.test.ts +8 -0
- package/src/actor/types.ts +2 -0
- package/src/applications/types.ts +8 -0
- package/src/applications/validate.ts +20 -0
- package/src/blocks/__tests__/layout.test.ts +1 -1
- package/src/blocks/layout.ts +1 -1
- package/src/business-service/__tests__/validate.test.ts +160 -0
- package/src/business-service/index.ts +3 -0
- package/src/business-service/types.ts +29 -0
- package/src/business-service/validate.ts +82 -0
- package/src/fgca/__tests__/parse-canonical.test.ts +10 -10
- package/src/fgca/__tests__/resolver.test.ts +7 -7
- package/src/fgca/__tests__/validate.test.ts +11 -11
- package/src/fgca/parse-canonical.ts +7 -11
- package/src/fgca/resolver.ts +5 -5
- package/src/fgca/validate.ts +4 -12
- package/src/index.ts +5 -0
- package/src/integration/__tests__/validate.test.ts +203 -0
- package/src/integration/index.ts +2 -0
- package/src/integration/types.ts +38 -0
- package/src/integration/validate.ts +104 -0
- package/src/location/__tests__/validate.test.ts +127 -0
- package/src/location/index.ts +3 -0
- package/src/location/types.ts +29 -0
- package/src/location/validate.ts +70 -0
- package/src/node/__tests__/validate.test.ts +118 -0
- package/src/node/index.ts +2 -0
- package/src/node/types.ts +27 -0
- package/src/node/validate.ts +59 -0
- package/src/repo-validate/__tests__/validate-repo.test.ts +550 -0
- package/src/repo-validate/validate-repo.ts +233 -10
- package/src/technology-service/__tests__/validate.test.ts +143 -0
- package/src/technology-service/index.ts +2 -0
- package/src/technology-service/types.ts +27 -0
- package/src/technology-service/validate.ts +71 -0
- package/src/webview/__tests__/entry-notations.test.ts +2 -4
- package/src/webview/__tests__/render-activities.test.ts +100 -0
- package/src/webview/__tests__/render-blocks.test.ts +209 -0
- package/src/webview/entry.ts +10 -19
- package/src/webview/notation-style.ts +12 -0
- package/src/webview/render-activities.ts +29 -4
- package/src/webview/render-blocks.ts +106 -21
- package/src/webview/render-goals.ts +2 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the host-neutral nested-blocks SVG renderer used by both the
|
|
3
|
+
* VS Code blocks preview and the IntelliJ JCEF webview bundle. These tests pin
|
|
4
|
+
* the per-node label rule (CLAUDE.md "Entity block layout"): leaf nodes wrap
|
|
5
|
+
* the name to at most 3 lines and the ID to at most 2 lines, with `…`
|
|
6
|
+
* truncation beyond that; container headers stack name + ID, each truncated
|
|
7
|
+
* with `…` to fit the block width. No label may extend past the rect.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { renderBlocksSvg } from '../render-blocks.js';
|
|
11
|
+
// Approximate character widths used by the renderer when sizing labels.
|
|
12
|
+
// Kept in sync with `CHAR_W` / `CHAR_W_ID` in render-blocks.ts so the test
|
|
13
|
+
// can compute the same overflow bounds the renderer uses.
|
|
14
|
+
const CHAR_W = 7;
|
|
15
|
+
const CHAR_W_ID = 6;
|
|
16
|
+
// Same horizontal margin the renderer reserves so text never abuts the rect.
|
|
17
|
+
const TEXT_MARGIN_X = 8;
|
|
18
|
+
function leafDoc(name, id) {
|
|
19
|
+
return {
|
|
20
|
+
notation: 'blocks',
|
|
21
|
+
spec_version: '0.1',
|
|
22
|
+
nested_blocks: {
|
|
23
|
+
id: 'BLOCKS-T-1',
|
|
24
|
+
name: 'Test',
|
|
25
|
+
blocks: [{ id, name }],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function containerDoc(name, id, childId = 'CHILD', childName = 'Child') {
|
|
30
|
+
return {
|
|
31
|
+
notation: 'blocks',
|
|
32
|
+
spec_version: '0.1',
|
|
33
|
+
nested_blocks: {
|
|
34
|
+
id: 'BLOCKS-T-1',
|
|
35
|
+
name: 'Test',
|
|
36
|
+
blocks: [
|
|
37
|
+
{
|
|
38
|
+
id,
|
|
39
|
+
name,
|
|
40
|
+
children: [{ id: childId, name: childName }],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Pull every text-primary / text-id line out of an SVG, in document order. */
|
|
47
|
+
function extractLabels(svg) {
|
|
48
|
+
const re = /<text class="(text-(?:primary|id))"[^>]*>([^<]*)<\/text>/g;
|
|
49
|
+
const out = [];
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(svg)))
|
|
52
|
+
out.push({ cls: m[1], text: m[2] });
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** Extract {class, y} for each text element in SVG document order. */
|
|
56
|
+
function extractTextY(svg) {
|
|
57
|
+
const re = /<text class="(text-(?:primary|id))"[^>]*\by="([^"]+)"/g;
|
|
58
|
+
const out = [];
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = re.exec(svg)))
|
|
61
|
+
out.push({ cls: m[1], y: parseFloat(m[2]) });
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
describe('renderBlocksSvg — leaf labels', () => {
|
|
65
|
+
it('wraps a long name to at most 3 lines and truncates with …', () => {
|
|
66
|
+
const longName = 'Personal data records that linger after consent withdrawal across many systems and ' +
|
|
67
|
+
'archives — known erasure gap';
|
|
68
|
+
const svg = renderBlocksSvg(leafDoc(longName, 'PII'));
|
|
69
|
+
const nameLines = extractLabels(svg).filter((l) => l.cls === 'text-primary');
|
|
70
|
+
expect(nameLines.length).toBeGreaterThan(0);
|
|
71
|
+
expect(nameLines.length).toBeLessThanOrEqual(3);
|
|
72
|
+
// If the name needed truncation, the final visible line must end with `…`.
|
|
73
|
+
if (nameLines.length === 3) {
|
|
74
|
+
expect(nameLines[2].text.endsWith('…')).toBe(true);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('wraps a long ID to at most 2 lines and truncates with …', () => {
|
|
78
|
+
const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE';
|
|
79
|
+
const svg = renderBlocksSvg(leafDoc('Short name', longId));
|
|
80
|
+
const idLines = extractLabels(svg).filter((l) => l.cls === 'text-id');
|
|
81
|
+
expect(idLines.length).toBeGreaterThan(0);
|
|
82
|
+
expect(idLines.length).toBeLessThanOrEqual(2);
|
|
83
|
+
if (idLines.length === 2) {
|
|
84
|
+
expect(idLines[1].text.endsWith('…')).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
it('keeps every label inside the 160px leaf width (approximated by CHAR_W * length)', () => {
|
|
88
|
+
const svg = renderBlocksSvg(leafDoc('Personal data records that linger after consent withdrawal across many systems', 'PII_RECORDS_WITH_A_FAIRLY_LONG_IDENTIFIER_HERE'));
|
|
89
|
+
const labels = extractLabels(svg);
|
|
90
|
+
const inner = 160 - TEXT_MARGIN_X * 2;
|
|
91
|
+
for (const l of labels) {
|
|
92
|
+
const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
|
|
93
|
+
expect(l.text.length * cw).toBeLessThanOrEqual(inner);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('renderBlocksSvg — container headers', () => {
|
|
98
|
+
it('renders the name and ID as two separate stacked text elements', () => {
|
|
99
|
+
const svg = renderBlocksSvg(containerDoc('Active systems', 'ACTIVE_SYSTEMS'));
|
|
100
|
+
const labels = extractLabels(svg);
|
|
101
|
+
// Container header contributes one text-primary (name) and one text-id (id);
|
|
102
|
+
// the leaf child contributes another pair. So 2 of each kind in total.
|
|
103
|
+
const primaries = labels.filter((l) => l.cls === 'text-primary').map((l) => l.text);
|
|
104
|
+
const ids = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
|
|
105
|
+
expect(primaries).toContain('Active systems');
|
|
106
|
+
expect(ids).toContain('ACTIVE_SYSTEMS');
|
|
107
|
+
// The legacy "(id)" suffix on the name line must not appear — id has its own row.
|
|
108
|
+
for (const t of primaries)
|
|
109
|
+
expect(t).not.toContain('(');
|
|
110
|
+
});
|
|
111
|
+
it('truncates a long container ID so it does not overflow the block width', () => {
|
|
112
|
+
const svg = renderBlocksSvg(containerDoc('Short name', 'VERY_LONG_CONTAINER_IDENTIFIER_THAT_WOULD_OTHERWISE_OVERFLOW', 'CHILD', 'Child'));
|
|
113
|
+
const labels = extractLabels(svg);
|
|
114
|
+
// The container width derives from its single 160px child: 160 + 2*12 (padding) = 184.
|
|
115
|
+
const containerW = 160 + 24;
|
|
116
|
+
const inner = containerW - TEXT_MARGIN_X * 2;
|
|
117
|
+
for (const l of labels) {
|
|
118
|
+
const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
|
|
119
|
+
expect(l.text.length * cw).toBeLessThanOrEqual(inner);
|
|
120
|
+
}
|
|
121
|
+
// The full ID was too long to fit on one line of `inner / CHAR_W_ID` chars, so
|
|
122
|
+
// the rendered ID must be the truncated form ending in `…`.
|
|
123
|
+
const renderedIds = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
|
|
124
|
+
expect(renderedIds.some((t) => t.endsWith('…'))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
it('truncates a long container name with … to fit the block width', () => {
|
|
127
|
+
const longName = 'Active systems that are erasure-reachable within the standard 30-day operational window';
|
|
128
|
+
const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
|
|
129
|
+
const labels = extractLabels(svg);
|
|
130
|
+
const containerW = 160 + 24;
|
|
131
|
+
const inner = containerW - TEXT_MARGIN_X * 2;
|
|
132
|
+
const primaries = labels.filter((l) => l.cls === 'text-primary');
|
|
133
|
+
for (const l of primaries) {
|
|
134
|
+
expect(l.text.length * CHAR_W).toBeLessThanOrEqual(inner);
|
|
135
|
+
}
|
|
136
|
+
// At least one primary line must have been truncated.
|
|
137
|
+
expect(primaries.some((l) => l.text.endsWith('…'))).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('renderBlocksSvg — name/ID vertical overlap regression (follows-up on #419)', () => {
|
|
141
|
+
// Root cause: NAME_ID_GAP was 6 px. text-primary is 12 px tall (±6 px around
|
|
142
|
+
// centre) and text-id is 10 px tall (±5 px around centre). The minimum gap
|
|
143
|
+
// between centres to avoid overlap is 11 px; a gap of 6 caused 5 px of overlap.
|
|
144
|
+
// Fix: NAME_ID_GAP = 14 (3 px visual buffer, matching the gap between name lines).
|
|
145
|
+
const NAME_HALF_H = 6; // half of text-primary font size (12 px)
|
|
146
|
+
const ID_HALF_H = 5; // half of text-id font size (10 px)
|
|
147
|
+
it('leaf node: last name line bottom does not overlap first ID line top (1 name + 1 ID)', () => {
|
|
148
|
+
const svg = renderBlocksSvg(leafDoc('Short', 'BLK-1'));
|
|
149
|
+
const positions = extractTextY(svg);
|
|
150
|
+
const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
|
|
151
|
+
const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
|
|
152
|
+
expect(nameY.length).toBeGreaterThan(0);
|
|
153
|
+
expect(idY.length).toBeGreaterThan(0);
|
|
154
|
+
const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
|
|
155
|
+
const firstIdTop = Math.min(...idY) - ID_HALF_H;
|
|
156
|
+
expect(firstIdTop).toBeGreaterThan(lastNameBottom);
|
|
157
|
+
});
|
|
158
|
+
it('leaf node: no overlap with a 3-word name (2 name lines + 1 ID)', () => {
|
|
159
|
+
const svg = renderBlocksSvg(leafDoc('Three Word Name', 'BLK-001'));
|
|
160
|
+
const positions = extractTextY(svg);
|
|
161
|
+
const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
|
|
162
|
+
const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
|
|
163
|
+
const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
|
|
164
|
+
const firstIdTop = Math.min(...idY) - ID_HALF_H;
|
|
165
|
+
expect(firstIdTop).toBeGreaterThan(lastNameBottom);
|
|
166
|
+
});
|
|
167
|
+
it('leaf node: no overlap with a long name (3 name lines) and wrapped ID (2 lines)', () => {
|
|
168
|
+
const longName = 'Personal data records that linger after consent withdrawal across many systems and archives';
|
|
169
|
+
const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE';
|
|
170
|
+
const svg = renderBlocksSvg(leafDoc(longName, longId));
|
|
171
|
+
const positions = extractTextY(svg);
|
|
172
|
+
const nameY = positions.filter(p => p.cls === 'text-primary').map(p => p.y);
|
|
173
|
+
const idY = positions.filter(p => p.cls === 'text-id').map(p => p.y);
|
|
174
|
+
const lastNameBottom = Math.max(...nameY) + NAME_HALF_H;
|
|
175
|
+
const firstIdTop = Math.min(...idY) - ID_HALF_H;
|
|
176
|
+
expect(firstIdTop).toBeGreaterThan(lastNameBottom);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/dist/webview/entry.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export interface RenderResult {
|
|
|
27
27
|
* convention used by the VS Code extension's `activationEvents`. All twelve
|
|
28
28
|
* are wired to their validator + renderer as of Step 4.
|
|
29
29
|
*/
|
|
30
|
-
export type NotationKind = 'goals' | 'dgca' | 'dga' | '
|
|
30
|
+
export type NotationKind = 'goals' | 'dgca' | 'dga' | 'action' | 'action-card' | 'applications' | 'products' | 'process-map' | 'process-blueprint' | 'scenarios' | 'capability-map' | 'blocks';
|
|
31
31
|
export declare function render(notationKind: string, source: string): RenderResult;
|
|
32
32
|
export declare const api: {
|
|
33
33
|
version: string;
|
package/dist/webview/entry.js
CHANGED
|
@@ -48,10 +48,8 @@ const SUPPORTED_KINDS = [
|
|
|
48
48
|
'goals',
|
|
49
49
|
'dgca',
|
|
50
50
|
'dga',
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
'activities',
|
|
54
|
-
'activity-card',
|
|
51
|
+
'action',
|
|
52
|
+
'action-card',
|
|
55
53
|
'applications',
|
|
56
54
|
'products',
|
|
57
55
|
'process-map',
|
|
@@ -110,11 +108,7 @@ function dispatchValidate(kind, doc) {
|
|
|
110
108
|
}
|
|
111
109
|
return r;
|
|
112
110
|
}
|
|
113
|
-
|
|
114
|
-
// deprecated aliases. All four share the same parse + renderer; only the
|
|
115
|
-
// Change column visibility differs (DGA/FGA collapse it).
|
|
116
|
-
case 'dgca':
|
|
117
|
-
case 'fgca': {
|
|
111
|
+
case 'dgca': {
|
|
118
112
|
const v = parseCanonicalFGCA(doc);
|
|
119
113
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
120
114
|
r.errors.push(...v.errors);
|
|
@@ -124,8 +118,7 @@ function dispatchValidate(kind, doc) {
|
|
|
124
118
|
}
|
|
125
119
|
return r;
|
|
126
120
|
}
|
|
127
|
-
case 'dga':
|
|
128
|
-
case 'fga': {
|
|
121
|
+
case 'dga': {
|
|
129
122
|
const v = parseCanonicalFGA(doc);
|
|
130
123
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
131
124
|
r.errors.push(...v.errors);
|
|
@@ -135,10 +128,10 @@ function dispatchValidate(kind, doc) {
|
|
|
135
128
|
}
|
|
136
129
|
return r;
|
|
137
130
|
}
|
|
138
|
-
case '
|
|
139
|
-
return renderFromValidation('
|
|
140
|
-
case '
|
|
141
|
-
return renderFromValidation('
|
|
131
|
+
case 'action':
|
|
132
|
+
return renderFromValidation('action', validateActivities(doc), () => renderActivitiesSvg(doc));
|
|
133
|
+
case 'action-card':
|
|
134
|
+
return renderFromValidation('action-card', validateActivityCard(doc), () => renderActivityCardSvg(doc));
|
|
142
135
|
case 'process-blueprint':
|
|
143
136
|
return renderFromValidation('process-blueprint', validateProcessBlueprint(doc), () => renderProcessBlueprintSvg(doc));
|
|
144
137
|
case 'blocks':
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared visual constants for entity-box (block-style) notation renderers.
|
|
3
|
+
*
|
|
4
|
+
* Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
|
|
5
|
+
* entity-box visual language import their shared style tokens from here.
|
|
6
|
+
* Centralising them prevents the per-renderer drift that caused the Goals/Blocks
|
|
7
|
+
* style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere
|
|
8
|
+
* else). Change here and every notation updates together.
|
|
9
|
+
*/
|
|
10
|
+
/** Border-radius (px) for entity and leaf-block node rectangles. */
|
|
11
|
+
export declare const ENTITY_NODE_RX = 8;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared visual constants for entity-box (block-style) notation renderers.
|
|
3
|
+
*
|
|
4
|
+
* Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
|
|
5
|
+
* entity-box visual language import their shared style tokens from here.
|
|
6
|
+
* Centralising them prevents the per-renderer drift that caused the Goals/Blocks
|
|
7
|
+
* style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere
|
|
8
|
+
* else). Change here and every notation updates together.
|
|
9
|
+
*/
|
|
10
|
+
/** Border-radius (px) for entity and leaf-block node rectangles. */
|
|
11
|
+
export const ENTITY_NODE_RX = 8;
|
|
@@ -28,6 +28,18 @@ export interface RenderActivitiesOptions {
|
|
|
28
28
|
curvature?: number;
|
|
29
29
|
/** Entry curvature at the target node; defaults to `curvature` when omitted. */
|
|
30
30
|
entryCurvature?: number;
|
|
31
|
+
/**
|
|
32
|
+
* When true (the default), activities with `activity_type === 'project'` are
|
|
33
|
+
* excluded from the network layout. Project container nodes add visual noise
|
|
34
|
+
* in the PSND view because the diagram itself already represents the project
|
|
35
|
+
* scope; suppressing them declutters the network without altering canonical
|
|
36
|
+
* data. Set to false to render all activities regardless of type.
|
|
37
|
+
*
|
|
38
|
+
* Convention: Network/diagram views suppress project nodes by default.
|
|
39
|
+
* Text/document views (Tree) keep them visible and compensate with the
|
|
40
|
+
* Action name in the view header.
|
|
41
|
+
*/
|
|
42
|
+
suppressProjectNodes?: boolean;
|
|
31
43
|
}
|
|
32
44
|
/**
|
|
33
45
|
* Render the network (PSND) view of an already-validated ActivityDoc to a
|
|
@@ -132,12 +132,18 @@ export function renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entr
|
|
|
132
132
|
* than short-circuiting.
|
|
133
133
|
*/
|
|
134
134
|
export function renderActivitiesSvg(doc, options = {}) {
|
|
135
|
-
const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
|
|
136
|
-
|
|
135
|
+
const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, suppressProjectNodes = true, } = options;
|
|
136
|
+
// #421: Suppress project-type container nodes in the network layout by
|
|
137
|
+
// default (see suppressProjectNodes JSDoc). A shallow copy avoids mutating
|
|
138
|
+
// the caller's doc.
|
|
139
|
+
const renderDoc = suppressProjectNodes
|
|
140
|
+
? { ...doc, activities: (doc.activities ?? []).filter((a) => a.activity_type?.toLowerCase() !== 'project') }
|
|
141
|
+
: doc;
|
|
142
|
+
const layout = layoutActivities(renderDoc, gaps);
|
|
137
143
|
if (layout.nodes.length === 0) {
|
|
138
144
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
|
|
139
145
|
}
|
|
140
|
-
const cpm = computeCpm(
|
|
146
|
+
const cpm = computeCpm(renderDoc.activities ?? []);
|
|
141
147
|
const titleH = title ? 24 : 0;
|
|
142
148
|
const w = layout.bounds.width + N_PAD * 2;
|
|
143
149
|
const h = layout.bounds.height + N_PAD * 2 + titleH;
|
|
@@ -15,11 +15,61 @@
|
|
|
15
15
|
import { layoutNestedBlocks } from '../blocks/layout.js';
|
|
16
16
|
import { generateSvgEmbedCss } from '../theme/index.js';
|
|
17
17
|
import { escXml } from './render-util.js';
|
|
18
|
+
import { ENTITY_NODE_RX } from './notation-style.js';
|
|
18
19
|
const PAD = 24;
|
|
19
20
|
// Approximate character width (px) at text-primary size (12px/600).
|
|
20
21
|
const CHAR_W = 7;
|
|
21
|
-
// Approximate character width (px) at text-id size (10px/600)
|
|
22
|
+
// Approximate character width (px) at text-id size (10px/600).
|
|
22
23
|
const CHAR_W_ID = 6;
|
|
24
|
+
// Vertical spacing between line centres.
|
|
25
|
+
const LINE_H = 14; // name lines (text-primary, 12 px font)
|
|
26
|
+
const LINE_H_ID = 12; // id lines (text-id, 10 px font)
|
|
27
|
+
// Minimum gap to avoid overlap: half of name text height (6 px) + half of id
|
|
28
|
+
// text height (5 px) = 11 px. Use 14 px to match the name-line separation and
|
|
29
|
+
// leave a 3 px visual buffer — same as the gap between consecutive name lines.
|
|
30
|
+
const NAME_ID_GAP = 14;
|
|
31
|
+
/** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
|
|
32
|
+
function wrapWords(text, maxChars, maxLines) {
|
|
33
|
+
const safeMax = Math.max(2, maxChars);
|
|
34
|
+
const words = text.split(' ');
|
|
35
|
+
const lines = [];
|
|
36
|
+
let cur = '';
|
|
37
|
+
for (let i = 0; i < words.length; i++) {
|
|
38
|
+
const w = words[i];
|
|
39
|
+
const next = cur ? `${cur} ${w}` : w;
|
|
40
|
+
if (next.length <= safeMax) {
|
|
41
|
+
cur = next;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
if (cur)
|
|
45
|
+
lines.push(cur);
|
|
46
|
+
cur = '';
|
|
47
|
+
if (lines.length >= maxLines)
|
|
48
|
+
break;
|
|
49
|
+
if (lines.length === maxLines - 1) {
|
|
50
|
+
const rest = words.slice(i).join(' ');
|
|
51
|
+
lines.push(rest.length <= safeMax ? rest : rest.slice(0, safeMax - 1) + '…');
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
cur = w.length <= safeMax ? w : w.slice(0, safeMax - 1) + '…';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (cur && lines.length < maxLines)
|
|
58
|
+
lines.push(cur);
|
|
59
|
+
return lines.length ? lines : [text.slice(0, safeMax - 1) + '…'];
|
|
60
|
+
}
|
|
61
|
+
/** Split an ID (no spaces) across at most 2 lines, breaking at `_` or `-`. */
|
|
62
|
+
function wrapId(id, maxChars) {
|
|
63
|
+
if (id.length <= maxChars)
|
|
64
|
+
return [id];
|
|
65
|
+
const seg = id.slice(0, maxChars);
|
|
66
|
+
const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
|
|
67
|
+
const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
|
|
68
|
+
const isSep = id[cut] === '_' || id[cut] === '-';
|
|
69
|
+
const line1 = id.slice(0, cut);
|
|
70
|
+
const rest = id.slice(cut + (isSep ? 1 : 0));
|
|
71
|
+
return [line1, rest.length <= maxChars ? rest : rest.slice(0, maxChars - 1) + '…'];
|
|
72
|
+
}
|
|
23
73
|
/**
|
|
24
74
|
* Pick the diagram-frame level class for a block at the given depth.
|
|
25
75
|
*
|
|
@@ -32,34 +82,67 @@ function levelClassForDepth(depth) {
|
|
|
32
82
|
const idx = Math.min(Math.max(depth - 1, 0), 6);
|
|
33
83
|
return `level-${idx}`;
|
|
34
84
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
85
|
+
// Reserve a small horizontal margin so text never abuts the rounded corners.
|
|
86
|
+
const TEXT_MARGIN_X = 8;
|
|
87
|
+
/** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
|
|
88
|
+
function truncateLine(text, maxChars) {
|
|
89
|
+
const safeMax = Math.max(2, maxChars);
|
|
90
|
+
if (text.length <= safeMax)
|
|
38
91
|
return text;
|
|
39
|
-
const cut = text.slice(0,
|
|
92
|
+
const cut = text.slice(0, safeMax - 1);
|
|
40
93
|
const lastSpace = cut.lastIndexOf(' ');
|
|
41
|
-
return (lastSpace >
|
|
94
|
+
return (lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut) + '…';
|
|
95
|
+
}
|
|
96
|
+
/** Single-line ID truncation that prefers `_`/`-` separators before falling back to a hard cut. */
|
|
97
|
+
function truncateId(id, maxChars) {
|
|
98
|
+
const safeMax = Math.max(2, maxChars);
|
|
99
|
+
if (id.length <= safeMax)
|
|
100
|
+
return id;
|
|
101
|
+
const cut = id.slice(0, safeMax - 1);
|
|
102
|
+
const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
|
|
103
|
+
return (sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut) + '…';
|
|
42
104
|
}
|
|
43
105
|
function emitBlockSvg(b, ox, oy, parts) {
|
|
44
106
|
const cls = levelClassForDepth(b.depth);
|
|
45
107
|
const cx = b.x + ox + b.width / 2;
|
|
46
|
-
parts.push(`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="
|
|
108
|
+
parts.push(`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="${ENTITY_NODE_RX}"/>`);
|
|
109
|
+
const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
|
|
47
110
|
const isLeaf = b.children.length === 0;
|
|
48
111
|
if (isLeaf) {
|
|
49
|
-
// Leaf block: name
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
112
|
+
// Leaf block: name (up to 3 lines) then ID (up to 2 lines), centred vertically.
|
|
113
|
+
const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
|
|
114
|
+
const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
|
|
115
|
+
const nameLines = wrapWords(b.name, maxCharsName, 3);
|
|
116
|
+
const idLines = wrapId(b.id, maxCharsId);
|
|
117
|
+
const nameTotalSpan = (nameLines.length - 1) * LINE_H;
|
|
118
|
+
const idTotalSpan = (idLines.length - 1) * LINE_H_ID;
|
|
119
|
+
const totalSpan = nameTotalSpan + NAME_ID_GAP + idTotalSpan;
|
|
120
|
+
const firstY = Math.round(b.y + oy + (b.height - totalSpan) / 2);
|
|
121
|
+
for (let i = 0; i < nameLines.length; i++) {
|
|
122
|
+
parts.push(`<text class="text-primary" x="${cx}" y="${firstY + i * LINE_H}" text-anchor="middle" dominant-baseline="central">${escXml(nameLines[i])}</text>`);
|
|
123
|
+
}
|
|
124
|
+
const idFirstY = firstY + nameTotalSpan + NAME_ID_GAP;
|
|
125
|
+
for (let i = 0; i < idLines.length; i++) {
|
|
126
|
+
parts.push(`<text class="text-id" x="${cx}" y="${idFirstY + i * LINE_H_ID}" text-anchor="middle" dominant-baseline="central">${escXml(idLines[i])}</text>`);
|
|
127
|
+
}
|
|
54
128
|
}
|
|
55
129
|
else {
|
|
56
|
-
// Container block: name
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
130
|
+
// Container block: name (text-primary) above, ID (text-id, grey) below — both
|
|
131
|
+
// single-line, truncated with `…` to fit within `b.width`. The header strip is
|
|
132
|
+
// only `headerHeight` tall (default 28px), which is enough for one line each at
|
|
133
|
+
// the configured text sizes. Stacking the ID as its own text-id element keeps
|
|
134
|
+
// the wrapping/truncation rule consistent with leaves and guarantees no overflow
|
|
135
|
+
// for any combination of name/ID length.
|
|
136
|
+
const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
|
|
137
|
+
const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
|
|
138
|
+
const nameText = truncateLine(b.name, maxCharsName);
|
|
139
|
+
const idText = truncateId(b.id, maxCharsId);
|
|
140
|
+
const headerMid = b.y + oy + b.headerHeight / 2;
|
|
141
|
+
const halfSpan = (LINE_H + LINE_H_ID) / 4;
|
|
142
|
+
const nameY = Math.round(headerMid - halfSpan);
|
|
143
|
+
const idY = Math.round(headerMid + halfSpan);
|
|
144
|
+
parts.push(`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}</text>`);
|
|
145
|
+
parts.push(`<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>`);
|
|
63
146
|
for (const c of b.children)
|
|
64
147
|
emitBlockSvg(c, ox, oy, parts);
|
|
65
148
|
}
|
|
@@ -14,6 +14,7 @@ import { layoutGoalTree } from '../goals/layout.js';
|
|
|
14
14
|
import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
|
|
15
15
|
import { generateSvgEmbedCss } from '../theme/index.js';
|
|
16
16
|
import { escXml } from './render-util.js';
|
|
17
|
+
import { ENTITY_NODE_RX } from './notation-style.js';
|
|
17
18
|
const NODE_W = 250;
|
|
18
19
|
const NODE_H = 72;
|
|
19
20
|
const RANK_SEP = 100;
|
|
@@ -103,7 +104,7 @@ export function renderGoalsLayoutSvg(layout, options = {}) {
|
|
|
103
104
|
const typeLabel = n.data.type ?? '';
|
|
104
105
|
const idText = String(n.data.id);
|
|
105
106
|
return `<g>
|
|
106
|
-
<rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="
|
|
107
|
+
<rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
|
|
107
108
|
<text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
|
|
108
109
|
<text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
|
|
109
110
|
<text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}
|
package/package.json
CHANGED
|
@@ -61,8 +61,8 @@ describe('coerceDatesToIsoStrings', () => {
|
|
|
61
61
|
describe('coerceDatesToIsoStrings — js-yaml integration', () => {
|
|
62
62
|
it('quoted ISO dates are preserved untouched', () => {
|
|
63
63
|
const text = [
|
|
64
|
-
'notation:
|
|
65
|
-
'
|
|
64
|
+
'notation: action',
|
|
65
|
+
'actions:',
|
|
66
66
|
' - id: "A-001"',
|
|
67
67
|
' name: "Quoted dates"',
|
|
68
68
|
' duration: 3',
|
|
@@ -70,18 +70,18 @@ describe('coerceDatesToIsoStrings — js-yaml integration', () => {
|
|
|
70
70
|
' end_date: "2026-06-03"',
|
|
71
71
|
'',
|
|
72
72
|
].join('\n');
|
|
73
|
-
const parsed = yaml.load(text) as {
|
|
73
|
+
const parsed = yaml.load(text) as { actions: { start_date: unknown; end_date: unknown }[] };
|
|
74
74
|
coerceDatesToIsoStrings(parsed);
|
|
75
|
-
expect(parsed.
|
|
76
|
-
expect(parsed.
|
|
75
|
+
expect(parsed.actions[0].start_date).toBe('2026-06-01');
|
|
76
|
+
expect(parsed.actions[0].end_date).toBe('2026-06-03');
|
|
77
77
|
const v = validateActivities(parsed);
|
|
78
78
|
expect(v.valid).toBe(true);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it('bare ISO dates are coerced to ISO strings and pass validation', () => {
|
|
82
82
|
const text = [
|
|
83
|
-
'notation:
|
|
84
|
-
'
|
|
83
|
+
'notation: action',
|
|
84
|
+
'actions:',
|
|
85
85
|
' - id: "A-001"',
|
|
86
86
|
' name: "Bare dates"',
|
|
87
87
|
' duration: 3',
|
|
@@ -89,14 +89,14 @@ describe('coerceDatesToIsoStrings — js-yaml integration', () => {
|
|
|
89
89
|
' end_date: 2026-06-03',
|
|
90
90
|
'',
|
|
91
91
|
].join('\n');
|
|
92
|
-
const parsed = yaml.load(text) as {
|
|
92
|
+
const parsed = yaml.load(text) as { actions: { start_date: unknown; end_date: unknown }[] };
|
|
93
93
|
// Without coercion, js-yaml returns Date instances and the validator
|
|
94
94
|
// would emit ACT-008 because typeof !== 'string'. Sanity-check that
|
|
95
95
|
// assumption before normalisation.
|
|
96
|
-
expect(parsed.
|
|
96
|
+
expect(parsed.actions[0].start_date).toBeInstanceOf(Date);
|
|
97
97
|
coerceDatesToIsoStrings(parsed);
|
|
98
|
-
expect(parsed.
|
|
99
|
-
expect(parsed.
|
|
98
|
+
expect(parsed.actions[0].start_date).toBe('2026-06-01');
|
|
99
|
+
expect(parsed.actions[0].end_date).toBe('2026-06-03');
|
|
100
100
|
const v = validateActivities(parsed);
|
|
101
101
|
expect(v.valid).toBe(true);
|
|
102
102
|
});
|
|
@@ -243,3 +243,48 @@ describe('computeGanttLayout — pinned mode', () => {
|
|
|
243
243
|
expect(r.links[0].targetId).toBe('B');
|
|
244
244
|
});
|
|
245
245
|
});
|
|
246
|
+
|
|
247
|
+
describe('computeGanttLayout — project-type activity suppression (regression for follow-up to #421)', () => {
|
|
248
|
+
// A project-type container activity is classified as a phase and rolls up into
|
|
249
|
+
// a bar spanning the full timeline. buildActivityViews filters project-type
|
|
250
|
+
// activities before calling computeGanttLayout so this bar never reaches the
|
|
251
|
+
// rendered Gantt. These tests confirm: (a) the container bar exists when NOT
|
|
252
|
+
// filtered, and (b) filtering it out still yields correct leaf/phase bars.
|
|
253
|
+
|
|
254
|
+
const docWithProjectContainer: ActivityDoc = {
|
|
255
|
+
notation: 'action',
|
|
256
|
+
title: 'Platform Launch Action',
|
|
257
|
+
project: { start_date: '2026-06-01' },
|
|
258
|
+
activities: [
|
|
259
|
+
{ id: 'PROJ-1', name: 'Platform Launch', activity_type: 'project' },
|
|
260
|
+
{ id: 'TASK-1', name: 'Design', activity_type: 'task', duration: 3, parent: 'PROJ-1' },
|
|
261
|
+
{ id: 'TASK-2', name: 'Implement', activity_type: 'task', duration: 5, parent: 'PROJ-1', predecessors: ['TASK-1'] },
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
it('includes the project container as a phase bar when not pre-filtered', () => {
|
|
266
|
+
const r = assertRenders(computeGanttLayout(docWithProjectContainer));
|
|
267
|
+
const projBar = r.bars.find(b => b.id === 'PROJ-1');
|
|
268
|
+
expect(projBar).toBeDefined();
|
|
269
|
+
expect(projBar!.kind).toBe('phase');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('still renders leaf tasks correctly when project-type activity is pre-filtered', () => {
|
|
273
|
+
const filteredDoc: ActivityDoc = {
|
|
274
|
+
...docWithProjectContainer,
|
|
275
|
+
activities: docWithProjectContainer.activities.filter(
|
|
276
|
+
(a) => a.activity_type?.toLowerCase() !== 'project',
|
|
277
|
+
),
|
|
278
|
+
};
|
|
279
|
+
const r = assertRenders(computeGanttLayout(filteredDoc));
|
|
280
|
+
// Project container bar must not appear.
|
|
281
|
+
expect(r.bars.find(b => b.id === 'PROJ-1')).toBeUndefined();
|
|
282
|
+
// Leaf tasks must still render with correct dates.
|
|
283
|
+
const t1 = r.bars.find(b => b.id === 'TASK-1')!;
|
|
284
|
+
const t2 = r.bars.find(b => b.id === 'TASK-2')!;
|
|
285
|
+
expect(t1).toBeDefined();
|
|
286
|
+
expect(t2).toBeDefined();
|
|
287
|
+
expect(t1.startDate).toBe('2026-06-01');
|
|
288
|
+
expect(t2.startDate).toBe('2026-06-04'); // ES = 3 (after TASK-1)
|
|
289
|
+
});
|
|
290
|
+
});
|