@transitrix/diagrams 1.3.0 → 1.4.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 (122) hide show
  1. package/dist/__tests__/yaml-normalize.test.js +9 -9
  2. package/dist/activities/__tests__/validate.test.js +77 -74
  3. package/dist/activities/validate.js +5 -12
  4. package/dist/activity-card/__tests__/example.test.js +2 -2
  5. package/dist/activity-card/__tests__/validate.test.js +29 -18
  6. package/dist/activity-card/validate.js +3 -10
  7. package/dist/actor/__tests__/validate.test.js +6 -0
  8. package/dist/actor/types.d.ts +1 -1
  9. package/dist/actor/types.js +2 -0
  10. package/dist/applications/types.d.ts +7 -0
  11. package/dist/applications/validate.js +18 -0
  12. package/dist/blocks/__tests__/layout.test.js +1 -1
  13. package/dist/blocks/layout.js +1 -1
  14. package/dist/business-service/__tests__/validate.test.d.ts +1 -0
  15. package/dist/business-service/__tests__/validate.test.js +135 -0
  16. package/dist/business-service/index.d.ts +3 -0
  17. package/dist/business-service/index.js +2 -0
  18. package/dist/business-service/types.d.ts +20 -0
  19. package/dist/business-service/types.js +3 -0
  20. package/dist/business-service/validate.d.ts +2 -0
  21. package/dist/business-service/validate.js +74 -0
  22. package/dist/fgca/__tests__/parse-canonical.test.js +10 -10
  23. package/dist/fgca/__tests__/resolver.test.js +7 -7
  24. package/dist/fgca/__tests__/validate.test.js +11 -11
  25. package/dist/fgca/parse-canonical.js +7 -11
  26. package/dist/fgca/resolver.d.ts +2 -2
  27. package/dist/fgca/resolver.js +5 -5
  28. package/dist/fgca/validate.js +4 -13
  29. package/dist/index.d.ts +5 -0
  30. package/dist/index.js +5 -0
  31. package/dist/integration/__tests__/validate.test.d.ts +1 -0
  32. package/dist/integration/__tests__/validate.test.js +169 -0
  33. package/dist/integration/index.d.ts +2 -0
  34. package/dist/integration/index.js +1 -0
  35. package/dist/integration/types.d.ts +27 -0
  36. package/dist/integration/types.js +5 -0
  37. package/dist/integration/validate.d.ts +2 -0
  38. package/dist/integration/validate.js +94 -0
  39. package/dist/location/__tests__/validate.test.d.ts +1 -0
  40. package/dist/location/__tests__/validate.test.js +107 -0
  41. package/dist/location/index.d.ts +3 -0
  42. package/dist/location/index.js +2 -0
  43. package/dist/location/types.d.ts +20 -0
  44. package/dist/location/types.js +3 -0
  45. package/dist/location/validate.d.ts +2 -0
  46. package/dist/location/validate.js +63 -0
  47. package/dist/node/__tests__/validate.test.d.ts +1 -0
  48. package/dist/node/__tests__/validate.test.js +100 -0
  49. package/dist/node/index.d.ts +2 -0
  50. package/dist/node/index.js +1 -0
  51. package/dist/node/types.d.ts +18 -0
  52. package/dist/node/types.js +3 -0
  53. package/dist/node/validate.d.ts +2 -0
  54. package/dist/node/validate.js +53 -0
  55. package/dist/repo-validate/__tests__/validate-repo.test.js +444 -0
  56. package/dist/repo-validate/validate-repo.js +224 -10
  57. package/dist/technology-service/__tests__/validate.test.d.ts +1 -0
  58. package/dist/technology-service/__tests__/validate.test.js +121 -0
  59. package/dist/technology-service/index.d.ts +2 -0
  60. package/dist/technology-service/index.js +1 -0
  61. package/dist/technology-service/types.d.ts +18 -0
  62. package/dist/technology-service/types.js +3 -0
  63. package/dist/technology-service/validate.d.ts +2 -0
  64. package/dist/technology-service/validate.js +64 -0
  65. package/dist/webview/__tests__/entry-notations.test.js +2 -4
  66. package/dist/webview/__tests__/render-activities.test.d.ts +1 -0
  67. package/dist/webview/__tests__/render-activities.test.js +91 -0
  68. package/dist/webview/__tests__/render-blocks.test.d.ts +1 -0
  69. package/dist/webview/__tests__/render-blocks.test.js +130 -0
  70. package/dist/webview/entry.d.ts +1 -1
  71. package/dist/webview/entry.js +8 -15
  72. package/dist/webview/render-activities.d.ts +12 -0
  73. package/dist/webview/render-activities.js +9 -3
  74. package/dist/webview/render-blocks.js +97 -18
  75. package/package.json +1 -1
  76. package/src/__tests__/yaml-normalize.test.ts +11 -11
  77. package/src/activities/__tests__/validate.test.ts +77 -74
  78. package/src/activities/validate.ts +5 -12
  79. package/src/activity-card/__tests__/example.test.ts +2 -2
  80. package/src/activity-card/__tests__/validate.test.ts +30 -18
  81. package/src/activity-card/validate.ts +3 -9
  82. package/src/actor/__tests__/validate.test.ts +8 -0
  83. package/src/actor/types.ts +2 -0
  84. package/src/applications/types.ts +8 -0
  85. package/src/applications/validate.ts +20 -0
  86. package/src/blocks/__tests__/layout.test.ts +1 -1
  87. package/src/blocks/layout.ts +1 -1
  88. package/src/business-service/__tests__/validate.test.ts +160 -0
  89. package/src/business-service/index.ts +3 -0
  90. package/src/business-service/types.ts +29 -0
  91. package/src/business-service/validate.ts +82 -0
  92. package/src/fgca/__tests__/parse-canonical.test.ts +10 -10
  93. package/src/fgca/__tests__/resolver.test.ts +7 -7
  94. package/src/fgca/__tests__/validate.test.ts +11 -11
  95. package/src/fgca/parse-canonical.ts +7 -11
  96. package/src/fgca/resolver.ts +5 -5
  97. package/src/fgca/validate.ts +4 -12
  98. package/src/index.ts +5 -0
  99. package/src/integration/__tests__/validate.test.ts +203 -0
  100. package/src/integration/index.ts +2 -0
  101. package/src/integration/types.ts +38 -0
  102. package/src/integration/validate.ts +104 -0
  103. package/src/location/__tests__/validate.test.ts +127 -0
  104. package/src/location/index.ts +3 -0
  105. package/src/location/types.ts +29 -0
  106. package/src/location/validate.ts +70 -0
  107. package/src/node/__tests__/validate.test.ts +118 -0
  108. package/src/node/index.ts +2 -0
  109. package/src/node/types.ts +27 -0
  110. package/src/node/validate.ts +59 -0
  111. package/src/repo-validate/__tests__/validate-repo.test.ts +550 -0
  112. package/src/repo-validate/validate-repo.ts +233 -10
  113. package/src/technology-service/__tests__/validate.test.ts +143 -0
  114. package/src/technology-service/index.ts +2 -0
  115. package/src/technology-service/types.ts +27 -0
  116. package/src/technology-service/validate.ts +71 -0
  117. package/src/webview/__tests__/entry-notations.test.ts +2 -4
  118. package/src/webview/__tests__/render-activities.test.ts +100 -0
  119. package/src/webview/__tests__/render-blocks.test.ts +155 -0
  120. package/src/webview/entry.ts +10 -19
  121. package/src/webview/render-activities.ts +29 -4
  122. package/src/webview/render-blocks.ts +101 -20
@@ -0,0 +1,155 @@
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
+
11
+ import type { BlocksFile } from '../../blocks/types.js';
12
+ import { renderBlocksSvg } from '../render-blocks.js';
13
+
14
+ // Approximate character widths used by the renderer when sizing labels.
15
+ // Kept in sync with `CHAR_W` / `CHAR_W_ID` in render-blocks.ts so the test
16
+ // can compute the same overflow bounds the renderer uses.
17
+ const CHAR_W = 7;
18
+ const CHAR_W_ID = 6;
19
+
20
+ // Same horizontal margin the renderer reserves so text never abuts the rect.
21
+ const TEXT_MARGIN_X = 8;
22
+
23
+ function leafDoc(name: string, id: string): BlocksFile {
24
+ return {
25
+ notation: 'blocks',
26
+ spec_version: '0.1',
27
+ nested_blocks: {
28
+ id: 'BLOCKS-T-1',
29
+ name: 'Test',
30
+ blocks: [{ id, name }],
31
+ },
32
+ };
33
+ }
34
+
35
+ function containerDoc(name: string, id: string, childId = 'CHILD', childName = 'Child'): BlocksFile {
36
+ return {
37
+ notation: 'blocks',
38
+ spec_version: '0.1',
39
+ nested_blocks: {
40
+ id: 'BLOCKS-T-1',
41
+ name: 'Test',
42
+ blocks: [
43
+ {
44
+ id,
45
+ name,
46
+ children: [{ id: childId, name: childName }],
47
+ },
48
+ ],
49
+ },
50
+ };
51
+ }
52
+
53
+ /** Pull every text-primary / text-id line out of an SVG, in document order. */
54
+ function extractLabels(svg: string): Array<{ cls: string; text: string }> {
55
+ const re = /<text class="(text-(?:primary|id))"[^>]*>([^<]*)<\/text>/g;
56
+ const out: Array<{ cls: string; text: string }> = [];
57
+ let m: RegExpExecArray | null;
58
+ while ((m = re.exec(svg))) out.push({ cls: m[1], text: m[2] });
59
+ return out;
60
+ }
61
+
62
+ describe('renderBlocksSvg — leaf labels', () => {
63
+ it('wraps a long name to at most 3 lines and truncates with …', () => {
64
+ const longName =
65
+ 'Personal data records that linger after consent withdrawal across many systems and ' +
66
+ 'archives — known erasure gap';
67
+ const svg = renderBlocksSvg(leafDoc(longName, 'PII'));
68
+ const nameLines = extractLabels(svg).filter((l) => l.cls === 'text-primary');
69
+ expect(nameLines.length).toBeGreaterThan(0);
70
+ expect(nameLines.length).toBeLessThanOrEqual(3);
71
+ // If the name needed truncation, the final visible line must end with `…`.
72
+ if (nameLines.length === 3) {
73
+ expect(nameLines[2].text.endsWith('…')).toBe(true);
74
+ }
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
+
88
+ it('keeps every label inside the 160px leaf width (approximated by CHAR_W * length)', () => {
89
+ const svg = renderBlocksSvg(
90
+ leafDoc(
91
+ 'Personal data records that linger after consent withdrawal across many systems',
92
+ 'PII_RECORDS_WITH_A_FAIRLY_LONG_IDENTIFIER_HERE',
93
+ ),
94
+ );
95
+ const labels = extractLabels(svg);
96
+ const inner = 160 - TEXT_MARGIN_X * 2;
97
+ for (const l of labels) {
98
+ const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
99
+ expect(l.text.length * cw).toBeLessThanOrEqual(inner);
100
+ }
101
+ });
102
+ });
103
+
104
+ describe('renderBlocksSvg — container headers', () => {
105
+ it('renders the name and ID as two separate stacked text elements', () => {
106
+ const svg = renderBlocksSvg(containerDoc('Active systems', 'ACTIVE_SYSTEMS'));
107
+ const labels = extractLabels(svg);
108
+ // Container header contributes one text-primary (name) and one text-id (id);
109
+ // the leaf child contributes another pair. So 2 of each kind in total.
110
+ const primaries = labels.filter((l) => l.cls === 'text-primary').map((l) => l.text);
111
+ const ids = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
112
+ expect(primaries).toContain('Active systems');
113
+ expect(ids).toContain('ACTIVE_SYSTEMS');
114
+ // The legacy "(id)" suffix on the name line must not appear — id has its own row.
115
+ for (const t of primaries) expect(t).not.toContain('(');
116
+ });
117
+
118
+ it('truncates a long container ID so it does not overflow the block width', () => {
119
+ const svg = renderBlocksSvg(
120
+ containerDoc(
121
+ 'Short name',
122
+ 'VERY_LONG_CONTAINER_IDENTIFIER_THAT_WOULD_OTHERWISE_OVERFLOW',
123
+ 'CHILD',
124
+ 'Child',
125
+ ),
126
+ );
127
+ const labels = extractLabels(svg);
128
+ // The container width derives from its single 160px child: 160 + 2*12 (padding) = 184.
129
+ const containerW = 160 + 24;
130
+ const inner = containerW - TEXT_MARGIN_X * 2;
131
+ for (const l of labels) {
132
+ const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
133
+ expect(l.text.length * cw).toBeLessThanOrEqual(inner);
134
+ }
135
+ // The full ID was too long to fit on one line of `inner / CHAR_W_ID` chars, so
136
+ // the rendered ID must be the truncated form ending in `…`.
137
+ const renderedIds = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
138
+ expect(renderedIds.some((t) => t.endsWith('…'))).toBe(true);
139
+ });
140
+
141
+ it('truncates a long container name with … to fit the block width', () => {
142
+ const longName =
143
+ 'Active systems that are erasure-reachable within the standard 30-day operational window';
144
+ const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
145
+ const labels = extractLabels(svg);
146
+ const containerW = 160 + 24;
147
+ const inner = containerW - TEXT_MARGIN_X * 2;
148
+ const primaries = labels.filter((l) => l.cls === 'text-primary');
149
+ for (const l of primaries) {
150
+ expect(l.text.length * CHAR_W).toBeLessThanOrEqual(inner);
151
+ }
152
+ // At least one primary line must have been truncated.
153
+ expect(primaries.some((l) => l.text.endsWith('…'))).toBe(true);
154
+ });
155
+ });
@@ -93,10 +93,8 @@ export type NotationKind =
93
93
  | 'goals'
94
94
  | 'dgca'
95
95
  | 'dga'
96
- | 'fgca'
97
- | 'fga'
98
- | 'activities'
99
- | 'activity-card'
96
+ | 'action'
97
+ | 'action-card'
100
98
  | 'applications'
101
99
  | 'products'
102
100
  | 'process-map'
@@ -109,10 +107,8 @@ const SUPPORTED_KINDS: readonly NotationKind[] = [
109
107
  'goals',
110
108
  'dgca',
111
109
  'dga',
112
- 'fgca',
113
- 'fga',
114
- 'activities',
115
- 'activity-card',
110
+ 'action',
111
+ 'action-card',
116
112
  'applications',
117
113
  'products',
118
114
  'process-map',
@@ -186,11 +182,7 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
186
182
  }
187
183
  return r;
188
184
  }
189
- // DGCA and DGA are the canonical new names; FGCA/FGA are accepted as
190
- // deprecated aliases. All four share the same parse + renderer; only the
191
- // Change column visibility differs (DGA/FGA collapse it).
192
- case 'dgca':
193
- case 'fgca': {
185
+ case 'dgca': {
194
186
  const v = parseCanonicalFGCA(doc);
195
187
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
196
188
  r.errors.push(...v.errors);
@@ -200,8 +192,7 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
200
192
  }
201
193
  return r;
202
194
  }
203
- case 'dga':
204
- case 'fga': {
195
+ case 'dga': {
205
196
  const v = parseCanonicalFGA(doc);
206
197
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
207
198
  r.errors.push(...v.errors);
@@ -211,12 +202,12 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
211
202
  }
212
203
  return r;
213
204
  }
214
- case 'activities':
215
- return renderFromValidation('activities', validateActivities(doc), () =>
205
+ case 'action':
206
+ return renderFromValidation('action', validateActivities(doc), () =>
216
207
  renderActivitiesSvg(doc as ActivityDoc),
217
208
  );
218
- case 'activity-card':
219
- return renderFromValidation('activity-card', validateActivityCard(doc), () =>
209
+ case 'action-card':
210
+ return renderFromValidation('action-card', validateActivityCard(doc), () =>
220
211
  renderActivityCardSvg(doc as ActivityCardDoc),
221
212
  );
222
213
  case 'process-blueprint':
@@ -144,6 +144,18 @@ export interface RenderActivitiesOptions {
144
144
  curvature?: number;
145
145
  /** Entry curvature at the target node; defaults to `curvature` when omitted. */
146
146
  entryCurvature?: number;
147
+ /**
148
+ * When true (the default), activities with `activity_type === 'project'` are
149
+ * excluded from the network layout. Project container nodes add visual noise
150
+ * in the PSND view because the diagram itself already represents the project
151
+ * scope; suppressing them declutters the network without altering canonical
152
+ * data. Set to false to render all activities regardless of type.
153
+ *
154
+ * Convention: Network/diagram views suppress project nodes by default.
155
+ * Text/document views (Tree) keep them visible and compensate with the
156
+ * Action name in the view header.
157
+ */
158
+ suppressProjectNodes?: boolean;
147
159
  }
148
160
 
149
161
  /**
@@ -161,15 +173,28 @@ export interface RenderActivitiesOptions {
161
173
  * than short-circuiting.
162
174
  */
163
175
  export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesOptions = {}): string {
164
- const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
165
-
166
- const layout: ActivitiesLayout = layoutActivities(doc, gaps);
176
+ const {
177
+ title = '',
178
+ gaps = {},
179
+ curvature = DEFAULT_EDGE_CURVATURE,
180
+ entryCurvature,
181
+ suppressProjectNodes = true,
182
+ } = options;
183
+
184
+ // #421: Suppress project-type container nodes in the network layout by
185
+ // default (see suppressProjectNodes JSDoc). A shallow copy avoids mutating
186
+ // the caller's doc.
187
+ const renderDoc: ActivityDoc = suppressProjectNodes
188
+ ? { ...doc, activities: (doc.activities ?? []).filter((a) => a.activity_type?.toLowerCase() !== 'project') }
189
+ : doc;
190
+
191
+ const layout: ActivitiesLayout = layoutActivities(renderDoc, gaps);
167
192
 
168
193
  if (layout.nodes.length === 0) {
169
194
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
170
195
  }
171
196
 
172
- const cpm = computeCpm(doc.activities ?? []);
197
+ const cpm = computeCpm(renderDoc.activities ?? []);
173
198
  const titleH = title ? 24 : 0;
174
199
  const w = layout.bounds.width + N_PAD * 2;
175
200
  const h = layout.bounds.height + N_PAD * 2 + titleH;
@@ -21,9 +21,53 @@ const PAD = 24;
21
21
 
22
22
  // Approximate character width (px) at text-primary size (12px/600).
23
23
  const CHAR_W = 7;
24
- // Approximate character width (px) at text-id size (10px/600) — used for ID suffix.
24
+ // Approximate character width (px) at text-id size (10px/600).
25
25
  const CHAR_W_ID = 6;
26
26
 
27
+ // Vertical spacing between line centres.
28
+ const LINE_H = 14; // name lines (text-primary)
29
+ const LINE_H_ID = 12; // id lines (text-id)
30
+ const NAME_ID_GAP = 6; // gap between last name centre and first ID centre
31
+
32
+ /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
33
+ function wrapWords(text: string, maxChars: number, maxLines: number): string[] {
34
+ const safeMax = Math.max(2, maxChars);
35
+ const words = text.split(' ');
36
+ const lines: string[] = [];
37
+ let cur = '';
38
+ for (let i = 0; i < words.length; i++) {
39
+ const w = words[i];
40
+ const next = cur ? `${cur} ${w}` : w;
41
+ if (next.length <= safeMax) {
42
+ cur = next;
43
+ } else {
44
+ if (cur) lines.push(cur);
45
+ cur = '';
46
+ if (lines.length >= maxLines) break;
47
+ if (lines.length === maxLines - 1) {
48
+ const rest = words.slice(i).join(' ');
49
+ lines.push(rest.length <= safeMax ? rest : rest.slice(0, safeMax - 1) + '…');
50
+ return lines;
51
+ }
52
+ cur = w.length <= safeMax ? w : w.slice(0, safeMax - 1) + '…';
53
+ }
54
+ }
55
+ if (cur && lines.length < maxLines) lines.push(cur);
56
+ return lines.length ? lines : [text.slice(0, safeMax - 1) + '…'];
57
+ }
58
+
59
+ /** Split an ID (no spaces) across at most 2 lines, breaking at `_` or `-`. */
60
+ function wrapId(id: string, maxChars: number): string[] {
61
+ if (id.length <= maxChars) return [id];
62
+ const seg = id.slice(0, maxChars);
63
+ const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
64
+ const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
65
+ const isSep = id[cut] === '_' || id[cut] === '-';
66
+ const line1 = id.slice(0, cut);
67
+ const rest = id.slice(cut + (isSep ? 1 : 0));
68
+ return [line1, rest.length <= maxChars ? rest : rest.slice(0, maxChars - 1) + '…'];
69
+ }
70
+
27
71
  export interface RenderBlocksOptions {
28
72
  title?: string;
29
73
  }
@@ -41,12 +85,25 @@ function levelClassForDepth(depth: number): string {
41
85
  return `level-${idx}`;
42
86
  }
43
87
 
44
- /** Truncate at a word boundary so labels never break in the middle of a word. */
45
- function wordTruncate(text: string, maxChars: number): string {
46
- if (text.length <= maxChars) return text;
47
- const cut = text.slice(0, maxChars - 1);
88
+ // Reserve a small horizontal margin so text never abuts the rounded corners.
89
+ const TEXT_MARGIN_X = 8;
90
+
91
+ /** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
92
+ function truncateLine(text: string, maxChars: number): string {
93
+ const safeMax = Math.max(2, maxChars);
94
+ if (text.length <= safeMax) return text;
95
+ const cut = text.slice(0, safeMax - 1);
48
96
  const lastSpace = cut.lastIndexOf(' ');
49
- return (lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut) + '…';
97
+ return (lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut) + '…';
98
+ }
99
+
100
+ /** Single-line ID truncation that prefers `_`/`-` separators before falling back to a hard cut. */
101
+ function truncateId(id: string, maxChars: number): string {
102
+ const safeMax = Math.max(2, maxChars);
103
+ if (id.length <= safeMax) return id;
104
+ const cut = id.slice(0, safeMax - 1);
105
+ const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
106
+ return (sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut) + '…';
50
107
  }
51
108
 
52
109
  function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]): void {
@@ -56,25 +113,49 @@ function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]):
56
113
  `<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`,
57
114
  );
58
115
 
116
+ const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
59
117
  const isLeaf = b.children.length === 0;
60
118
  if (isLeaf) {
61
- // Leaf block: name on upper line, ID on lower line — both centred.
62
- const nameY = b.y + oy + Math.round(b.height * 0.38);
63
- const idY = b.y + oy + Math.round(b.height * 0.66);
64
- const maxChars = Math.max(4, Math.floor(b.width / CHAR_W));
119
+ // Leaf block: name (up to 3 lines) then ID (up to 2 lines), centred vertically.
120
+ const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
121
+ const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
122
+ const nameLines = wrapWords(b.name, maxCharsName, 3);
123
+ const idLines = wrapId(b.id, maxCharsId);
124
+ const nameTotalSpan = (nameLines.length - 1) * LINE_H;
125
+ const idTotalSpan = (idLines.length - 1) * LINE_H_ID;
126
+ const totalSpan = nameTotalSpan + NAME_ID_GAP + idTotalSpan;
127
+ const firstY = Math.round(b.y + oy + (b.height - totalSpan) / 2);
128
+ for (let i = 0; i < nameLines.length; i++) {
129
+ parts.push(
130
+ `<text class="text-primary" x="${cx}" y="${firstY + i * LINE_H}" text-anchor="middle" dominant-baseline="central">${escXml(nameLines[i])}</text>`,
131
+ );
132
+ }
133
+ const idFirstY = firstY + nameTotalSpan + NAME_ID_GAP;
134
+ for (let i = 0; i < idLines.length; i++) {
135
+ parts.push(
136
+ `<text class="text-id" x="${cx}" y="${idFirstY + i * LINE_H_ID}" text-anchor="middle" dominant-baseline="central">${escXml(idLines[i])}</text>`,
137
+ );
138
+ }
139
+ } else {
140
+ // Container block: name (text-primary) above, ID (text-id, grey) below — both
141
+ // single-line, truncated with `…` to fit within `b.width`. The header strip is
142
+ // only `headerHeight` tall (default 28px), which is enough for one line each at
143
+ // the configured text sizes. Stacking the ID as its own text-id element keeps
144
+ // the wrapping/truncation rule consistent with leaves and guarantees no overflow
145
+ // for any combination of name/ID length.
146
+ const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
147
+ const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
148
+ const nameText = truncateLine(b.name, maxCharsName);
149
+ const idText = truncateId(b.id, maxCharsId);
150
+ const headerMid = b.y + oy + b.headerHeight / 2;
151
+ const halfSpan = (LINE_H + LINE_H_ID) / 4;
152
+ const nameY = Math.round(headerMid - halfSpan);
153
+ const idY = Math.round(headerMid + halfSpan);
65
154
  parts.push(
66
- `<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(wordTruncate(b.name, maxChars))}</text>`,
67
- `<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(b.id)}</text>`,
155
+ `<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}</text>`,
68
156
  );
69
- } else {
70
- // Container block: name + (ID) on the single header line; ID in grey.
71
- const headerY = b.y + oy + b.headerHeight / 2;
72
- const idSuffix = ` (${b.id})`;
73
- const idSuffixW = idSuffix.length * CHAR_W_ID;
74
- const nameMaxChars = Math.max(4, Math.floor((b.width - idSuffixW) / CHAR_W));
75
- const nameText = wordTruncate(b.name, nameMaxChars);
76
157
  parts.push(
77
- `<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>`,
158
+ `<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>`,
78
159
  );
79
160
  for (const c of b.children) emitBlockSvg(c, ox, oy, parts);
80
161
  }