@transitrix/diagrams 1.7.6 → 1.8.2

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 (64) hide show
  1. package/dist/__tests__/node-size-presets.test.d.ts +1 -0
  2. package/dist/__tests__/node-size-presets.test.js +30 -0
  3. package/dist/activities/layout.js +7 -7
  4. package/dist/activities/types.d.ts +4 -0
  5. package/dist/blocks/__tests__/layout.test.js +2 -2
  6. package/dist/blocks/layout.js +3 -2
  7. package/dist/capability-map/dsm-layout.js +1 -1
  8. package/dist/capability-map/dsm-schema.d.ts +1 -1
  9. package/dist/capability-map/dsm-schema.js +1 -1
  10. package/dist/capability-map/layout-tree.d.ts +6 -2
  11. package/dist/capability-map/layout-tree.js +12 -10
  12. package/dist/capability-map/render-capability-tree.d.ts +2 -9
  13. package/dist/capability-map/render-capability-tree.js +16 -28
  14. package/dist/fgca/preview-layout.d.ts +10 -6
  15. package/dist/fgca/preview-layout.js +16 -15
  16. package/dist/node-size-presets.d.ts +63 -0
  17. package/dist/node-size-presets.js +117 -0
  18. package/dist/process-blueprint/layout.js +4 -47
  19. package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
  20. package/dist/webview/__tests__/entity-text-layout.test.js +60 -0
  21. package/dist/webview/__tests__/entry.test.js +3 -3
  22. package/dist/webview/__tests__/render-blocks.test.js +10 -13
  23. package/dist/webview/__tests__/render-goals.test.js +41 -0
  24. package/dist/webview/entity-text-layout.d.ts +95 -0
  25. package/dist/webview/entity-text-layout.js +277 -0
  26. package/dist/webview/entry.js +7 -3
  27. package/dist/webview/notation-style.d.ts +1 -1
  28. package/dist/webview/notation-style.js +1 -1
  29. package/dist/webview/render-activities.d.ts +3 -1
  30. package/dist/webview/render-activities.js +29 -32
  31. package/dist/webview/render-blocks.d.ts +4 -1
  32. package/dist/webview/render-blocks.js +27 -108
  33. package/dist/webview/render-fgca.d.ts +15 -8
  34. package/dist/webview/render-fgca.js +41 -44
  35. package/dist/webview/render-goals.d.ts +2 -0
  36. package/dist/webview/render-goals.js +21 -36
  37. package/dist/webview/render-process-blueprint.d.ts +4 -10
  38. package/dist/webview/render-process-blueprint.js +18 -64
  39. package/package.json +1 -1
  40. package/src/__tests__/node-size-presets.test.ts +50 -0
  41. package/src/activities/layout.ts +12 -7
  42. package/src/activities/types.ts +4 -0
  43. package/src/blocks/__tests__/layout.test.ts +2 -2
  44. package/src/blocks/layout.ts +3 -2
  45. package/src/capability-map/dsm-layout.ts +1 -1
  46. package/src/capability-map/dsm-schema.ts +1 -1
  47. package/src/capability-map/index.ts +1 -1
  48. package/src/capability-map/layout-tree.ts +17 -9
  49. package/src/capability-map/render-capability-tree.ts +18 -30
  50. package/src/fgca/preview-layout.ts +24 -17
  51. package/src/node-size-presets.ts +180 -0
  52. package/src/process-blueprint/layout.ts +4 -48
  53. package/src/webview/__tests__/entity-text-layout.test.ts +73 -0
  54. package/src/webview/__tests__/entry.test.ts +3 -3
  55. package/src/webview/__tests__/render-blocks.test.ts +11 -14
  56. package/src/webview/__tests__/render-goals.test.ts +42 -0
  57. package/src/webview/entity-text-layout.ts +355 -0
  58. package/src/webview/entry.ts +8 -4
  59. package/src/webview/notation-style.ts +1 -1
  60. package/src/webview/render-activities.ts +38 -27
  61. package/src/webview/render-blocks.ts +35 -118
  62. package/src/webview/render-fgca.ts +59 -44
  63. package/src/webview/render-goals.ts +22 -34
  64. package/src/webview/render-process-blueprint.ts +30 -65
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Shared text-in-block layout for Transitrix diagram renderers.
3
+ *
4
+ * Consolidates wrapping, truncation, and vertical placement patterns previously
5
+ * duplicated across Blocks, Goals, DGCA/DGA, Activities, Process Blueprint, and
6
+ * Capability Map renderers.
7
+ */
8
+
9
+ export const TEXT_MARGIN_X = 8;
10
+ /** Approximate character width (px) at text-primary size (12px/600). */
11
+ export const CHAR_W_PRIMARY = 7;
12
+ /** Approximate character width (px) at text-id size (10px/600). */
13
+ export const CHAR_W_ID = 6;
14
+ /** Approximate character width (px) at text-secondary size. */
15
+ export const CHAR_W_SECONDARY = 7;
16
+ /** Vertical advance between name line centres (text-primary). */
17
+ export const LINE_H_PRIMARY = 14;
18
+ /** Vertical advance between secondary line centres. */
19
+ export const LINE_H_SECONDARY = 14;
20
+ /** Vertical advance between id line centres (text-id). */
21
+ export const LINE_H_ID = 12;
22
+ /** Gap between the name block and the id block in leaf-style layouts. */
23
+ export const NAME_ID_GAP = 14;
24
+ /** Gap between stacked row groups (name → type → id). */
25
+ export const ROW_GROUP_GAP = 9;
26
+
27
+ export function maxCharsForInnerWidth(innerWidth: number, charWidth: number): number {
28
+ return Math.max(4, Math.floor(Math.max(0, innerWidth) / charWidth));
29
+ }
30
+
31
+ /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
32
+ export function wrapWords(text: string, maxChars: number, maxLines: number): string[] {
33
+ const safeMax = Math.max(2, maxChars);
34
+ const words = text.split(' ');
35
+ const lines: string[] = [];
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
+ } else {
43
+ if (cur) lines.push(cur);
44
+ cur = '';
45
+ if (lines.length >= maxLines) break;
46
+ if (lines.length === maxLines - 1) {
47
+ const rest = words.slice(i).join(' ');
48
+ lines.push(rest.length <= safeMax ? rest : `${rest.slice(0, safeMax - 1)}…`);
49
+ return lines;
50
+ }
51
+ cur = w.length <= safeMax ? w : `${w.slice(0, safeMax - 1)}…`;
52
+ }
53
+ }
54
+ if (cur && lines.length < maxLines) lines.push(cur);
55
+ return lines.length ? lines : [`${text.slice(0, safeMax - 1)}…`];
56
+ }
57
+
58
+ /**
59
+ * Greedy word-wrap with hard-break for tokens wider than the budget. When the
60
+ * result exceeds `maxLines`, the last kept line is truncated with an ellipsis.
61
+ * Used by Process Blueprint goal/result cells and aspect pill names.
62
+ */
63
+ export function wrapTextLines(text: string, maxChars: number, maxLines: number): string[] {
64
+ const trimmed = (text ?? '').trim();
65
+ if (trimmed.length === 0) return [];
66
+ const budget = Math.max(1, maxChars);
67
+
68
+ const words = trimmed.split(/\s+/);
69
+ const lines: string[] = [];
70
+ let cur = '';
71
+ const flush = (): void => {
72
+ if (cur.length > 0) {
73
+ lines.push(cur);
74
+ cur = '';
75
+ }
76
+ };
77
+
78
+ for (const word of words) {
79
+ if (word.length > budget) {
80
+ flush();
81
+ let rest = word;
82
+ while (rest.length > budget) {
83
+ lines.push(rest.slice(0, budget));
84
+ rest = rest.slice(budget);
85
+ }
86
+ cur = rest;
87
+ continue;
88
+ }
89
+ const candidate = cur.length > 0 ? `${cur} ${word}` : word;
90
+ if (candidate.length > budget) {
91
+ flush();
92
+ cur = word;
93
+ } else {
94
+ cur = candidate;
95
+ }
96
+ }
97
+ flush();
98
+
99
+ if (lines.length > maxLines) {
100
+ const capped = lines.slice(0, maxLines);
101
+ const last = capped[maxLines - 1];
102
+ const clipped = last.length > budget - 1 ? last.slice(0, budget - 1) : last;
103
+ capped[maxLines - 1] = `${clipped.replace(/…$/, '')}…`;
104
+ return capped;
105
+ }
106
+ return lines;
107
+ }
108
+
109
+ /** Split an ID (no spaces) across lines, breaking at `_` or `-`. */
110
+ export function wrapId(id: string, maxChars: number, maxLines = 2): string[] {
111
+ if (maxLines <= 1) return [truncateId(id, maxChars)];
112
+ if (id.length <= maxChars) return [id];
113
+ const seg = id.slice(0, maxChars);
114
+ const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
115
+ const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
116
+ const isSep = id[cut] === '_' || id[cut] === '-';
117
+ const line1 = id.slice(0, cut);
118
+ const rest = id.slice(cut + (isSep ? 1 : 0));
119
+ return [line1, rest.length <= maxChars ? rest : `${rest.slice(0, maxChars - 1)}…`];
120
+ }
121
+
122
+ /** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
123
+ export function truncateLine(text: string, maxChars: number): string {
124
+ const safeMax = Math.max(2, maxChars);
125
+ if (text.length <= safeMax) return text;
126
+ const cut = text.slice(0, safeMax - 1);
127
+ const lastSpace = cut.lastIndexOf(' ');
128
+ return `${lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut}…`;
129
+ }
130
+
131
+ /** Single-line ID truncation that prefers `_`/`-` separators before a hard cut. */
132
+ export function truncateId(id: string, maxChars: number): string {
133
+ const safeMax = Math.max(2, maxChars);
134
+ if (id.length <= safeMax) return id;
135
+ const cut = id.slice(0, safeMax - 1);
136
+ const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
137
+ return `${sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut}…`;
138
+ }
139
+
140
+ export interface TextLineSpec {
141
+ cls: string;
142
+ text: string;
143
+ y: number;
144
+ /** When set, use this x; otherwise callers supply a shared anchor (e.g. box centre). */
145
+ x?: number;
146
+ }
147
+
148
+ export interface LayoutCenteredEntityOptions {
149
+ boxX: number;
150
+ boxY: number;
151
+ boxWidth: number;
152
+ boxHeight: number;
153
+ name: string;
154
+ type?: string;
155
+ id: string;
156
+ nameMaxLines?: number;
157
+ idMaxLines?: number;
158
+ marginX?: number;
159
+ }
160
+
161
+ interface TextRowGroup {
162
+ cls: string;
163
+ lines: string[];
164
+ lineHeight: number;
165
+ gapAfter: number;
166
+ }
167
+
168
+ function totalVerticalSpan(groups: TextRowGroup[]): number {
169
+ let span = 0;
170
+ for (let i = 0; i < groups.length; i++) {
171
+ const g = groups[i];
172
+ span += g.lines.length * g.lineHeight;
173
+ if (i < groups.length - 1) span += g.gapAfter;
174
+ }
175
+ return span;
176
+ }
177
+
178
+ /**
179
+ * Layout centred entity text (name → optional type → id) inside a fixed box.
180
+ * Returns absolute SVG y-positions (line centres) for each rendered row.
181
+ */
182
+ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): TextLineSpec[] {
183
+ const marginX = opts.marginX ?? TEXT_MARGIN_X;
184
+ const innerW = Math.max(0, opts.boxWidth - marginX * 2);
185
+ const cx = opts.boxX + opts.boxWidth / 2;
186
+ const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
187
+ const maxCharsType = maxCharsForInnerWidth(innerW, CHAR_W_SECONDARY);
188
+ const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
189
+
190
+ const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 2);
191
+ const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
192
+ const idMaxLines = opts.idMaxLines ?? 1;
193
+ const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
194
+
195
+ const groups: TextRowGroup[] = [
196
+ {
197
+ cls: 'text-primary',
198
+ lines: nameLines,
199
+ lineHeight: LINE_H_PRIMARY,
200
+ gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
201
+ },
202
+ ];
203
+ if (typeLine) {
204
+ groups.push({
205
+ cls: 'text-secondary',
206
+ lines: [typeLine],
207
+ lineHeight: LINE_H_SECONDARY,
208
+ gapAfter: NAME_ID_GAP,
209
+ });
210
+ }
211
+ groups.push({
212
+ cls: 'text-id',
213
+ lines: idLines,
214
+ lineHeight: LINE_H_ID,
215
+ gapAfter: 0,
216
+ });
217
+
218
+ const span = totalVerticalSpan(groups);
219
+ const firstY = Math.round(opts.boxY + (opts.boxHeight - span) / 2);
220
+ const out: TextLineSpec[] = [];
221
+ let y = firstY;
222
+ for (let gi = 0; gi < groups.length; gi++) {
223
+ const g = groups[gi];
224
+ for (let li = 0; li < g.lines.length; li++) {
225
+ out.push({ cls: g.cls, text: g.lines[li], y });
226
+ y += g.lineHeight;
227
+ }
228
+ if (gi < groups.length - 1) y += g.gapAfter;
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /** Emit centred `<text>` elements from {@link layoutCenteredEntityText} specs. */
234
+ export function emitCenteredTextSvg(
235
+ specs: TextLineSpec[],
236
+ centerX: number,
237
+ esc: (s: string) => string,
238
+ ): string {
239
+ return specs
240
+ .map((spec) => {
241
+ const x = spec.x ?? centerX;
242
+ const anchor = spec.x != null && spec.cls === 'text-pill' ? ' text-anchor="middle"' : ' text-anchor="middle"';
243
+ return `<text class="${spec.cls}" x="${x}" y="${spec.y}"${anchor} dominant-baseline="central">${esc(spec.text)}</text>`;
244
+ })
245
+ .join('\n');
246
+ }
247
+
248
+ export interface LayoutLeafBlockTextOptions {
249
+ boxX: number;
250
+ boxY: number;
251
+ boxWidth: number;
252
+ boxHeight: number;
253
+ name: string;
254
+ id: string;
255
+ nameMaxLines?: number;
256
+ marginX?: number;
257
+ }
258
+
259
+ /** Leaf nested-block layout: name (up to 3 lines) + id (up to 2 lines), centred. */
260
+ export function layoutLeafBlockText(opts: LayoutLeafBlockTextOptions): TextLineSpec[] {
261
+ const marginX = opts.marginX ?? TEXT_MARGIN_X;
262
+ const innerW = Math.max(0, opts.boxWidth - marginX * 2);
263
+ const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
264
+ const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
265
+ const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 3);
266
+ const idLines = wrapId(opts.id, maxCharsId, 2);
267
+
268
+ const nameSpan = (nameLines.length - 1) * LINE_H_PRIMARY;
269
+ const idSpan = (idLines.length - 1) * LINE_H_ID;
270
+ const totalSpan = nameSpan + NAME_ID_GAP + idSpan;
271
+ const firstY = Math.round(opts.boxY + (opts.boxHeight - totalSpan) / 2);
272
+
273
+ const out: TextLineSpec[] = [];
274
+ for (let i = 0; i < nameLines.length; i++) {
275
+ out.push({ cls: 'text-primary', text: nameLines[i], y: firstY + i * LINE_H_PRIMARY });
276
+ }
277
+ const idFirstY = firstY + nameSpan + NAME_ID_GAP;
278
+ for (let i = 0; i < idLines.length; i++) {
279
+ out.push({ cls: 'text-id', text: idLines[i], y: idFirstY + i * LINE_H_ID });
280
+ }
281
+ return out;
282
+ }
283
+
284
+ export interface LayoutHeaderBlockTextOptions {
285
+ boxX: number;
286
+ boxY: number;
287
+ boxWidth: number;
288
+ headerHeight: number;
289
+ name: string;
290
+ id: string;
291
+ marginX?: number;
292
+ }
293
+
294
+ /** Container header: single-line name + id stacked in the header strip. */
295
+ export function layoutHeaderBlockText(opts: LayoutHeaderBlockTextOptions): TextLineSpec[] {
296
+ const marginX = opts.marginX ?? TEXT_MARGIN_X;
297
+ const innerW = Math.max(0, opts.boxWidth - marginX * 2);
298
+ const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
299
+ const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
300
+ const nameText = truncateLine(opts.name, maxCharsName);
301
+ const idText = truncateId(opts.id, maxCharsId);
302
+ const headerMid = opts.boxY + opts.headerHeight / 2;
303
+ const halfSpan = (LINE_H_PRIMARY + LINE_H_ID) / 4;
304
+ return [
305
+ { cls: 'text-primary', text: nameText, y: Math.round(headerMid - halfSpan) },
306
+ { cls: 'text-id', text: idText, y: Math.round(headerMid + halfSpan) },
307
+ ];
308
+ }
309
+
310
+ /**
311
+ * Left-anchored multi-line cell text, vertically centred (Process Blueprint cells).
312
+ * Returns one spec per line with absolute x baked into caller via anchorX.
313
+ */
314
+ export function layoutLeftCellLines(
315
+ lines: string[],
316
+ anchorX: number,
317
+ cellTop: number,
318
+ cellHeight: number,
319
+ lineHeight: number,
320
+ cls: string,
321
+ ): TextLineSpec[] {
322
+ const ls = lines.length > 0 ? lines : [''];
323
+ const firstY = cellTop + cellHeight / 2 - ((ls.length - 1) / 2) * lineHeight;
324
+ return ls.map((text, i) => ({ cls, text, y: firstY + i * lineHeight, x: anchorX }));
325
+ }
326
+
327
+ /** Centred pill text: name lines + optional id line below. */
328
+ export function layoutCenteredPillText(
329
+ pillX: number,
330
+ pillY: number,
331
+ pillWidth: number,
332
+ pillHeight: number,
333
+ nameLines: string[],
334
+ idLine: string | undefined,
335
+ nameLineHeight = 15,
336
+ ): TextLineSpec[] {
337
+ const cx = pillX + pillWidth / 2;
338
+ const lineCount = nameLines.length + (idLine ? 1 : 0);
339
+ const firstY = pillY + pillHeight / 2 - ((lineCount - 1) / 2) * nameLineHeight;
340
+ const out: TextLineSpec[] = nameLines.map((text, i) => ({
341
+ cls: 'text-pill',
342
+ text,
343
+ y: firstY + i * nameLineHeight,
344
+ x: cx,
345
+ }));
346
+ if (idLine) {
347
+ out.push({
348
+ cls: 'text-secondary',
349
+ text: idLine,
350
+ y: firstY + nameLines.length * nameLineHeight,
351
+ x: cx,
352
+ });
353
+ }
354
+ return out;
355
+ }
@@ -19,6 +19,10 @@
19
19
  * (SVG for the diagram notations, an HTML fragment for the catalogue
20
20
  * notations). The JVM host drops whatever `svg` carries into the DOM, so the
21
21
  * field name is historical — it transports any self-contained markup.
22
+ *
23
+ * Import validators/types from leaf modules (e.g. `capability-map/validate.js`),
24
+ * never from package `index.ts` barrels that also re-export React views —
25
+ * esbuild cannot tree-shake those and the JCEF bundle would pull in React.
22
26
  */
23
27
  import yaml from 'js-yaml';
24
28
 
@@ -30,8 +34,8 @@ import { validateApplicationsCatalogue } from '../applications/index.js';
30
34
  import type { ApplicationsCatalogueFile } from '../applications/index.js';
31
35
  import { validateNestedBlocks } from '../blocks/index.js';
32
36
  import type { BlocksFile } from '../blocks/index.js';
33
- import { validateCapabilityMap } from '../capability-map/index.js';
34
- import type { CapabilityMapFile } from '../capability-map/index.js';
37
+ import { validateCapabilityMap } from '../capability-map/validate.js';
38
+ import type { CapabilityMapFile } from '../capability-map/types.js';
35
39
  import { parseCanonicalFGCA, parseCanonicalFGA } from '../fgca/parse-canonical.js';
36
40
  import { parseCanonicalGoals } from '../goals/parse-canonical.js';
37
41
  import { validateProcessBlueprint } from '../process-blueprint/index.js';
@@ -188,7 +192,7 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
188
192
  r.errors.push(...v.errors);
189
193
  r.warnings.push(...v.warnings);
190
194
  if (v.valid && v.parsed) {
191
- r.svg = renderFgcaSvg(v.parsed, { variant: 'fgca' });
195
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca' });
192
196
  }
193
197
  return r;
194
198
  }
@@ -198,7 +202,7 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
198
202
  r.errors.push(...v.errors);
199
203
  r.warnings.push(...v.warnings);
200
204
  if (v.valid && v.parsed) {
201
- r.svg = renderFgcaSvg(v.parsed, { variant: 'fga' });
205
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dga' });
202
206
  }
203
207
  return r;
204
208
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared visual constants for entity-box (block-style) notation renderers.
3
3
  *
4
- * Goals, Nested Blocks, FGCA/DGCA, and any future notation using the same
4
+ * Goals, Nested Blocks, DGCA/DGA, and any future notation using the same
5
5
  * entity-box visual language import their shared style tokens from here.
6
6
  * Centralising them prevents the per-renderer drift that caused the Goals/Blocks
7
7
  * style mismatch (border radius diverged to rx=6 in Blocks vs rx=8 everywhere
@@ -24,11 +24,11 @@ import type {
24
24
  ActivitiesLayoutOptions,
25
25
  } from '../activities/types.js';
26
26
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
27
+ import { parseNodeSizePreset, resolveActionNodeSize, type NodeSizePreset } from '../node-size-presets.js';
27
28
  import { generateSvgEmbedCss } from '../theme/index.js';
29
+ import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
28
30
  import { escXml } from './render-util.js';
29
31
 
30
- const N_NODE_W = 200;
31
- const N_NODE_H = 80;
32
32
  const N_PAD = 24;
33
33
 
34
34
  /**
@@ -55,8 +55,31 @@ export const ACTIVITIES_NETWORK_DEFS = `<defs>
55
55
  </marker>
56
56
  </defs>`;
57
57
 
58
- function truncate(s: string, maxLen: number): string {
59
- return s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
58
+ function activityNodeSvg(
59
+ n: ActivitiesLayout['nodes'][number],
60
+ x: number,
61
+ y: number,
62
+ isCritical: boolean,
63
+ isMilestone: boolean,
64
+ durLabel: string,
65
+ ): string {
66
+ const cls = `diagram-node act-node ${isCritical ? 'critical-node' : ''} ${isMilestone ? 'milestone-node' : ''}`.trim();
67
+ const specs = layoutCenteredEntityText({
68
+ boxX: x,
69
+ boxY: y,
70
+ boxWidth: n.width,
71
+ boxHeight: n.height,
72
+ name: n.data.name,
73
+ id: n.id,
74
+ nameMaxLines: 2,
75
+ idMaxLines: 1,
76
+ });
77
+ const textSvg = emitCenteredTextSvg(specs, x + n.width / 2, escXml);
78
+ return [
79
+ `<rect class="${cls}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="8"/>`,
80
+ textSvg,
81
+ durLabel ? `<text class="text-secondary" x="${x + n.width - 8}" y="${y + n.height - 8}" text-anchor="end">${durLabel}</text>` : '',
82
+ ].filter(Boolean).join('\n');
60
83
  }
61
84
 
62
85
  /**
@@ -107,28 +130,8 @@ export function renderActivitiesNetworkBody(
107
130
  const isCritical = cpm.get(n.id)?.isCritical ?? false;
108
131
  const durVal = n.data.duration;
109
132
  const isMilestone = (durVal ?? -1) === 0;
110
- const cls = `diagram-node act-node ${isCritical ? 'critical-node' : ''} ${isMilestone ? 'milestone-node' : ''}`.trim();
111
- const idLabel = escXml(n.id);
112
- const words = n.data.name.split(' ');
113
- let line1 = '';
114
- let line2 = '';
115
- for (const w of words) {
116
- if ((line1 + ' ' + w).trim().length <= 24) line1 = (line1 + ' ' + w).trim();
117
- else if ((line2 + ' ' + w).trim().length <= 24) line2 = (line2 + ' ' + w).trim();
118
- else if (!line2) { line2 = w.slice(0, 22) + '…'; break; }
119
- }
120
- const twoLines = line2.length > 0;
121
- const nameY1 = twoLines ? y + 18 : y + 28;
122
- const nameY2 = y + 34;
123
- const idY = twoLines ? y + 54 : y + 50;
124
133
  const durLabel = (durVal !== undefined && durVal > 0) ? `${durVal}d` : '';
125
- return [
126
- `<rect class="${cls}" x="${x}" y="${y}" width="${N_NODE_W}" height="${N_NODE_H}" rx="8"/>`,
127
- `<text class="text-primary" x="${x + N_NODE_W / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
128
- twoLines ? `<text class="text-primary" x="${x + N_NODE_W / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : '',
129
- `<text class="text-id" x="${x + N_NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${idLabel}</text>`,
130
- durLabel ? `<text class="text-secondary" x="${x + N_NODE_W - 8}" y="${y + N_NODE_H - 8}" text-anchor="end">${durLabel}</text>` : '',
131
- ].filter(Boolean).join('\n');
134
+ return activityNodeSvg(n, x, y, isCritical, isMilestone, durLabel);
132
135
  })
133
136
  .join('\n');
134
137
 
@@ -138,8 +141,9 @@ export function renderActivitiesNetworkBody(
138
141
  export interface RenderActivitiesOptions {
139
142
  /** Optional heading rendered as a `text-header` above the diagram. */
140
143
  title?: string;
141
- /** Network column / row gaps. Defaults match `layoutActivities`. */
144
+ /** Network column / row gaps and node size. Defaults match `layoutActivities`. */
142
145
  gaps?: ActivitiesLayoutOptions;
146
+ nodeSizePreset?: NodeSizePreset;
143
147
  /** Exit edge curvature; 1 = default, 0 = straight, higher = stronger arc. */
144
148
  curvature?: number;
145
149
  /** Entry curvature at the target node; defaults to `curvature` when omitted. */
@@ -176,10 +180,17 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
176
180
  const {
177
181
  title = '',
178
182
  gaps = {},
183
+ nodeSizePreset = 'normal',
179
184
  curvature = DEFAULT_EDGE_CURVATURE,
180
185
  entryCurvature,
181
186
  suppressProjectNodes = true,
182
187
  } = options;
188
+ const nodeSize = resolveActionNodeSize(parseNodeSizePreset(nodeSizePreset));
189
+ const layoutGaps: ActivitiesLayoutOptions = {
190
+ ...gaps,
191
+ nodeWidth: gaps.nodeWidth ?? nodeSize.width,
192
+ nodeHeight: gaps.nodeHeight ?? nodeSize.height,
193
+ };
183
194
 
184
195
  // #421: Suppress project-type container nodes in the network layout by
185
196
  // default (see suppressProjectNodes JSDoc). A shallow copy avoids mutating
@@ -188,7 +199,7 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
188
199
  ? { ...doc, activities: (doc.activities ?? []).filter((a) => a.activity_type?.toLowerCase() !== 'project') }
189
200
  : doc;
190
201
 
191
- const layout: ActivitiesLayout = layoutActivities(renderDoc, gaps);
202
+ const layout: ActivitiesLayout = layoutActivities(renderDoc, layoutGaps);
192
203
 
193
204
  if (layout.nodes.length === 0) {
194
205
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;