@transitrix/diagrams 1.0.1 → 1.2.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 (137) hide show
  1. package/dist/activities/__tests__/validate.test.js +32 -1
  2. package/dist/activities/cpm.js +11 -2
  3. package/dist/activities/gantt.js +6 -3
  4. package/dist/activities/types.d.ts +1 -0
  5. package/dist/activities/validate.js +24 -8
  6. package/dist/activity-card/__tests__/resolver.test.js +22 -1
  7. package/dist/activity-card/__tests__/validate.test.js +23 -1
  8. package/dist/activity-card/resolver.js +28 -20
  9. package/dist/activity-card/validate.js +22 -11
  10. package/dist/capability-map/index.d.ts +4 -0
  11. package/dist/capability-map/index.js +2 -0
  12. package/dist/capability-map/layout-tree.d.ts +42 -0
  13. package/dist/capability-map/layout-tree.js +65 -0
  14. package/dist/capability-map/render-capability-tree.d.ts +15 -0
  15. package/dist/capability-map/render-capability-tree.js +108 -0
  16. package/dist/capability-map/types.d.ts +2 -0
  17. package/dist/compliance/__tests__/impact.test.js +198 -0
  18. package/dist/compliance/html.js +1 -7
  19. package/dist/compliance/impact.d.ts +24 -1
  20. package/dist/compliance/impact.js +97 -21
  21. package/dist/factor/__tests__/validate.test.js +3 -3
  22. package/dist/factor/types.d.ts +1 -1
  23. package/dist/factor/validate.js +2 -2
  24. package/dist/fgca/__tests__/chain-table.test.js +2 -2
  25. package/dist/fgca/__tests__/layout.test.js +11 -11
  26. package/dist/fgca/__tests__/preview-layout.test.js +7 -7
  27. package/dist/fgca/__tests__/resolver.test.js +2 -2
  28. package/dist/fgca/__tests__/validate.test.js +2 -2
  29. package/dist/fgca/chain-table.d.ts +1 -1
  30. package/dist/fgca/chain-table.js +4 -4
  31. package/dist/fgca/index.d.ts +2 -2
  32. package/dist/fgca/index.js +1 -1
  33. package/dist/fgca/layout.d.ts +2 -2
  34. package/dist/fgca/layout.js +9 -9
  35. package/dist/fgca/nodeTypes.d.ts +1 -1
  36. package/dist/fgca/nodeTypes.js +2 -2
  37. package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
  38. package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
  39. package/dist/fgca/parse-canonical.js +24 -13
  40. package/dist/fgca/preview-layout.d.ts +1 -1
  41. package/dist/fgca/preview-layout.js +4 -4
  42. package/dist/fgca/resolver.js +3 -1
  43. package/dist/fgca/types.d.ts +13 -11
  44. package/dist/fgca/types.js +2 -2
  45. package/dist/fgca/validate.d.ts +2 -2
  46. package/dist/fgca/validate.js +34 -23
  47. package/dist/process-blueprint/index.d.ts +1 -1
  48. package/dist/process-blueprint/layout.js +64 -37
  49. package/dist/process-blueprint/types.d.ts +14 -0
  50. package/dist/scenarios/index.d.ts +1 -1
  51. package/dist/scenarios/types.d.ts +4 -4
  52. package/dist/theme/index.d.ts +1 -1
  53. package/dist/theme/index.js +1 -1
  54. package/dist/theme/themes.js +28 -3
  55. package/dist/theme/tokens.d.ts +47 -1
  56. package/dist/theme/tokens.js +31 -1
  57. package/dist/webview/__tests__/entry-notations.test.js +2 -0
  58. package/dist/webview/__tests__/render-process.test.d.ts +1 -0
  59. package/dist/webview/__tests__/render-process.test.js +463 -0
  60. package/dist/webview/__tests__/render-util.test.d.ts +1 -0
  61. package/dist/webview/__tests__/render-util.test.js +54 -0
  62. package/dist/webview/entry.d.ts +1 -1
  63. package/dist/webview/entry.js +9 -5
  64. package/dist/webview/render-activities.d.ts +21 -1
  65. package/dist/webview/render-activities.js +91 -56
  66. package/dist/webview/render-activity-card.d.ts +11 -1
  67. package/dist/webview/render-activity-card.js +11 -2
  68. package/dist/webview/render-blocks.d.ts +24 -1
  69. package/dist/webview/render-blocks.js +58 -24
  70. package/dist/webview/render-fgca.d.ts +27 -0
  71. package/dist/webview/render-fgca.js +30 -16
  72. package/dist/webview/render-goals.d.ts +26 -1
  73. package/dist/webview/render-goals.js +30 -20
  74. package/dist/webview/render-process-blueprint.d.ts +11 -1
  75. package/dist/webview/render-process-blueprint.js +118 -53
  76. package/dist/webview/render-process.d.ts +95 -0
  77. package/dist/webview/render-process.js +392 -0
  78. package/dist/webview/render-scenarios.js +2 -2
  79. package/dist/webview/render-util.d.ts +19 -6
  80. package/dist/webview/render-util.js +23 -15
  81. package/package.json +15 -1
  82. package/src/activities/__tests__/validate.test.ts +35 -1
  83. package/src/activities/cpm.ts +14 -2
  84. package/src/activities/gantt.ts +6 -5
  85. package/src/activities/types.ts +1 -0
  86. package/src/activities/validate.ts +24 -8
  87. package/src/activity-card/__tests__/resolver.test.ts +24 -1
  88. package/src/activity-card/__tests__/validate.test.ts +25 -1
  89. package/src/activity-card/resolver.ts +29 -20
  90. package/src/activity-card/validate.ts +20 -11
  91. package/src/capability-map/index.ts +11 -0
  92. package/src/capability-map/layout-tree.ts +114 -0
  93. package/src/capability-map/render-capability-tree.ts +131 -0
  94. package/src/capability-map/types.ts +2 -0
  95. package/src/compliance/__tests__/impact.test.ts +219 -0
  96. package/src/compliance/html.ts +1 -8
  97. package/src/compliance/impact.ts +141 -23
  98. package/src/factor/__tests__/validate.test.ts +3 -3
  99. package/src/factor/types.ts +1 -1
  100. package/src/factor/validate.ts +2 -2
  101. package/src/fgca/__tests__/chain-table.test.ts +2 -2
  102. package/src/fgca/__tests__/layout.test.ts +13 -13
  103. package/src/fgca/__tests__/preview-layout.test.ts +7 -7
  104. package/src/fgca/__tests__/resolver.test.ts +2 -2
  105. package/src/fgca/__tests__/validate.test.ts +2 -2
  106. package/src/fgca/chain-table.ts +5 -5
  107. package/src/fgca/index.ts +3 -3
  108. package/src/fgca/layout.ts +11 -11
  109. package/src/fgca/nodeTypes.ts +3 -3
  110. package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
  111. package/src/fgca/parse-canonical.ts +26 -15
  112. package/src/fgca/preview-layout.ts +5 -5
  113. package/src/fgca/resolver.ts +3 -1
  114. package/src/fgca/types.ts +15 -13
  115. package/src/fgca/validate.ts +32 -21
  116. package/src/process-blueprint/index.ts +1 -0
  117. package/src/process-blueprint/layout.ts +71 -42
  118. package/src/process-blueprint/types.ts +15 -0
  119. package/src/scenarios/index.ts +2 -2
  120. package/src/scenarios/types.ts +4 -4
  121. package/src/scenarios/validate.ts +3 -3
  122. package/src/theme/index.ts +1 -1
  123. package/src/theme/themes.ts +29 -2
  124. package/src/theme/tokens.ts +33 -1
  125. package/src/webview/__tests__/entry-notations.test.ts +2 -0
  126. package/src/webview/__tests__/render-process.test.ts +538 -0
  127. package/src/webview/__tests__/render-util.test.ts +66 -0
  128. package/src/webview/entry.ts +11 -5
  129. package/src/webview/render-activities.ts +107 -67
  130. package/src/webview/render-activity-card.ts +11 -2
  131. package/src/webview/render-blocks.ts +78 -28
  132. package/src/webview/render-fgca.ts +53 -29
  133. package/src/webview/render-goals.ts +45 -23
  134. package/src/webview/render-process-blueprint.ts +156 -82
  135. package/src/webview/render-process.ts +507 -0
  136. package/src/webview/render-scenarios.ts +3 -3
  137. package/src/webview/render-util.ts +27 -14
@@ -11,6 +11,10 @@
11
11
  * Only the DEFAULT network view (Project Schedule Network Diagram) is ported.
12
12
  * The Gantt view, the CSS-only tab switcher and the interactive spacing /
13
13
  * curvature controls stay in the VS Code preview.
14
+ *
15
+ * Single-emitter unification (review C): the canonical network body lives here
16
+ * in `renderActivitiesNetworkBody` and is shared verbatim with the VS Code
17
+ * preview's `networkSvg`.
14
18
  */
15
19
  import { layoutActivities } from '../activities/layout.js';
16
20
  import { computeCpm } from '../activities/cpm.js';
@@ -27,22 +31,110 @@ const N_NODE_W = 200;
27
31
  const N_NODE_H = 80;
28
32
  const N_PAD = 24;
29
33
 
30
- // Activities-specific diagram CSS. Mirrors `ACTIVITIES_DIAGRAM_CSS` from the
31
- // VS Code preview — the rules that shape the network diagram itself (critical
32
- // path, milestones, edge colours). Embedded alongside the shared theme CSS so
33
- // the SVG is self-contained for the JCEF host.
34
- const ACTIVITIES_DIAGRAM_CSS = `
35
- .act-node { fill: var(--ts-bg-surface, #f8fafc); stroke: var(--ts-border, #94a3b8); stroke-width: 1.5; }
34
+ /**
35
+ * Network-view diagram CSS (critical path, milestones, edge colours) — the
36
+ * canonical rules shared with the VS Code preview's `ACTIVITIES_DIAGRAM_CSS`
37
+ * network subset. Embedded alongside the shared theme CSS so the SVG is
38
+ * self-contained for the JCEF host.
39
+ */
40
+ export const ACTIVITIES_NETWORK_CSS = `
41
+ .act-node { fill: var(--ts-layer-activity, #d4edda); stroke: var(--ts-node-stroke, #94a3b8); stroke-width: 1; }
36
42
  .critical-node { fill: #fff7ed; stroke: var(--ts-brand-orange, #ff4d00); stroke-width: 2.5; }
37
43
  .milestone-node { fill: #ecfeff; stroke: var(--ts-text-muted, #64748b); stroke-dasharray: 4 2; }
38
44
  .critical-edge { stroke: var(--ts-brand-orange, #ff4d00); stroke-width: 2; }
39
45
  .arrow-fill-critical { fill: var(--ts-brand-orange, #ff4d00); }
40
46
  `;
41
47
 
48
+ /** Arrowhead marker defs shared by both hosts for the network view. */
49
+ export const ACTIVITIES_NETWORK_DEFS = `<defs>
50
+ <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
51
+ <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
52
+ </marker>
53
+ <marker id="arrow-crit" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
54
+ <path d="M0,0 L0,6 L8,3 z" class="arrow-fill-critical"/>
55
+ </marker>
56
+ </defs>`;
57
+
42
58
  function truncate(s: string, maxLen: number): string {
43
59
  return s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
44
60
  }
45
61
 
62
+ /**
63
+ * Canonical Network (PSND) body — the node rects and edge paths that go inside
64
+ * the `<svg>`, shared verbatim by the VS Code preview and the host-neutral
65
+ * wrapper below. Excludes the host-specific title block and the (identical)
66
+ * marker defs (`ACTIVITIES_NETWORK_DEFS`).
67
+ *
68
+ * `ox`/`oy` are the canvas offsets (the caller folds in padding + any title
69
+ * height); `curvature`/`entryCurvature` scale the exit/entry edge handles.
70
+ */
71
+ export function renderActivitiesNetworkBody(
72
+ layout: ActivitiesLayout,
73
+ cpm: ReturnType<typeof computeCpm>,
74
+ ox: number,
75
+ oy: number,
76
+ curvature: number,
77
+ entryCurvature: number | undefined,
78
+ ): string {
79
+ const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
80
+
81
+ // SVG paints later siblings over earlier ones. Sort non-critical first so the
82
+ // bright orange critical-path edges land on top — a gray edge crossing an
83
+ // orange one shouldn't bury the critical signal.
84
+ const orderedEdges = [...layout.edges].sort(
85
+ (a, b) => Number(Boolean(a.isCritical)) - Number(Boolean(b.isCritical)),
86
+ );
87
+
88
+ const edgeSvg = orderedEdges
89
+ .map((e) => {
90
+ const s = nodeMap.get(e.sourceId);
91
+ const t = nodeMap.get(e.targetId);
92
+ if (!s || !t) return '';
93
+ const sx = s.x + ox + s.width;
94
+ const sy = s.y + oy + s.height / 2;
95
+ const tx = t.x + ox;
96
+ const ty = t.y + oy + t.height / 2;
97
+ const cls = e.isCritical ? 'diagram-edge critical-edge' : 'diagram-edge';
98
+ const marker = `url(#${e.isCritical ? 'arrow-crit' : 'arrow'})`;
99
+ return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature)}" class="${cls}" marker-end="${marker}"/>`;
100
+ })
101
+ .join('\n');
102
+
103
+ const nodeSvg = layout.nodes
104
+ .map((n) => {
105
+ const x = n.x + ox;
106
+ const y = n.y + oy;
107
+ const isCritical = cpm.get(n.id)?.isCritical ?? false;
108
+ const durVal = n.data.duration;
109
+ 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
+ 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');
132
+ })
133
+ .join('\n');
134
+
135
+ return `${nodeSvg}\n${edgeSvg}`;
136
+ }
137
+
46
138
  export interface RenderActivitiesOptions {
47
139
  /** Optional heading rendered as a `text-header` above the diagram. */
48
140
  title?: string;
@@ -84,75 +176,23 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
84
176
  const ox = -layout.bounds.x + N_PAD;
85
177
  const oy = -layout.bounds.y + N_PAD + titleH;
86
178
 
87
- const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
88
-
89
- // SVG paints later siblings over earlier ones. Sort non-critical first so the
90
- // bright orange critical-path edges land on top — a gray edge crossing an
91
- // orange one shouldn't bury the critical signal.
92
- const orderedEdges = [...layout.edges].sort(
93
- (a, b) => Number(Boolean(a.isCritical)) - Number(Boolean(b.isCritical)),
94
- );
95
-
96
- // Edge path = a single cubic Bézier with horizontal control handles (shared
97
- // geometry in `edge-path.js`), so the curve meets the vertical node edge at a
98
- // right angle and the marker-end arrow reads as perpendicular.
99
- const edgeSvg = orderedEdges
100
- .map((e) => {
101
- const s = nodeMap.get(e.sourceId);
102
- const t = nodeMap.get(e.targetId);
103
- if (!s || !t) return '';
104
- const sx = s.x + ox + s.width;
105
- const sy = s.y + oy + s.height / 2;
106
- const tx = t.x + ox;
107
- const ty = t.y + oy + t.height / 2;
108
- const cls = e.isCritical ? 'diagram-edge critical-edge' : 'diagram-edge';
109
- const marker = `url(#${e.isCritical ? 'arrow-crit' : 'arrow'})`;
110
- return `<path d="${horizontalCubicEdgePath(sx, sy, tx, ty, curvature, entryCurvature)}" class="${cls}" marker-end="${marker}"/>`;
111
- })
112
- .join('\n');
113
-
114
- const nodeSvg = layout.nodes
115
- .map((n) => {
116
- const x = n.x + ox;
117
- const y = n.y + oy;
118
- const isCritical = cpm.get(n.id)?.isCritical ?? false;
119
- const isMilestone = (n.data.duration ?? -1) === 0;
120
- const cls = `diagram-node act-node ${isCritical ? 'critical-node' : ''} ${isMilestone ? 'milestone-node' : ''}`.trim();
121
- const idLabel = escXml(n.id);
122
- const nameLabel = escXml(truncate(n.data.name, 24));
123
- const durLabel = n.data.duration !== undefined ? `${n.data.duration}d` : '—';
124
- return [
125
- `<rect class="${cls}" x="${x}" y="${y}" width="${N_NODE_W}" height="${N_NODE_H}" rx="6"/>`,
126
- `<text class="text-id" x="${x + 8}" y="${y + 18}">${idLabel}</text>`,
127
- `<text class="text-primary" x="${x + N_NODE_W / 2}" y="${y + N_NODE_H / 2}" text-anchor="middle" dominant-baseline="central">${nameLabel}</text>`,
128
- `<text class="text-secondary" x="${x + N_NODE_W - 8}" y="${y + N_NODE_H - 10}" text-anchor="end">${escXml(durLabel)}</text>`,
129
- ].join('\n');
130
- })
131
- .join('\n');
179
+ const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
132
180
 
133
181
  const titleSvg = title
134
182
  ? `<text class="text-header" x="${N_PAD}" y="${N_PAD - 6}">${escXml(title)}</text>`
135
183
  : '';
136
184
 
137
- // Embed the shared theme CSS plus the activities-specific diagram CSS inside
138
- // the SVG so the rendered output is self-contained — the JCEF host page only
139
- // needs to drop the SVG into the DOM and styling resolves without any
140
- // cooperation from the host stylesheet. Matches what the VS Code path
141
- // produces via `prepareSvgForExport`.
142
- const embedCss = generateSvgEmbedCss('transitrix') + ACTIVITIES_DIAGRAM_CSS;
185
+ // Embed the shared theme CSS plus the network diagram CSS inside the SVG so
186
+ // the rendered output is self-contained — the JCEF host page only needs to
187
+ // drop the SVG into the DOM and styling resolves without any cooperation from
188
+ // the host stylesheet. Matches what the VS Code path produces via
189
+ // `prepareSvgForExport`.
190
+ const embedCss = generateSvgEmbedCss('transitrix') + ACTIVITIES_NETWORK_CSS;
143
191
 
144
192
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
145
193
  <style>${embedCss}</style>
146
- <defs>
147
- <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
148
- <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
149
- </marker>
150
- <marker id="arrow-crit" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
151
- <path d="M0,0 L0,6 L8,3 z" class="arrow-fill-critical"/>
152
- </marker>
153
- </defs>
194
+ ${ACTIVITIES_NETWORK_DEFS}
154
195
  ${titleSvg}
155
- ${nodeSvg}
156
- ${edgeSvg}
196
+ ${body}
157
197
  </svg>`;
158
198
  }
@@ -75,7 +75,16 @@ function resolveSingleDoc(doc: ActivityCardDoc): ResolvedActivityCard {
75
75
  };
76
76
  }
77
77
 
78
- function buildBody(layout: ActivityCardLayout, ox: number, oy: number): string {
78
+ /**
79
+ * The single Activity Card SVG body emitter shared by every host. Takes an
80
+ * already-laid-out {@link ActivityCardLayout} plus the canvas offset and emits
81
+ * the body elements (card, header, badges, dates, chain sections, milestones,
82
+ * child activities, footer) — everything except the host chrome (`<defs>`
83
+ * marker, title, embedded CSS, `<svg>` wrapper). The VS Code preview and the
84
+ * host-neutral {@link renderActivityCardSvg} both build their own chrome around
85
+ * this single source of truth.
86
+ */
87
+ export function renderActivityCardBody(layout: ActivityCardLayout, ox: number, oy: number): string {
79
88
  const parts: string[] = [];
80
89
 
81
90
  // Outer card.
@@ -245,7 +254,7 @@ export function renderActivityCardSvg(
245
254
  const ox = PAD;
246
255
  const oy = PAD + titleH;
247
256
 
248
- const body = buildBody(layout, ox, oy);
257
+ const body = renderActivityCardBody(layout, ox, oy);
249
258
 
250
259
  const titleSvg = title
251
260
  ? `<text class="text-header" x="${PAD}" y="${PAD + 14}">${escXml(`Activity Card — ${title}`)}</text>`
@@ -14,11 +14,16 @@
14
14
  */
15
15
  import { layoutNestedBlocks } from '../blocks/layout.js';
16
16
  import type { BlocksFile, BlocksLayout, LaidOutBlock } from '../blocks/types.js';
17
- import { generateSvgEmbedCss } from '../theme/index.js';
17
+ import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
18
18
  import { escXml } from './render-util.js';
19
19
 
20
20
  const PAD = 24;
21
21
 
22
+ // Approximate character width (px) at text-primary size (12px/600).
23
+ const CHAR_W = 7;
24
+ // Approximate character width (px) at text-id size (10px/600) — used for ID suffix.
25
+ const CHAR_W_ID = 6;
26
+
22
27
  export interface RenderBlocksOptions {
23
28
  title?: string;
24
29
  }
@@ -36,27 +41,89 @@ function levelClassForDepth(depth: number): string {
36
41
  return `level-${idx}`;
37
42
  }
38
43
 
39
- function truncate(text: string, maxChars: number): string {
44
+ /** Truncate at a word boundary so labels never break in the middle of a word. */
45
+ function wordTruncate(text: string, maxChars: number): string {
40
46
  if (text.length <= maxChars) return text;
41
- return text.slice(0, Math.max(0, maxChars - 1)) + '…';
47
+ const cut = text.slice(0, maxChars - 1);
48
+ const lastSpace = cut.lastIndexOf(' ');
49
+ return (lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut) + '…';
42
50
  }
43
51
 
44
52
  function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]): void {
45
53
  const cls = levelClassForDepth(b.depth);
54
+ const cx = b.x + ox + b.width / 2;
46
55
  parts.push(
47
56
  `<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`,
48
57
  );
49
58
 
50
- // Header label, centred horizontally within the block's header strip.
51
- const headerY = b.y + oy + b.headerHeight / 2;
52
- const maxChars = Math.max(4, Math.floor(b.width / 8));
53
- parts.push(
54
- `<text class="text-header" x="${b.x + ox + b.width / 2}" y="${headerY}" text-anchor="middle" dominant-baseline="central">${escXml(truncate(b.name, maxChars))}</text>`,
55
- );
59
+ const isLeaf = b.children.length === 0;
60
+ 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));
65
+ 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>`,
68
+ );
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
+ 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>`,
78
+ );
79
+ for (const c of b.children) emitBlockSvg(c, ox, oy, parts);
80
+ }
81
+ }
56
82
 
57
- for (const c of b.children) emitBlockSvg(c, ox, oy, parts);
83
+ export interface RenderBlocksLayoutOptions {
84
+ /** Extra vertical space reserved at the top of the canvas (e.g. for a title block). */
85
+ topInset?: number;
86
+ /** Raw SVG injected immediately after the opening tag — a header line or a full title block. */
87
+ title?: string;
88
+ /** When set, the theme CSS is embedded as `<style>` so the SVG is self-contained. */
89
+ embedCssTheme?: ThemeId;
58
90
  }
59
91
 
92
+ /**
93
+ * The single Nested Blocks SVG emitter shared by every host. Takes an
94
+ * already-computed {@link BlocksLayout} (callers decide the layout options) and
95
+ * produces the `<svg>`. Hosts wrap it with their own chrome:
96
+ * - IntelliJ/UI via {@link renderBlocksSvg} (embedded CSS + simple header);
97
+ * - VS Code's blocks preview (rich title block, no embedded CSS — the webview
98
+ * and the export path own styling).
99
+ */
100
+ export function renderBlocksLayoutSvg(
101
+ layout: BlocksLayout,
102
+ options: RenderBlocksLayoutOptions = {},
103
+ ): string {
104
+ const { topInset = 0, title = '', embedCssTheme } = options;
105
+
106
+ const w = layout.bounds.width + PAD * 2;
107
+ const h = layout.bounds.height + PAD * 2 + topInset;
108
+ const ox = -layout.bounds.x + PAD;
109
+ const oy = -layout.bounds.y + PAD + topInset;
110
+
111
+ const parts: string[] = [];
112
+ for (const top of layout.blocks) emitBlockSvg(top, ox, oy, parts);
113
+
114
+ const styleLine = embedCssTheme ? `\n<style>${generateSvgEmbedCss(embedCssTheme)}</style>` : '';
115
+
116
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${styleLine}
117
+ ${title}
118
+ ${parts.join('\n')}
119
+ </svg>`;
120
+ }
121
+
122
+ /**
123
+ * Host-neutral blocks renderer (IntelliJ/UI). Lays the doc out with the default
124
+ * spacing, then delegates the actual SVG emission to {@link renderBlocksLayoutSvg}
125
+ * with the shared theme CSS embedded so the output is self-contained.
126
+ */
60
127
  export function renderBlocksSvg(doc: BlocksFile, options: RenderBlocksOptions = {}): string {
61
128
  const { title = '' } = options;
62
129
 
@@ -66,26 +133,9 @@ export function renderBlocksSvg(doc: BlocksFile, options: RenderBlocksOptions =
66
133
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
67
134
  }
68
135
 
69
- const w = layout.bounds.width + PAD * 2;
70
- const h = layout.bounds.height + PAD * 2;
71
- const ox = -layout.bounds.x + PAD;
72
- const oy = -layout.bounds.y + PAD;
73
-
74
- const parts: string[] = [];
75
- for (const top of layout.blocks) emitBlockSvg(top, ox, oy, parts);
76
-
77
136
  const titleSvg = title
78
137
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Nested Blocks — ${title}`)}</text>`
79
138
  : '';
80
139
 
81
- // Embed the shared theme CSS inside the SVG so the rendered output is
82
- // self-contained — the JCEF host page only needs to drop the SVG into the
83
- // DOM and styling resolves without any cooperation from the host stylesheet.
84
- const embedCss = generateSvgEmbedCss('transitrix');
85
-
86
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
87
- <style>${embedCss}</style>
88
- ${titleSvg}
89
- ${parts.join('\n')}
90
- </svg>`;
140
+ return renderBlocksLayoutSvg(layout, { title: titleSvg, embedCssTheme: 'transitrix' });
91
141
  }
@@ -26,33 +26,30 @@ import { generateSvgEmbedCss } from '../theme/index.js';
26
26
  import { escXml } from './render-util.js';
27
27
 
28
28
  const COL_LABELS: Record<FGCAPreviewColumn, string> = {
29
- factor: 'Factors (F)',
29
+ driver: 'Drivers (D)',
30
30
  goal: 'Goals (G)',
31
31
  change: 'Changes (C)',
32
- activity: 'Activities (A)',
32
+ activity: 'Actions (A)',
33
33
  };
34
34
 
35
- export interface RenderFgcaOptions {
36
- /** `'fga'` hides the Changes column; defaults to `'fgca'`. */
37
- variant?: 'fgca' | 'fga';
38
- /** Optional heading rendered as a left-anchored `text-header` line. */
39
- title?: string;
40
- /** Exit edge curvature; 1 = default, 0 = straight, higher = stronger arc. */
41
- curvature?: number;
42
- /** Entry curvature at the target node; defaults to `curvature` when omitted. */
43
- entryCurvature?: number;
44
- }
45
-
46
- export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
47
- const { variant = 'fgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
48
- const hideChanges = variant === 'fga';
49
-
50
- const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, { hideChanges });
51
-
52
- if (nodes.length === 0) {
53
- return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
54
- }
35
+ type FgcaLayout = ReturnType<typeof layoutFGCAPreview>;
55
36
 
37
+ /**
38
+ * Canonical FGCA/FGA body — the column headers, node rects and edge paths that
39
+ * go inside the `<svg>`, shared verbatim by the VS Code preview (`buildSvg`)
40
+ * and the host-neutral wrapper below. Excludes the host-specific title block
41
+ * and the (identical) `<defs>` arrow marker. Coordinates are absolute (the
42
+ * layout's own); the VS Code path wraps this in a `translate(0, titleH)` group
43
+ * to make room for its title block, while the host-neutral wrapper renders it
44
+ * untranslated.
45
+ */
46
+ export function renderFgcaBody(
47
+ columns: FgcaLayout['columns'],
48
+ nodes: FgcaLayout['nodes'],
49
+ edges: FgcaLayout['edges'],
50
+ curvature: number,
51
+ entryCurvature: number | undefined,
52
+ ): string {
56
53
  const headerSvg = columns
57
54
  .map(({ col, x }) =>
58
55
  [
@@ -85,20 +82,49 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
85
82
  }
86
83
  }
87
84
  const twoLines = line2.length > 0;
88
- const y1 = twoLines ? n.y + NODE_H / 2 - 8 : n.y + NODE_H / 2 + 5;
89
- const y2 = y1 + 18;
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
+ const entityId = n.id.slice(n.id.indexOf('_') + 1);
90
89
  return [
91
90
  `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="8"/>`,
92
- `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle">${escXml(line1)}</text>`,
91
+ `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
93
92
  twoLines
94
- ? `<text class="text-secondary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle">${escXml(line2)}</text>`
93
+ ? `<text class="text-secondary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
95
94
  : '',
95
+ `<text class="text-id" x="${n.x + NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(entityId)}</text>`,
96
96
  ]
97
97
  .filter(Boolean)
98
98
  .join('\n');
99
99
  })
100
100
  .join('\n');
101
101
 
102
+ return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
103
+ }
104
+
105
+ export interface RenderFgcaOptions {
106
+ /** `'fga'` hides the Changes column; defaults to `'fgca'`. */
107
+ variant?: 'fgca' | 'fga';
108
+ /** Optional heading rendered as a left-anchored `text-header` line. */
109
+ title?: string;
110
+ /** Exit edge curvature; 1 = default, 0 = straight, higher = stronger arc. */
111
+ curvature?: number;
112
+ /** Entry curvature at the target node; defaults to `curvature` when omitted. */
113
+ entryCurvature?: number;
114
+ }
115
+
116
+ 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
+
120
+ const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, { hideChanges });
121
+
122
+ if (nodes.length === 0) {
123
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
124
+ }
125
+
126
+ const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature);
127
+
102
128
  const titleSvg = title
103
129
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
104
130
  : '';
@@ -116,8 +142,6 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
116
142
  </marker>
117
143
  </defs>
118
144
  ${titleSvg}
119
- ${headerSvg}
120
- ${nodeSvg}
121
- ${edgeSvg}
145
+ ${body}
122
146
  </svg>`;
123
147
  }
@@ -13,7 +13,8 @@
13
13
  import { layoutGoalTree } from '../goals/layout.js';
14
14
  import type { GoalTree, GoalTreeLayout, LaidOutEdge } from '../goals/types.js';
15
15
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
16
- import { generateSvgEmbedCss } from '../theme/index.js';
16
+ import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
17
+ import { escXml } from './render-util.js';
17
18
 
18
19
  const NODE_W = 250;
19
20
  const NODE_H = 60;
@@ -23,20 +24,17 @@ const PAD = 24;
23
24
  const LABEL_MAX = 36;
24
25
  const LABEL_TRIM = 34;
25
26
 
26
- function escXml(s: string): string {
27
- return s
28
- .replace(/&/g, '&amp;')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;');
32
- }
33
-
34
27
  export interface RenderGoalsOptions {
35
28
  treeName?: string;
36
29
  curvature?: number;
37
30
  entryCurvature?: number;
38
31
  }
39
32
 
33
+ /**
34
+ * Host-neutral goals renderer (IntelliJ/UI). Lays the tree out with the default
35
+ * spacing, then delegates the actual SVG emission to {@link renderGoalsLayoutSvg}
36
+ * with the shared theme CSS embedded so the output is self-contained.
37
+ */
40
38
  export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {}): string {
41
39
  const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
42
40
 
@@ -51,10 +49,43 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
51
49
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
52
50
  }
53
51
 
52
+ const title = treeName
53
+ ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
54
+ : '';
55
+
56
+ // Embed the shared theme CSS so the JCEF host page only needs to drop the SVG
57
+ // into the DOM — styling resolves without help from the host stylesheet. The
58
+ // VS Code preview omits this (its webview supplies the CSS) and embeds it only
59
+ // on export via `prepareSvgForExport`.
60
+ return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, title, embedCssTheme: 'transitrix' });
61
+ }
62
+
63
+ export interface RenderGoalsLayoutOptions {
64
+ curvature?: number;
65
+ entryCurvature?: number;
66
+ /** Extra vertical space reserved at the top of the canvas (e.g. for a title block). */
67
+ topInset?: number;
68
+ /** Raw SVG injected immediately after `<defs>` — a header line or a full title block. */
69
+ title?: string;
70
+ /** When set, the theme CSS is embedded as `<style>` so the SVG is self-contained. */
71
+ embedCssTheme?: ThemeId;
72
+ }
73
+
74
+ /**
75
+ * The single goals SVG emitter shared by every host. Takes an already-computed
76
+ * {@link GoalTreeLayout} (callers decide spacing/scope) and produces the `<svg>`.
77
+ * Hosts wrap it with their own chrome:
78
+ * - IntelliJ/UI via {@link renderGoalsSvg} (embedded CSS + simple header);
79
+ * - VS Code's goals preview (rich title block, no embedded CSS — the webview
80
+ * and the export path own styling).
81
+ */
82
+ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoalsLayoutOptions = {}): string {
83
+ const { curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, topInset = 0, title = '', embedCssTheme } = options;
84
+
54
85
  const w = layout.bounds.width + PAD * 2;
55
- const h = layout.bounds.height + PAD * 2;
86
+ const h = layout.bounds.height + PAD * 2 + topInset;
56
87
  const ox = -layout.bounds.x + PAD;
57
- const oy = -layout.bounds.y + PAD;
88
+ const oy = -layout.bounds.y + PAD + topInset;
58
89
 
59
90
  const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
60
91
 
@@ -87,24 +118,15 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
87
118
  })
88
119
  .join('\n');
89
120
 
90
- const titleSvg = treeName
91
- ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
92
- : '';
93
-
94
- // Embed the shared theme CSS inside the SVG so the rendered output is
95
- // self-contained — the JCEF host page only needs to drop the SVG into the
96
- // DOM and styling resolves without any cooperation from the host stylesheet.
97
- // Matches what the VS Code path produces via `prepareSvgForExport`.
98
- const embedCss = generateSvgEmbedCss('transitrix');
121
+ const styleLine = embedCssTheme ? `\n<style>${generateSvgEmbedCss(embedCssTheme)}</style>` : '';
99
122
 
100
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
101
- <style>${embedCss}</style>
123
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${styleLine}
102
124
  <defs>
103
125
  <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
104
126
  <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
105
127
  </marker>
106
128
  </defs>
107
- ${titleSvg}
129
+ ${title}
108
130
  ${nodeSvg}
109
131
  ${edgeSvg}
110
132
  </svg>`;