@transitrix/diagrams 1.7.6 → 1.8.1

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 (53) hide show
  1. package/dist/activities/layout.js +7 -7
  2. package/dist/activities/types.d.ts +4 -0
  3. package/dist/capability-map/dsm-schema.d.ts +1 -1
  4. package/dist/capability-map/dsm-schema.js +1 -1
  5. package/dist/capability-map/layout-tree.d.ts +5 -1
  6. package/dist/capability-map/layout-tree.js +11 -9
  7. package/dist/capability-map/render-capability-tree.d.ts +2 -9
  8. package/dist/capability-map/render-capability-tree.js +16 -28
  9. package/dist/fgca/preview-layout.d.ts +8 -4
  10. package/dist/fgca/preview-layout.js +13 -13
  11. package/dist/node-size-presets.d.ts +50 -0
  12. package/dist/node-size-presets.js +103 -0
  13. package/dist/process-blueprint/layout.js +4 -47
  14. package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
  15. package/dist/webview/__tests__/entity-text-layout.test.js +43 -0
  16. package/dist/webview/__tests__/entry.test.js +3 -3
  17. package/dist/webview/__tests__/render-blocks.test.js +1 -7
  18. package/dist/webview/entity-text-layout.d.ts +95 -0
  19. package/dist/webview/entity-text-layout.js +279 -0
  20. package/dist/webview/entry.js +7 -3
  21. package/dist/webview/notation-style.d.ts +1 -1
  22. package/dist/webview/notation-style.js +1 -1
  23. package/dist/webview/render-activities.d.ts +3 -1
  24. package/dist/webview/render-activities.js +29 -32
  25. package/dist/webview/render-blocks.d.ts +4 -1
  26. package/dist/webview/render-blocks.js +27 -108
  27. package/dist/webview/render-fgca.d.ts +15 -8
  28. package/dist/webview/render-fgca.js +41 -44
  29. package/dist/webview/render-goals.d.ts +2 -0
  30. package/dist/webview/render-goals.js +21 -36
  31. package/dist/webview/render-process-blueprint.d.ts +4 -10
  32. package/dist/webview/render-process-blueprint.js +18 -64
  33. package/package.json +1 -1
  34. package/src/activities/layout.ts +12 -7
  35. package/src/activities/types.ts +4 -0
  36. package/src/capability-map/dsm-schema.ts +1 -1
  37. package/src/capability-map/index.ts +1 -1
  38. package/src/capability-map/layout-tree.ts +16 -8
  39. package/src/capability-map/render-capability-tree.ts +18 -30
  40. package/src/fgca/preview-layout.ts +21 -15
  41. package/src/node-size-presets.ts +157 -0
  42. package/src/process-blueprint/layout.ts +4 -48
  43. package/src/webview/__tests__/entity-text-layout.test.ts +52 -0
  44. package/src/webview/__tests__/entry.test.ts +3 -3
  45. package/src/webview/__tests__/render-blocks.test.ts +1 -9
  46. package/src/webview/entity-text-layout.ts +355 -0
  47. package/src/webview/entry.ts +8 -4
  48. package/src/webview/notation-style.ts +1 -1
  49. package/src/webview/render-activities.ts +38 -27
  50. package/src/webview/render-blocks.ts +35 -118
  51. package/src/webview/render-fgca.ts +59 -44
  52. package/src/webview/render-goals.ts +22 -34
  53. package/src/webview/render-process-blueprint.ts +30 -65
@@ -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>`;
@@ -13,67 +13,23 @@
13
13
  * title `<text>`.
14
14
  */
15
15
  import { layoutNestedBlocks } from '../blocks/layout.js';
16
- import type { BlocksFile, BlocksLayout, LaidOutBlock } from '../blocks/types.js';
16
+ import type { BlocksFile, BlocksLayout, BlocksLayoutOptions, LaidOutBlock } from '../blocks/types.js';
17
+ import { parseNodeSizePreset, resolveBlocksLeafSize, type NodeSizePreset } from '../node-size-presets.js';
17
18
  import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
19
+ import {
20
+ emitCenteredTextSvg,
21
+ layoutHeaderBlockText,
22
+ layoutLeafBlockText,
23
+ } from './entity-text-layout.js';
18
24
  import { escXml } from './render-util.js';
19
25
  import { ENTITY_NODE_RX } from './notation-style.js';
20
26
 
21
27
  const PAD = 24;
22
28
 
23
- // Approximate character width (px) at text-primary size (12px/600).
24
- const CHAR_W = 7;
25
- // Approximate character width (px) at text-id size (10px/600).
26
- const CHAR_W_ID = 6;
27
-
28
- // Vertical spacing between line centres.
29
- const LINE_H = 14; // name lines (text-primary, 12 px font)
30
- const LINE_H_ID = 12; // id lines (text-id, 10 px font)
31
- // Minimum gap to avoid overlap: half of name text height (6 px) + half of id
32
- // text height (5 px) = 11 px. Use 14 px to match the name-line separation and
33
- // leave a 3 px visual buffer — same as the gap between consecutive name lines.
34
- const NAME_ID_GAP = 14;
35
-
36
- /** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
37
- function wrapWords(text: string, maxChars: number, maxLines: number): string[] {
38
- const safeMax = Math.max(2, maxChars);
39
- const words = text.split(' ');
40
- const lines: string[] = [];
41
- let cur = '';
42
- for (let i = 0; i < words.length; i++) {
43
- const w = words[i];
44
- const next = cur ? `${cur} ${w}` : w;
45
- if (next.length <= safeMax) {
46
- cur = next;
47
- } else {
48
- if (cur) lines.push(cur);
49
- cur = '';
50
- if (lines.length >= maxLines) break;
51
- if (lines.length === maxLines - 1) {
52
- const rest = words.slice(i).join(' ');
53
- lines.push(rest.length <= safeMax ? rest : rest.slice(0, safeMax - 1) + '…');
54
- return lines;
55
- }
56
- cur = w.length <= safeMax ? w : w.slice(0, safeMax - 1) + '…';
57
- }
58
- }
59
- if (cur && lines.length < maxLines) lines.push(cur);
60
- return lines.length ? lines : [text.slice(0, safeMax - 1) + '…'];
61
- }
62
-
63
- /** Split an ID (no spaces) across at most 2 lines, breaking at `_` or `-`. */
64
- function wrapId(id: string, maxChars: number): string[] {
65
- if (id.length <= maxChars) return [id];
66
- const seg = id.slice(0, maxChars);
67
- const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
68
- const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
69
- const isSep = id[cut] === '_' || id[cut] === '-';
70
- const line1 = id.slice(0, cut);
71
- const rest = id.slice(cut + (isSep ? 1 : 0));
72
- return [line1, rest.length <= maxChars ? rest : rest.slice(0, maxChars - 1) + '…'];
73
- }
74
-
75
29
  export interface RenderBlocksOptions {
76
30
  title?: string;
31
+ nodeSizePreset?: NodeSizePreset;
32
+ layoutOptions?: BlocksLayoutOptions;
77
33
  }
78
34
 
79
35
  /**
@@ -89,27 +45,6 @@ function levelClassForDepth(depth: number): string {
89
45
  return `level-${idx}`;
90
46
  }
91
47
 
92
- // Reserve a small horizontal margin so text never abuts the rounded corners.
93
- const TEXT_MARGIN_X = 8;
94
-
95
- /** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
96
- function truncateLine(text: string, maxChars: number): string {
97
- const safeMax = Math.max(2, maxChars);
98
- if (text.length <= safeMax) return text;
99
- const cut = text.slice(0, safeMax - 1);
100
- const lastSpace = cut.lastIndexOf(' ');
101
- return (lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut) + '…';
102
- }
103
-
104
- /** Single-line ID truncation that prefers `_`/`-` separators before falling back to a hard cut. */
105
- function truncateId(id: string, maxChars: number): string {
106
- const safeMax = Math.max(2, maxChars);
107
- if (id.length <= safeMax) return id;
108
- const cut = id.slice(0, safeMax - 1);
109
- const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
110
- return (sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut) + '…';
111
- }
112
-
113
48
  function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]): void {
114
49
  const cls = levelClassForDepth(b.depth);
115
50
  const cx = b.x + ox + b.width / 2;
@@ -117,50 +52,27 @@ function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]):
117
52
  `<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="${ENTITY_NODE_RX}"/>`,
118
53
  );
119
54
 
120
- const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
121
55
  const isLeaf = b.children.length === 0;
122
56
  if (isLeaf) {
123
- // Leaf block: name (up to 3 lines) then ID (up to 2 lines), centred vertically.
124
- const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
125
- const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
126
- const nameLines = wrapWords(b.name, maxCharsName, 3);
127
- const idLines = wrapId(b.id, maxCharsId);
128
- const nameTotalSpan = (nameLines.length - 1) * LINE_H;
129
- const idTotalSpan = (idLines.length - 1) * LINE_H_ID;
130
- const totalSpan = nameTotalSpan + NAME_ID_GAP + idTotalSpan;
131
- const firstY = Math.round(b.y + oy + (b.height - totalSpan) / 2);
132
- for (let i = 0; i < nameLines.length; i++) {
133
- parts.push(
134
- `<text class="text-primary" x="${cx}" y="${firstY + i * LINE_H}" text-anchor="middle" dominant-baseline="central">${escXml(nameLines[i])}</text>`,
135
- );
136
- }
137
- const idFirstY = firstY + nameTotalSpan + NAME_ID_GAP;
138
- for (let i = 0; i < idLines.length; i++) {
139
- parts.push(
140
- `<text class="text-id" x="${cx}" y="${idFirstY + i * LINE_H_ID}" text-anchor="middle" dominant-baseline="central">${escXml(idLines[i])}</text>`,
141
- );
142
- }
57
+ const specs = layoutLeafBlockText({
58
+ boxX: b.x + ox,
59
+ boxY: b.y + oy,
60
+ boxWidth: b.width,
61
+ boxHeight: b.height,
62
+ name: b.name,
63
+ id: b.id,
64
+ });
65
+ parts.push(emitCenteredTextSvg(specs, cx, escXml));
143
66
  } else {
144
- // Container block: name (text-primary) above, ID (text-id, grey) below — both
145
- // single-line, truncated with `…` to fit within `b.width`. The header strip is
146
- // only `headerHeight` tall (default 28px), which is enough for one line each at
147
- // the configured text sizes. Stacking the ID as its own text-id element keeps
148
- // the wrapping/truncation rule consistent with leaves and guarantees no overflow
149
- // for any combination of name/ID length.
150
- const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
151
- const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
152
- const nameText = truncateLine(b.name, maxCharsName);
153
- const idText = truncateId(b.id, maxCharsId);
154
- const headerMid = b.y + oy + b.headerHeight / 2;
155
- const halfSpan = (LINE_H + LINE_H_ID) / 4;
156
- const nameY = Math.round(headerMid - halfSpan);
157
- const idY = Math.round(headerMid + halfSpan);
158
- parts.push(
159
- `<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}</text>`,
160
- );
161
- parts.push(
162
- `<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>`,
163
- );
67
+ const specs = layoutHeaderBlockText({
68
+ boxX: b.x + ox,
69
+ boxY: b.y + oy,
70
+ boxWidth: b.width,
71
+ headerHeight: b.headerHeight,
72
+ name: b.name,
73
+ id: b.id,
74
+ });
75
+ parts.push(emitCenteredTextSvg(specs, cx, escXml));
164
76
  for (const c of b.children) emitBlockSvg(c, ox, oy, parts);
165
77
  }
166
78
  }
@@ -210,9 +122,14 @@ ${parts.join('\n')}
210
122
  * with the shared theme CSS embedded so the output is self-contained.
211
123
  */
212
124
  export function renderBlocksSvg(doc: BlocksFile, options: RenderBlocksOptions = {}): string {
213
- const { title = '' } = options;
214
-
215
- const layout: BlocksLayout = layoutNestedBlocks(doc);
125
+ const { title = '', nodeSizePreset = 'normal', layoutOptions } = options;
126
+ const leaf = resolveBlocksLeafSize(parseNodeSizePreset(nodeSizePreset));
127
+
128
+ const layout: BlocksLayout = layoutNestedBlocks(doc, {
129
+ leafWidth: leaf.width,
130
+ leafHeight: leaf.height,
131
+ ...layoutOptions,
132
+ });
216
133
 
217
134
  if (layout.blocks.length === 0) {
218
135
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
@@ -1,28 +1,30 @@
1
1
  /**
2
- * Browser-safe SVG renderer for the canonical FGCA and FGA notations.
2
+ * Browser-safe SVG renderer for the canonical DGCA and DGA notations.
3
3
  *
4
4
  * Step 4 of the IntelliJ epic (ADR 0001): the webview bundle must turn a
5
- * validated FGCA/FGA document into renderable SVG so JCEF can drop it into the
6
- * preview panel. The VS Code path lives in `extension/src/fgca-preview.ts` and
5
+ * validated DGCA/DGA document into renderable SVG so JCEF can drop it into the
6
+ * preview panel. The VS Code path lives in `extension/src/dgca-preview.ts` and
7
7
  * pulls in VS Code-specific concerns (themes, title block, save dialogs,
8
8
  * spacing/scope controls); this module is the host-neutral subset — pure
9
9
  * column layout → SVG with no VS Code APIs, no `node:fs`, no `node:path`.
10
10
  *
11
- * FGCA and FGA share the same renderer; FGA only differs by hiding the
11
+ * DGCA and DGA share the same renderer; DGA only differs by hiding the
12
12
  * `change` column (`hideChanges` / the layout's Goal → Activity collapse),
13
13
  * exactly as the extension preview does.
14
14
  */
15
15
  import {
16
16
  layoutFGCAPreview,
17
- FGCA_NODE_W as NODE_W,
18
- FGCA_NODE_H as NODE_H,
17
+ FGCA_NODE_W,
18
+ FGCA_NODE_H,
19
19
  FGCA_HEADER_H as HEADER_H,
20
20
  FGCA_PAD as PAD,
21
21
  type FGCAPreviewColumn,
22
22
  } from '../fgca/preview-layout.js';
23
23
  import type { FGCADoc } from '../fgca/validate.js';
24
24
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
25
+ import { parseNodeSizePreset, resolveDgcaNodeSize, type NodeSizePreset } from '../node-size-presets.js';
25
26
  import { generateSvgEmbedCss } from '../theme/index.js';
27
+ import { emitCenteredTextSvg, layoutCenteredEntityText, truncateLine } from './entity-text-layout.js';
26
28
  import { escXml } from './render-util.js';
27
29
 
28
30
  const COL_LABELS: Record<FGCAPreviewColumn, string> = {
@@ -34,8 +36,13 @@ const COL_LABELS: Record<FGCAPreviewColumn, string> = {
34
36
 
35
37
  type FgcaLayout = ReturnType<typeof layoutFGCAPreview>;
36
38
 
39
+ export interface RenderFgcaBodyOptions {
40
+ nodeWidth?: number;
41
+ nodeHeight?: number;
42
+ }
43
+
37
44
  /**
38
- * Canonical FGCA/FGA body — the column headers, node rects and edge paths that
45
+ * Canonical DGCA/DGA body — the column headers, node rects and edge paths that
39
46
  * go inside the `<svg>`, shared verbatim by the VS Code preview (`buildSvg`)
40
47
  * and the host-neutral wrapper below. Excludes the host-specific title block
41
48
  * and the (identical) `<defs>` arrow marker. Coordinates are absolute (the
@@ -49,12 +56,17 @@ export function renderFgcaBody(
49
56
  edges: FgcaLayout['edges'],
50
57
  curvature: number,
51
58
  entryCurvature: number | undefined,
59
+ bodyOptions: RenderFgcaBodyOptions = {},
52
60
  ): string {
61
+ const nodeWidth = bodyOptions.nodeWidth ?? FGCA_NODE_W;
62
+ const nodeHeight = bodyOptions.nodeHeight ?? FGCA_NODE_H;
63
+ const headerTruncate = Math.max(8, Math.floor((nodeWidth - 16) / 7));
64
+
53
65
  const headerSvg = columns
54
66
  .map(({ col, x }) =>
55
67
  [
56
- `<rect class="diagram-node layer-${col}" x="${x}" y="${PAD}" width="${NODE_W}" height="${HEADER_H}" rx="6"/>`,
57
- `<text class="text-header" x="${x + NODE_W / 2}" y="${PAD + HEADER_H / 2}" text-anchor="middle" dominant-baseline="central">${escXml(COL_LABELS[col])}</text>`,
68
+ `<rect class="diagram-node layer-${col}" x="${x}" y="${PAD}" width="${nodeWidth}" height="${HEADER_H}" rx="6"/>`,
69
+ `<text class="text-header" x="${x + nodeWidth / 2}" y="${PAD + HEADER_H / 2}" text-anchor="middle" dominant-baseline="central">${escXml(truncateLine(COL_LABELS[col], headerTruncate))}</text>`,
58
70
  ].join('\n'),
59
71
  )
60
72
  .join('\n');
@@ -68,34 +80,22 @@ export function renderFgcaBody(
68
80
 
69
81
  const nodeSvg = nodes
70
82
  .map((n) => {
71
- const words = n.label.split(' ');
72
- let line1 = '';
73
- let line2 = '';
74
- for (const w of words) {
75
- if ((line1 + ' ' + w).trim().length <= 26) {
76
- line1 = (line1 + ' ' + w).trim();
77
- } else if ((line2 + ' ' + w).trim().length <= 26) {
78
- line2 = (line2 + ' ' + w).trim();
79
- } else if (!line2) {
80
- line2 = w.slice(0, 24) + '…';
81
- break;
82
- }
83
- }
84
- const twoLines = line2.length > 0;
85
- const y1 = twoLines ? n.y + 16 : n.y + 26;
86
- const y2 = n.y + 32;
87
- const idY = twoLines ? n.y + 54 : n.y + 50;
88
83
  const entityId = n.id.slice(n.id.indexOf('_') + 1);
84
+ const specs = layoutCenteredEntityText({
85
+ boxX: n.x,
86
+ boxY: n.y,
87
+ boxWidth: nodeWidth,
88
+ boxHeight: nodeHeight,
89
+ name: n.label,
90
+ id: entityId,
91
+ nameMaxLines: 2,
92
+ idMaxLines: 1,
93
+ });
94
+ const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
89
95
  return [
90
- `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="8"/>`,
91
- `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
92
- twoLines
93
- ? `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
94
- : '',
95
- `<text class="text-id" x="${n.x + NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(entityId)}</text>`,
96
- ]
97
- .filter(Boolean)
98
- .join('\n');
96
+ `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${nodeWidth}" height="${nodeHeight}" rx="8"/>`,
97
+ textSvg,
98
+ ].join('\n');
99
99
  })
100
100
  .join('\n');
101
101
 
@@ -103,35 +103,50 @@ export function renderFgcaBody(
103
103
  }
104
104
 
105
105
  export interface RenderFgcaOptions {
106
- /** `'fga'` hides the Changes column; defaults to `'fgca'`. */
107
- variant?: 'fgca' | 'fga';
106
+ /** `'dga'` hides the Changes column; defaults to `'dgca'`. */
107
+ variant?: 'dgca' | 'dga';
108
108
  /** Optional heading rendered as a left-anchored `text-header` line. */
109
109
  title?: string;
110
110
  /** Exit edge curvature; 1 = default, 0 = straight, higher = stronger arc. */
111
111
  curvature?: number;
112
112
  /** Entry curvature at the target node; defaults to `curvature` when omitted. */
113
113
  entryCurvature?: number;
114
+ nodeSizePreset?: NodeSizePreset;
115
+ layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
114
116
  }
115
117
 
116
118
  export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
117
- const { variant = 'fgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
118
- const hideChanges = variant === 'fga';
119
+ const {
120
+ variant = 'dgca',
121
+ title = '',
122
+ curvature = DEFAULT_EDGE_CURVATURE,
123
+ entryCurvature,
124
+ nodeSizePreset = 'normal',
125
+ layoutOptions,
126
+ } = options;
127
+ const hideChanges = variant === 'dga';
128
+ const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
119
129
 
120
- const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, { hideChanges });
130
+ const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
131
+ hideChanges,
132
+ nodeWidth: nodeSize.width,
133
+ nodeHeight: nodeSize.height,
134
+ ...layoutOptions,
135
+ });
121
136
 
122
137
  if (nodes.length === 0) {
123
138
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
124
139
  }
125
140
 
126
- const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature);
141
+ const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature, {
142
+ nodeWidth: nodeSize.width,
143
+ nodeHeight: nodeSize.height,
144
+ });
127
145
 
128
146
  const titleSvg = title
129
147
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
130
148
  : '';
131
149
 
132
- // Embed the shared theme CSS inside the SVG so the rendered output is
133
- // self-contained — the JCEF host page only needs to drop the SVG into the
134
- // DOM and styling resolves without any cooperation from the host stylesheet.
135
150
  const embedCss = generateSvgEmbedCss('transitrix');
136
151
 
137
152
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
@@ -2,8 +2,8 @@
2
2
  * Browser-safe SVG renderer for the canonical Goals notation.
3
3
  *
4
4
  * Step 3 of the IntelliJ epic (ADR 0001): the webview bundle must turn a
5
- * validated GoalTree into renderable SVG so JCEF can drop it into the preview
6
- * panel. The VS Code path lives in `extension/src/goals-preview.ts` and pulls
5
+ * validated GoalTree into renderable SVG so JCEF can drop it into the
6
+ * preview panel. The VS Code path lives in `extension/src/goals-preview.ts` and pulls
7
7
  * in VS Code-specific concerns (themes, title block, save dialogs); this
8
8
  * module is the host-neutral subset — pure layout → SVG with no VS Code APIs,
9
9
  * no `node:fs`, no `node:path`.
@@ -14,21 +14,21 @@ import { layoutGoalTree } from '../goals/layout.js';
14
14
  import type { GoalTree, GoalTreeLayout, LaidOutEdge } from '../goals/types.js';
15
15
  import { displayGoalId } from '../goals/parse-canonical.js';
16
16
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
17
+ import { parseNodeSizePreset, resolveGoalsNodeSize, type NodeSizePreset } from '../node-size-presets.js';
17
18
  import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
19
+ import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
18
20
  import { escXml } from './render-util.js';
19
21
  import { ENTITY_NODE_RX } from './notation-style.js';
20
22
 
21
- const NODE_W = 250;
22
- const NODE_H = 72;
23
23
  const RANK_SEP = 100;
24
24
  const NODE_SEP = 24;
25
25
  const PAD = 24;
26
- const LABEL_CHARS = 30;
27
26
 
28
27
  export interface RenderGoalsOptions {
29
28
  treeName?: string;
30
29
  curvature?: number;
31
30
  entryCurvature?: number;
31
+ nodeSizePreset?: NodeSizePreset;
32
32
  }
33
33
 
34
34
  /**
@@ -37,11 +37,12 @@ export interface RenderGoalsOptions {
37
37
  * with the shared theme CSS embedded so the output is self-contained.
38
38
  */
39
39
  export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {}): string {
40
- const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
40
+ const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal' } = options;
41
+ const nodeSize = resolveGoalsNodeSize(parseNodeSizePreset(nodeSizePreset));
41
42
 
42
43
  const layout: GoalTreeLayout = layoutGoalTree(tree, {
43
- nodeWidth: NODE_W,
44
- nodeHeight: NODE_H,
44
+ nodeWidth: nodeSize.width,
45
+ nodeHeight: nodeSize.height,
45
46
  rankSep: RANK_SEP,
46
47
  nodeSep: NODE_SEP,
47
48
  });
@@ -54,10 +55,6 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
54
55
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
55
56
  : '';
56
57
 
57
- // Embed the shared theme CSS so the JCEF host page only needs to drop the SVG
58
- // into the DOM — styling resolves without help from the host stylesheet. The
59
- // VS Code preview omits this (its webview supplies the CSS) and embeds it only
60
- // on export via `prepareSvgForExport`.
61
58
  return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, title, embedCssTheme: 'transitrix' });
62
59
  }
63
60
 
@@ -111,32 +108,23 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
111
108
  const y = n.y + oy;
112
109
  const level = n.data.level % 8;
113
110
  const labelText = n.data.name ?? String(n.id);
114
- const words = labelText.split(' ');
115
- let line1 = '';
116
- let line2 = '';
117
- for (const w of words) {
118
- if ((line1 + ' ' + w).trim().length <= LABEL_CHARS) {
119
- line1 = (line1 + ' ' + w).trim();
120
- } else if ((line2 + ' ' + w).trim().length <= LABEL_CHARS) {
121
- line2 = (line2 + ' ' + w).trim();
122
- } else if (!line2) {
123
- line2 = w.slice(0, LABEL_CHARS - 2) + '…';
124
- break;
125
- }
126
- }
127
- const twoLines = line2.length > 0;
128
- const nameY1 = twoLines ? y + 12 : y + 16;
129
- const nameY2 = y + 26;
130
- const typeY = twoLines ? y + 43 : y + 35;
131
- const idY = twoLines ? y + 59 : y + 55;
132
111
  const typeLabel = n.data.type ?? '';
133
112
  const idText = displayGoalId(n.data);
113
+ const specs = layoutCenteredEntityText({
114
+ boxX: x,
115
+ boxY: y,
116
+ boxWidth: n.width,
117
+ boxHeight: n.height,
118
+ name: labelText,
119
+ type: typeLabel || undefined,
120
+ id: idText,
121
+ nameMaxLines: 2,
122
+ idMaxLines: 1,
123
+ });
124
+ const textSvg = emitCenteredTextSvg(specs, x + n.width / 2, escXml);
134
125
  return `<g>
135
126
  <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
136
- <text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `
137
- <text class="text-primary" x="${x + n.width / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : ''}${typeLabel ? `
138
- <text class="text-secondary" x="${x + n.width / 2}" y="${typeY}" text-anchor="middle" dominant-baseline="central">${escXml(typeLabel)}</text>` : ''}
139
- <text class="text-id" x="${x + n.width / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>
127
+ ${textSvg}
140
128
  </g>`;
141
129
  })
142
130
  .join('\n');