@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.
Files changed (129) hide show
  1. package/dist/__tests__/yaml-normalize.test.js +9 -9
  2. package/dist/activities/__tests__/gantt.test.js +39 -0
  3. package/dist/activities/__tests__/validate.test.js +77 -74
  4. package/dist/activities/validate.js +5 -12
  5. package/dist/activity-card/__tests__/example.test.js +2 -2
  6. package/dist/activity-card/__tests__/validate.test.js +29 -18
  7. package/dist/activity-card/validate.js +3 -10
  8. package/dist/actor/__tests__/validate.test.js +6 -0
  9. package/dist/actor/types.d.ts +1 -1
  10. package/dist/actor/types.js +2 -0
  11. package/dist/applications/types.d.ts +7 -0
  12. package/dist/applications/validate.js +18 -0
  13. package/dist/blocks/__tests__/layout.test.js +1 -1
  14. package/dist/blocks/layout.js +1 -1
  15. package/dist/business-service/__tests__/validate.test.d.ts +1 -0
  16. package/dist/business-service/__tests__/validate.test.js +135 -0
  17. package/dist/business-service/index.d.ts +3 -0
  18. package/dist/business-service/index.js +2 -0
  19. package/dist/business-service/types.d.ts +20 -0
  20. package/dist/business-service/types.js +3 -0
  21. package/dist/business-service/validate.d.ts +2 -0
  22. package/dist/business-service/validate.js +74 -0
  23. package/dist/fgca/__tests__/parse-canonical.test.js +10 -10
  24. package/dist/fgca/__tests__/resolver.test.js +7 -7
  25. package/dist/fgca/__tests__/validate.test.js +11 -11
  26. package/dist/fgca/parse-canonical.js +7 -11
  27. package/dist/fgca/resolver.d.ts +2 -2
  28. package/dist/fgca/resolver.js +5 -5
  29. package/dist/fgca/validate.js +4 -13
  30. package/dist/index.d.ts +5 -0
  31. package/dist/index.js +5 -0
  32. package/dist/integration/__tests__/validate.test.d.ts +1 -0
  33. package/dist/integration/__tests__/validate.test.js +169 -0
  34. package/dist/integration/index.d.ts +2 -0
  35. package/dist/integration/index.js +1 -0
  36. package/dist/integration/types.d.ts +27 -0
  37. package/dist/integration/types.js +5 -0
  38. package/dist/integration/validate.d.ts +2 -0
  39. package/dist/integration/validate.js +94 -0
  40. package/dist/location/__tests__/validate.test.d.ts +1 -0
  41. package/dist/location/__tests__/validate.test.js +107 -0
  42. package/dist/location/index.d.ts +3 -0
  43. package/dist/location/index.js +2 -0
  44. package/dist/location/types.d.ts +20 -0
  45. package/dist/location/types.js +3 -0
  46. package/dist/location/validate.d.ts +2 -0
  47. package/dist/location/validate.js +63 -0
  48. package/dist/node/__tests__/validate.test.d.ts +1 -0
  49. package/dist/node/__tests__/validate.test.js +100 -0
  50. package/dist/node/index.d.ts +2 -0
  51. package/dist/node/index.js +1 -0
  52. package/dist/node/types.d.ts +18 -0
  53. package/dist/node/types.js +3 -0
  54. package/dist/node/validate.d.ts +2 -0
  55. package/dist/node/validate.js +53 -0
  56. package/dist/repo-validate/__tests__/validate-repo.test.js +444 -0
  57. package/dist/repo-validate/validate-repo.js +224 -10
  58. package/dist/technology-service/__tests__/validate.test.d.ts +1 -0
  59. package/dist/technology-service/__tests__/validate.test.js +121 -0
  60. package/dist/technology-service/index.d.ts +2 -0
  61. package/dist/technology-service/index.js +1 -0
  62. package/dist/technology-service/types.d.ts +18 -0
  63. package/dist/technology-service/types.js +3 -0
  64. package/dist/technology-service/validate.d.ts +2 -0
  65. package/dist/technology-service/validate.js +64 -0
  66. package/dist/webview/__tests__/entry-notations.test.js +2 -4
  67. package/dist/webview/__tests__/render-activities.test.d.ts +1 -0
  68. package/dist/webview/__tests__/render-activities.test.js +91 -0
  69. package/dist/webview/__tests__/render-blocks.test.d.ts +1 -0
  70. package/dist/webview/__tests__/render-blocks.test.js +178 -0
  71. package/dist/webview/entry.d.ts +1 -1
  72. package/dist/webview/entry.js +8 -15
  73. package/dist/webview/notation-style.d.ts +11 -0
  74. package/dist/webview/notation-style.js +11 -0
  75. package/dist/webview/render-activities.d.ts +12 -0
  76. package/dist/webview/render-activities.js +9 -3
  77. package/dist/webview/render-blocks.js +102 -19
  78. package/dist/webview/render-goals.js +2 -1
  79. package/package.json +1 -1
  80. package/src/__tests__/yaml-normalize.test.ts +11 -11
  81. package/src/activities/__tests__/gantt.test.ts +45 -0
  82. package/src/activities/__tests__/validate.test.ts +77 -74
  83. package/src/activities/validate.ts +5 -12
  84. package/src/activity-card/__tests__/example.test.ts +2 -2
  85. package/src/activity-card/__tests__/validate.test.ts +30 -18
  86. package/src/activity-card/validate.ts +3 -9
  87. package/src/actor/__tests__/validate.test.ts +8 -0
  88. package/src/actor/types.ts +2 -0
  89. package/src/applications/types.ts +8 -0
  90. package/src/applications/validate.ts +20 -0
  91. package/src/blocks/__tests__/layout.test.ts +1 -1
  92. package/src/blocks/layout.ts +1 -1
  93. package/src/business-service/__tests__/validate.test.ts +160 -0
  94. package/src/business-service/index.ts +3 -0
  95. package/src/business-service/types.ts +29 -0
  96. package/src/business-service/validate.ts +82 -0
  97. package/src/fgca/__tests__/parse-canonical.test.ts +10 -10
  98. package/src/fgca/__tests__/resolver.test.ts +7 -7
  99. package/src/fgca/__tests__/validate.test.ts +11 -11
  100. package/src/fgca/parse-canonical.ts +7 -11
  101. package/src/fgca/resolver.ts +5 -5
  102. package/src/fgca/validate.ts +4 -12
  103. package/src/index.ts +5 -0
  104. package/src/integration/__tests__/validate.test.ts +203 -0
  105. package/src/integration/index.ts +2 -0
  106. package/src/integration/types.ts +38 -0
  107. package/src/integration/validate.ts +104 -0
  108. package/src/location/__tests__/validate.test.ts +127 -0
  109. package/src/location/index.ts +3 -0
  110. package/src/location/types.ts +29 -0
  111. package/src/location/validate.ts +70 -0
  112. package/src/node/__tests__/validate.test.ts +118 -0
  113. package/src/node/index.ts +2 -0
  114. package/src/node/types.ts +27 -0
  115. package/src/node/validate.ts +59 -0
  116. package/src/repo-validate/__tests__/validate-repo.test.ts +550 -0
  117. package/src/repo-validate/validate-repo.ts +233 -10
  118. package/src/technology-service/__tests__/validate.test.ts +143 -0
  119. package/src/technology-service/index.ts +2 -0
  120. package/src/technology-service/types.ts +27 -0
  121. package/src/technology-service/validate.ts +71 -0
  122. package/src/webview/__tests__/entry-notations.test.ts +2 -4
  123. package/src/webview/__tests__/render-activities.test.ts +100 -0
  124. package/src/webview/__tests__/render-blocks.test.ts +209 -0
  125. package/src/webview/entry.ts +10 -19
  126. package/src/webview/notation-style.ts +12 -0
  127. package/src/webview/render-activities.ts +29 -4
  128. package/src/webview/render-blocks.ts +106 -21
  129. 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
+ });
@@ -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' | 'fgca' | 'fga' | 'activities' | 'activity-card' | 'applications' | 'products' | 'process-map' | 'process-blueprint' | 'scenarios' | 'capability-map' | 'blocks';
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;
@@ -48,10 +48,8 @@ const SUPPORTED_KINDS = [
48
48
  'goals',
49
49
  'dgca',
50
50
  'dga',
51
- 'fgca',
52
- 'fga',
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
- // DGCA and DGA are the canonical new names; FGCA/FGA are accepted as
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 'activities':
139
- return renderFromValidation('activities', validateActivities(doc), () => renderActivitiesSvg(doc));
140
- case 'activity-card':
141
- return renderFromValidation('activity-card', validateActivityCard(doc), () => renderActivityCardSvg(doc));
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
- const layout = layoutActivities(doc, gaps);
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(doc.activities ?? []);
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) — used for ID suffix.
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
- /** Truncate at a word boundary so labels never break in the middle of a word. */
36
- function wordTruncate(text, maxChars) {
37
- if (text.length <= maxChars)
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, maxChars - 1);
92
+ const cut = text.slice(0, safeMax - 1);
40
93
  const lastSpace = cut.lastIndexOf(' ');
41
- return (lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut) + '…';
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="6"/>`);
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 on upper line, ID on lower line — both centred.
50
- const nameY = b.y + oy + Math.round(b.height * 0.38);
51
- const idY = b.y + oy + Math.round(b.height * 0.66);
52
- const maxChars = Math.max(4, Math.floor(b.width / CHAR_W));
53
- parts.push(`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(wordTruncate(b.name, maxChars))}</text>`, `<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(b.id)}</text>`);
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 + (ID) on the single header line; ID in grey.
57
- const headerY = b.y + oy + b.headerHeight / 2;
58
- const idSuffix = ` (${b.id})`;
59
- const idSuffixW = idSuffix.length * CHAR_W_ID;
60
- const nameMaxChars = Math.max(4, Math.floor((b.width - idSuffixW) / CHAR_W));
61
- const nameText = wordTruncate(b.name, nameMaxChars);
62
- parts.push(`<text class="text-primary" x="${cx}" y="${headerY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}<tspan fill="var(--ts-text-secondary, #64748b)" font-size="10" font-weight="600">${escXml(idSuffix)}</tspan></text>`);
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="8"/>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -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: activities',
65
- 'activities:',
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 { activities: { start_date: unknown; end_date: unknown }[] };
73
+ const parsed = yaml.load(text) as { actions: { start_date: unknown; end_date: unknown }[] };
74
74
  coerceDatesToIsoStrings(parsed);
75
- expect(parsed.activities[0].start_date).toBe('2026-06-01');
76
- expect(parsed.activities[0].end_date).toBe('2026-06-03');
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: activities',
84
- 'activities:',
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 { activities: { start_date: unknown; end_date: unknown }[] };
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.activities[0].start_date).toBeInstanceOf(Date);
96
+ expect(parsed.actions[0].start_date).toBeInstanceOf(Date);
97
97
  coerceDatesToIsoStrings(parsed);
98
- expect(parsed.activities[0].start_date).toBe('2026-06-01');
99
- expect(parsed.activities[0].end_date).toBe('2026-06-03');
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
+ });