@transitrix/diagrams 1.0.1 → 1.1.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 (115) hide show
  1. package/dist/activities/cpm.js +11 -2
  2. package/dist/activities/gantt.js +6 -3
  3. package/dist/activities/types.d.ts +1 -0
  4. package/dist/activities/validate.js +3 -3
  5. package/dist/activity-card/__tests__/resolver.test.js +22 -1
  6. package/dist/activity-card/resolver.js +11 -5
  7. package/dist/compliance/__tests__/impact.test.js +198 -0
  8. package/dist/compliance/html.js +1 -7
  9. package/dist/compliance/impact.d.ts +24 -1
  10. package/dist/compliance/impact.js +97 -21
  11. package/dist/factor/__tests__/validate.test.js +3 -3
  12. package/dist/factor/types.d.ts +1 -1
  13. package/dist/factor/validate.js +2 -2
  14. package/dist/fgca/__tests__/chain-table.test.js +2 -2
  15. package/dist/fgca/__tests__/layout.test.js +11 -11
  16. package/dist/fgca/__tests__/preview-layout.test.js +7 -7
  17. package/dist/fgca/__tests__/resolver.test.js +2 -2
  18. package/dist/fgca/chain-table.d.ts +1 -1
  19. package/dist/fgca/chain-table.js +4 -4
  20. package/dist/fgca/index.d.ts +2 -2
  21. package/dist/fgca/index.js +1 -1
  22. package/dist/fgca/layout.d.ts +2 -2
  23. package/dist/fgca/layout.js +9 -9
  24. package/dist/fgca/nodeTypes.d.ts +1 -1
  25. package/dist/fgca/nodeTypes.js +2 -2
  26. package/dist/fgca/nodes/FGCAFactorNode.d.ts +3 -3
  27. package/dist/fgca/nodes/FGCAFactorNode.js +5 -5
  28. package/dist/fgca/parse-canonical.js +15 -10
  29. package/dist/fgca/preview-layout.d.ts +1 -1
  30. package/dist/fgca/preview-layout.js +4 -4
  31. package/dist/fgca/resolver.js +3 -1
  32. package/dist/fgca/types.d.ts +11 -11
  33. package/dist/fgca/types.js +2 -2
  34. package/dist/fgca/validate.d.ts +2 -2
  35. package/dist/fgca/validate.js +17 -14
  36. package/dist/process-blueprint/index.d.ts +1 -1
  37. package/dist/process-blueprint/layout.js +64 -37
  38. package/dist/process-blueprint/types.d.ts +14 -0
  39. package/dist/scenarios/index.d.ts +1 -1
  40. package/dist/scenarios/types.d.ts +4 -4
  41. package/dist/theme/themes.js +2 -0
  42. package/dist/theme/tokens.d.ts +7 -1
  43. package/dist/theme/tokens.js +3 -1
  44. package/dist/webview/__tests__/entry-notations.test.js +2 -0
  45. package/dist/webview/__tests__/render-process.test.d.ts +1 -0
  46. package/dist/webview/__tests__/render-process.test.js +463 -0
  47. package/dist/webview/__tests__/render-util.test.d.ts +1 -0
  48. package/dist/webview/__tests__/render-util.test.js +54 -0
  49. package/dist/webview/entry.d.ts +1 -1
  50. package/dist/webview/entry.js +9 -5
  51. package/dist/webview/render-activities.d.ts +21 -1
  52. package/dist/webview/render-activities.js +91 -56
  53. package/dist/webview/render-activity-card.d.ts +11 -1
  54. package/dist/webview/render-activity-card.js +11 -2
  55. package/dist/webview/render-blocks.d.ts +24 -1
  56. package/dist/webview/render-blocks.js +58 -24
  57. package/dist/webview/render-fgca.d.ts +27 -0
  58. package/dist/webview/render-fgca.js +29 -15
  59. package/dist/webview/render-goals.d.ts +26 -1
  60. package/dist/webview/render-goals.js +30 -20
  61. package/dist/webview/render-process-blueprint.d.ts +11 -1
  62. package/dist/webview/render-process-blueprint.js +118 -53
  63. package/dist/webview/render-process.d.ts +95 -0
  64. package/dist/webview/render-process.js +392 -0
  65. package/dist/webview/render-scenarios.js +2 -2
  66. package/dist/webview/render-util.d.ts +19 -6
  67. package/dist/webview/render-util.js +23 -15
  68. package/package.json +15 -1
  69. package/src/activities/cpm.ts +14 -2
  70. package/src/activities/gantt.ts +6 -5
  71. package/src/activities/types.ts +1 -0
  72. package/src/activities/validate.ts +3 -3
  73. package/src/activity-card/__tests__/resolver.test.ts +24 -1
  74. package/src/activity-card/resolver.ts +12 -5
  75. package/src/compliance/__tests__/impact.test.ts +219 -0
  76. package/src/compliance/html.ts +1 -8
  77. package/src/compliance/impact.ts +141 -23
  78. package/src/factor/__tests__/validate.test.ts +3 -3
  79. package/src/factor/types.ts +1 -1
  80. package/src/factor/validate.ts +2 -2
  81. package/src/fgca/__tests__/chain-table.test.ts +2 -2
  82. package/src/fgca/__tests__/layout.test.ts +13 -13
  83. package/src/fgca/__tests__/preview-layout.test.ts +7 -7
  84. package/src/fgca/__tests__/resolver.test.ts +2 -2
  85. package/src/fgca/chain-table.ts +5 -5
  86. package/src/fgca/index.ts +3 -3
  87. package/src/fgca/layout.ts +11 -11
  88. package/src/fgca/nodeTypes.ts +3 -3
  89. package/src/fgca/nodes/FGCAFactorNode.tsx +6 -6
  90. package/src/fgca/parse-canonical.ts +17 -12
  91. package/src/fgca/preview-layout.ts +5 -5
  92. package/src/fgca/resolver.ts +3 -1
  93. package/src/fgca/types.ts +13 -13
  94. package/src/fgca/validate.ts +17 -14
  95. package/src/process-blueprint/index.ts +1 -0
  96. package/src/process-blueprint/layout.ts +71 -42
  97. package/src/process-blueprint/types.ts +15 -0
  98. package/src/scenarios/index.ts +2 -2
  99. package/src/scenarios/types.ts +4 -4
  100. package/src/scenarios/validate.ts +3 -3
  101. package/src/theme/themes.ts +2 -0
  102. package/src/theme/tokens.ts +3 -1
  103. package/src/webview/__tests__/entry-notations.test.ts +2 -0
  104. package/src/webview/__tests__/render-process.test.ts +538 -0
  105. package/src/webview/__tests__/render-util.test.ts +66 -0
  106. package/src/webview/entry.ts +11 -5
  107. package/src/webview/render-activities.ts +107 -67
  108. package/src/webview/render-activity-card.ts +11 -2
  109. package/src/webview/render-blocks.ts +78 -28
  110. package/src/webview/render-fgca.ts +52 -28
  111. package/src/webview/render-goals.ts +45 -23
  112. package/src/webview/render-process-blueprint.ts +156 -82
  113. package/src/webview/render-process.ts +507 -0
  114. package/src/webview/render-scenarios.ts +3 -3
  115. 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';
@@ -20,54 +24,46 @@ import { escXml } from './render-util.js';
20
24
  const N_NODE_W = 200;
21
25
  const N_NODE_H = 80;
22
26
  const N_PAD = 24;
23
- // Activities-specific diagram CSS. Mirrors `ACTIVITIES_DIAGRAM_CSS` from the
24
- // VS Code preview — the rules that shape the network diagram itself (critical
25
- // path, milestones, edge colours). Embedded alongside the shared theme CSS so
26
- // the SVG is self-contained for the JCEF host.
27
- const ACTIVITIES_DIAGRAM_CSS = `
28
- .act-node { fill: var(--ts-bg-surface, #f8fafc); stroke: var(--ts-border, #94a3b8); stroke-width: 1.5; }
27
+ /**
28
+ * Network-view diagram CSS (critical path, milestones, edge colours) — the
29
+ * canonical rules shared with the VS Code preview's `ACTIVITIES_DIAGRAM_CSS`
30
+ * network subset. Embedded alongside the shared theme CSS so the SVG is
31
+ * self-contained for the JCEF host.
32
+ */
33
+ export const ACTIVITIES_NETWORK_CSS = `
34
+ .act-node { fill: var(--ts-layer-activity, #d4edda); stroke: var(--ts-node-stroke, #94a3b8); stroke-width: 1; }
29
35
  .critical-node { fill: #fff7ed; stroke: var(--ts-brand-orange, #ff4d00); stroke-width: 2.5; }
30
36
  .milestone-node { fill: #ecfeff; stroke: var(--ts-text-muted, #64748b); stroke-dasharray: 4 2; }
31
37
  .critical-edge { stroke: var(--ts-brand-orange, #ff4d00); stroke-width: 2; }
32
38
  .arrow-fill-critical { fill: var(--ts-brand-orange, #ff4d00); }
33
39
  `;
40
+ /** Arrowhead marker defs shared by both hosts for the network view. */
41
+ export const ACTIVITIES_NETWORK_DEFS = `<defs>
42
+ <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
43
+ <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
44
+ </marker>
45
+ <marker id="arrow-crit" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
46
+ <path d="M0,0 L0,6 L8,3 z" class="arrow-fill-critical"/>
47
+ </marker>
48
+ </defs>`;
34
49
  function truncate(s, maxLen) {
35
50
  return s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
36
51
  }
37
52
  /**
38
- * Render the network (PSND) view of an already-validated ActivityDoc to a
39
- * self-contained SVG string.
40
- *
41
- * The caller passes a doc cast from the yaml-parsed `unknown` after
42
- * `validateActivities` returns valid (the activities module has no parsed
43
- * field, so the dispatcher does `doc as ActivityDoc`).
53
+ * Canonical Network (PSND) body — the node rects and edge paths that go inside
54
+ * the `<svg>`, shared verbatim by the VS Code preview and the host-neutral
55
+ * wrapper below. Excludes the host-specific title block and the (identical)
56
+ * marker defs (`ACTIVITIES_NETWORK_DEFS`).
44
57
  *
45
- * Cyclic graphs degrade gracefully: `layoutActivities` / `computeCpm` defend
46
- * against cycles internally (Kahn's topo-order omits cyclic nodes from the
47
- * critical-path computation, which `computeCpm` backfills with neutral CPM
48
- * values), so this renderer simply renders whatever the layout returns rather
49
- * than short-circuiting.
58
+ * `ox`/`oy` are the canvas offsets (the caller folds in padding + any title
59
+ * height); `curvature`/`entryCurvature` scale the exit/entry edge handles.
50
60
  */
51
- export function renderActivitiesSvg(doc, options = {}) {
52
- const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
53
- const layout = layoutActivities(doc, gaps);
54
- if (layout.nodes.length === 0) {
55
- return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
56
- }
57
- const cpm = computeCpm(doc.activities ?? []);
58
- const titleH = title ? 24 : 0;
59
- const w = layout.bounds.width + N_PAD * 2;
60
- const h = layout.bounds.height + N_PAD * 2 + titleH;
61
- const ox = -layout.bounds.x + N_PAD;
62
- const oy = -layout.bounds.y + N_PAD + titleH;
61
+ export function renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature) {
63
62
  const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
64
63
  // SVG paints later siblings over earlier ones. Sort non-critical first so the
65
64
  // bright orange critical-path edges land on top — a gray edge crossing an
66
65
  // orange one shouldn't bury the critical signal.
67
66
  const orderedEdges = [...layout.edges].sort((a, b) => Number(Boolean(a.isCritical)) - Number(Boolean(b.isCritical)));
68
- // Edge path = a single cubic Bézier with horizontal control handles (shared
69
- // geometry in `edge-path.js`), so the curve meets the vertical node edge at a
70
- // right angle and the marker-end arrow reads as perpendicular.
71
67
  const edgeSvg = orderedEdges
72
68
  .map((e) => {
73
69
  const s = nodeMap.get(e.sourceId);
@@ -88,40 +84,79 @@ export function renderActivitiesSvg(doc, options = {}) {
88
84
  const x = n.x + ox;
89
85
  const y = n.y + oy;
90
86
  const isCritical = cpm.get(n.id)?.isCritical ?? false;
91
- const isMilestone = (n.data.duration ?? -1) === 0;
87
+ const durVal = n.data.duration;
88
+ const isMilestone = (durVal ?? -1) === 0;
92
89
  const cls = `diagram-node act-node ${isCritical ? 'critical-node' : ''} ${isMilestone ? 'milestone-node' : ''}`.trim();
93
90
  const idLabel = escXml(n.id);
94
- const nameLabel = escXml(truncate(n.data.name, 24));
95
- const durLabel = n.data.duration !== undefined ? `${n.data.duration}d` : '—';
91
+ const words = n.data.name.split(' ');
92
+ let line1 = '';
93
+ let line2 = '';
94
+ for (const w of words) {
95
+ if ((line1 + ' ' + w).trim().length <= 24)
96
+ line1 = (line1 + ' ' + w).trim();
97
+ else if ((line2 + ' ' + w).trim().length <= 24)
98
+ line2 = (line2 + ' ' + w).trim();
99
+ else if (!line2) {
100
+ line2 = w.slice(0, 22) + '…';
101
+ break;
102
+ }
103
+ }
104
+ const twoLines = line2.length > 0;
105
+ const nameY1 = twoLines ? y + 18 : y + 28;
106
+ const nameY2 = y + 34;
107
+ const idY = twoLines ? y + 54 : y + 50;
108
+ const durLabel = (durVal !== undefined && durVal > 0) ? `${durVal}d` : '';
96
109
  return [
97
- `<rect class="${cls}" x="${x}" y="${y}" width="${N_NODE_W}" height="${N_NODE_H}" rx="6"/>`,
98
- `<text class="text-id" x="${x + 8}" y="${y + 18}">${idLabel}</text>`,
99
- `<text class="text-primary" x="${x + N_NODE_W / 2}" y="${y + N_NODE_H / 2}" text-anchor="middle" dominant-baseline="central">${nameLabel}</text>`,
100
- `<text class="text-secondary" x="${x + N_NODE_W - 8}" y="${y + N_NODE_H - 10}" text-anchor="end">${escXml(durLabel)}</text>`,
101
- ].join('\n');
110
+ `<rect class="${cls}" x="${x}" y="${y}" width="${N_NODE_W}" height="${N_NODE_H}" rx="8"/>`,
111
+ `<text class="text-primary" x="${x + N_NODE_W / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
112
+ twoLines ? `<text class="text-primary" x="${x + N_NODE_W / 2}" y="${nameY2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>` : '',
113
+ `<text class="text-id" x="${x + N_NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${idLabel}</text>`,
114
+ durLabel ? `<text class="text-secondary" x="${x + N_NODE_W - 8}" y="${y + N_NODE_H - 8}" text-anchor="end">${durLabel}</text>` : '',
115
+ ].filter(Boolean).join('\n');
102
116
  })
103
117
  .join('\n');
118
+ return `${nodeSvg}\n${edgeSvg}`;
119
+ }
120
+ /**
121
+ * Render the network (PSND) view of an already-validated ActivityDoc to a
122
+ * self-contained SVG string.
123
+ *
124
+ * The caller passes a doc cast from the yaml-parsed `unknown` after
125
+ * `validateActivities` returns valid (the activities module has no parsed
126
+ * field, so the dispatcher does `doc as ActivityDoc`).
127
+ *
128
+ * Cyclic graphs degrade gracefully: `layoutActivities` / `computeCpm` defend
129
+ * against cycles internally (Kahn's topo-order omits cyclic nodes from the
130
+ * critical-path computation, which `computeCpm` backfills with neutral CPM
131
+ * values), so this renderer simply renders whatever the layout returns rather
132
+ * than short-circuiting.
133
+ */
134
+ export function renderActivitiesSvg(doc, options = {}) {
135
+ const { title = '', gaps = {}, curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
136
+ const layout = layoutActivities(doc, gaps);
137
+ if (layout.nodes.length === 0) {
138
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
139
+ }
140
+ const cpm = computeCpm(doc.activities ?? []);
141
+ const titleH = title ? 24 : 0;
142
+ const w = layout.bounds.width + N_PAD * 2;
143
+ const h = layout.bounds.height + N_PAD * 2 + titleH;
144
+ const ox = -layout.bounds.x + N_PAD;
145
+ const oy = -layout.bounds.y + N_PAD + titleH;
146
+ const body = renderActivitiesNetworkBody(layout, cpm, ox, oy, curvature, entryCurvature);
104
147
  const titleSvg = title
105
148
  ? `<text class="text-header" x="${N_PAD}" y="${N_PAD - 6}">${escXml(title)}</text>`
106
149
  : '';
107
- // Embed the shared theme CSS plus the activities-specific diagram CSS inside
108
- // the SVG so the rendered output is self-contained — the JCEF host page only
109
- // needs to drop the SVG into the DOM and styling resolves without any
110
- // cooperation from the host stylesheet. Matches what the VS Code path
111
- // produces via `prepareSvgForExport`.
112
- const embedCss = generateSvgEmbedCss('transitrix') + ACTIVITIES_DIAGRAM_CSS;
150
+ // Embed the shared theme CSS plus the network diagram CSS inside the SVG so
151
+ // the rendered output is self-contained — the JCEF host page only needs to
152
+ // drop the SVG into the DOM and styling resolves without any cooperation from
153
+ // the host stylesheet. Matches what the VS Code path produces via
154
+ // `prepareSvgForExport`.
155
+ const embedCss = generateSvgEmbedCss('transitrix') + ACTIVITIES_NETWORK_CSS;
113
156
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
114
157
  <style>${embedCss}</style>
115
- <defs>
116
- <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
117
- <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
118
- </marker>
119
- <marker id="arrow-crit" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
120
- <path d="M0,0 L0,6 L8,3 z" class="arrow-fill-critical"/>
121
- </marker>
122
- </defs>
158
+ ${ACTIVITIES_NETWORK_DEFS}
123
159
  ${titleSvg}
124
- ${nodeSvg}
125
- ${edgeSvg}
160
+ ${body}
126
161
  </svg>`;
127
162
  }
@@ -1,5 +1,15 @@
1
- import type { ActivityCardDoc } from '../activity-card/types.js';
1
+ import type { ActivityCardDoc, ActivityCardLayout } from '../activity-card/types.js';
2
2
  export interface RenderActivityCardOptions {
3
3
  title?: string;
4
4
  }
5
+ /**
6
+ * The single Activity Card SVG body emitter shared by every host. Takes an
7
+ * already-laid-out {@link ActivityCardLayout} plus the canvas offset and emits
8
+ * the body elements (card, header, badges, dates, chain sections, milestones,
9
+ * child activities, footer) — everything except the host chrome (`<defs>`
10
+ * marker, title, embedded CSS, `<svg>` wrapper). The VS Code preview and the
11
+ * host-neutral {@link renderActivityCardSvg} both build their own chrome around
12
+ * this single source of truth.
13
+ */
14
+ export declare function renderActivityCardBody(layout: ActivityCardLayout, ox: number, oy: number): string;
5
15
  export declare function renderActivityCardSvg(doc: ActivityCardDoc, options?: RenderActivityCardOptions): string;
@@ -60,7 +60,16 @@ function resolveSingleDoc(doc) {
60
60
  notes: card?.notes,
61
61
  };
62
62
  }
63
- function buildBody(layout, ox, oy) {
63
+ /**
64
+ * The single Activity Card SVG body emitter shared by every host. Takes an
65
+ * already-laid-out {@link ActivityCardLayout} plus the canvas offset and emits
66
+ * the body elements (card, header, badges, dates, chain sections, milestones,
67
+ * child activities, footer) — everything except the host chrome (`<defs>`
68
+ * marker, title, embedded CSS, `<svg>` wrapper). The VS Code preview and the
69
+ * host-neutral {@link renderActivityCardSvg} both build their own chrome around
70
+ * this single source of truth.
71
+ */
72
+ export function renderActivityCardBody(layout, ox, oy) {
64
73
  const parts = [];
65
74
  // Outer card.
66
75
  parts.push(`<rect class="diagram-node level-0" x="${ox}" y="${oy}" width="${layout.bounds.width}" height="${layout.bounds.height}" rx="8"/>`);
@@ -177,7 +186,7 @@ export function renderActivityCardSvg(doc, options = {}) {
177
186
  const h = layout.bounds.height + PAD * 2 + titleH;
178
187
  const ox = PAD;
179
188
  const oy = PAD + titleH;
180
- const body = buildBody(layout, ox, oy);
189
+ const body = renderActivityCardBody(layout, ox, oy);
181
190
  const titleSvg = title
182
191
  ? `<text class="text-header" x="${PAD}" y="${PAD + 14}">${escXml(`Activity Card — ${title}`)}</text>`
183
192
  : '';
@@ -1,5 +1,28 @@
1
- import type { BlocksFile } from '../blocks/types.js';
1
+ import type { BlocksFile, BlocksLayout } from '../blocks/types.js';
2
+ import { type ThemeId } from '../theme/index.js';
2
3
  export interface RenderBlocksOptions {
3
4
  title?: string;
4
5
  }
6
+ export interface RenderBlocksLayoutOptions {
7
+ /** Extra vertical space reserved at the top of the canvas (e.g. for a title block). */
8
+ topInset?: number;
9
+ /** Raw SVG injected immediately after the opening tag — a header line or a full title block. */
10
+ title?: string;
11
+ /** When set, the theme CSS is embedded as `<style>` so the SVG is self-contained. */
12
+ embedCssTheme?: ThemeId;
13
+ }
14
+ /**
15
+ * The single Nested Blocks SVG emitter shared by every host. Takes an
16
+ * already-computed {@link BlocksLayout} (callers decide the layout options) and
17
+ * produces the `<svg>`. Hosts wrap it with their own chrome:
18
+ * - IntelliJ/UI via {@link renderBlocksSvg} (embedded CSS + simple header);
19
+ * - VS Code's blocks preview (rich title block, no embedded CSS — the webview
20
+ * and the export path own styling).
21
+ */
22
+ export declare function renderBlocksLayoutSvg(layout: BlocksLayout, options?: RenderBlocksLayoutOptions): string;
23
+ /**
24
+ * Host-neutral blocks renderer (IntelliJ/UI). Lays the doc out with the default
25
+ * spacing, then delegates the actual SVG emission to {@link renderBlocksLayoutSvg}
26
+ * with the shared theme CSS embedded so the output is self-contained.
27
+ */
5
28
  export declare function renderBlocksSvg(doc: BlocksFile, options?: RenderBlocksOptions): string;
@@ -16,6 +16,10 @@ import { layoutNestedBlocks } from '../blocks/layout.js';
16
16
  import { generateSvgEmbedCss } from '../theme/index.js';
17
17
  import { escXml } from './render-util.js';
18
18
  const PAD = 24;
19
+ // Approximate character width (px) at text-primary size (12px/600).
20
+ const CHAR_W = 7;
21
+ // Approximate character width (px) at text-id size (10px/600) — used for ID suffix.
22
+ const CHAR_W_ID = 6;
19
23
  /**
20
24
  * Pick the diagram-frame level class for a block at the given depth.
21
25
  *
@@ -28,44 +32,74 @@ function levelClassForDepth(depth) {
28
32
  const idx = Math.min(Math.max(depth - 1, 0), 6);
29
33
  return `level-${idx}`;
30
34
  }
31
- function truncate(text, maxChars) {
35
+ /** Truncate at a word boundary so labels never break in the middle of a word. */
36
+ function wordTruncate(text, maxChars) {
32
37
  if (text.length <= maxChars)
33
38
  return text;
34
- return text.slice(0, Math.max(0, maxChars - 1)) + '…';
39
+ const cut = text.slice(0, maxChars - 1);
40
+ const lastSpace = cut.lastIndexOf(' ');
41
+ return (lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut) + '…';
35
42
  }
36
43
  function emitBlockSvg(b, ox, oy, parts) {
37
44
  const cls = levelClassForDepth(b.depth);
45
+ const cx = b.x + ox + b.width / 2;
38
46
  parts.push(`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`);
39
- // Header label, centred horizontally within the block's header strip.
40
- const headerY = b.y + oy + b.headerHeight / 2;
41
- const maxChars = Math.max(4, Math.floor(b.width / 8));
42
- parts.push(`<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>`);
43
- for (const c of b.children)
44
- emitBlockSvg(c, ox, oy, parts);
47
+ const isLeaf = b.children.length === 0;
48
+ if (isLeaf) {
49
+ // Leaf block: name on upper line, ID on lower line — both centred.
50
+ const nameY = b.y + oy + Math.round(b.height * 0.38);
51
+ const idY = b.y + oy + Math.round(b.height * 0.66);
52
+ const maxChars = Math.max(4, Math.floor(b.width / CHAR_W));
53
+ parts.push(`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(wordTruncate(b.name, maxChars))}</text>`, `<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(b.id)}</text>`);
54
+ }
55
+ else {
56
+ // Container block: name + (ID) on the single header line; ID in grey.
57
+ const headerY = b.y + oy + b.headerHeight / 2;
58
+ const idSuffix = ` (${b.id})`;
59
+ const idSuffixW = idSuffix.length * CHAR_W_ID;
60
+ const nameMaxChars = Math.max(4, Math.floor((b.width - idSuffixW) / CHAR_W));
61
+ const nameText = wordTruncate(b.name, nameMaxChars);
62
+ parts.push(`<text class="text-primary" x="${cx}" y="${headerY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}<tspan fill="var(--ts-text-secondary, #64748b)" font-size="10" font-weight="600">${escXml(idSuffix)}</tspan></text>`);
63
+ for (const c of b.children)
64
+ emitBlockSvg(c, ox, oy, parts);
65
+ }
45
66
  }
67
+ /**
68
+ * The single Nested Blocks SVG emitter shared by every host. Takes an
69
+ * already-computed {@link BlocksLayout} (callers decide the layout options) and
70
+ * produces the `<svg>`. Hosts wrap it with their own chrome:
71
+ * - IntelliJ/UI via {@link renderBlocksSvg} (embedded CSS + simple header);
72
+ * - VS Code's blocks preview (rich title block, no embedded CSS — the webview
73
+ * and the export path own styling).
74
+ */
75
+ export function renderBlocksLayoutSvg(layout, options = {}) {
76
+ const { topInset = 0, title = '', embedCssTheme } = options;
77
+ const w = layout.bounds.width + PAD * 2;
78
+ const h = layout.bounds.height + PAD * 2 + topInset;
79
+ const ox = -layout.bounds.x + PAD;
80
+ const oy = -layout.bounds.y + PAD + topInset;
81
+ const parts = [];
82
+ for (const top of layout.blocks)
83
+ emitBlockSvg(top, ox, oy, parts);
84
+ const styleLine = embedCssTheme ? `\n<style>${generateSvgEmbedCss(embedCssTheme)}</style>` : '';
85
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${styleLine}
86
+ ${title}
87
+ ${parts.join('\n')}
88
+ </svg>`;
89
+ }
90
+ /**
91
+ * Host-neutral blocks renderer (IntelliJ/UI). Lays the doc out with the default
92
+ * spacing, then delegates the actual SVG emission to {@link renderBlocksLayoutSvg}
93
+ * with the shared theme CSS embedded so the output is self-contained.
94
+ */
46
95
  export function renderBlocksSvg(doc, options = {}) {
47
96
  const { title = '' } = options;
48
97
  const layout = layoutNestedBlocks(doc);
49
98
  if (layout.blocks.length === 0) {
50
99
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
51
100
  }
52
- const w = layout.bounds.width + PAD * 2;
53
- const h = layout.bounds.height + PAD * 2;
54
- const ox = -layout.bounds.x + PAD;
55
- const oy = -layout.bounds.y + PAD;
56
- const parts = [];
57
- for (const top of layout.blocks)
58
- emitBlockSvg(top, ox, oy, parts);
59
101
  const titleSvg = title
60
102
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Nested Blocks — ${title}`)}</text>`
61
103
  : '';
62
- // Embed the shared theme CSS inside the SVG so the rendered output is
63
- // self-contained — the JCEF host page only needs to drop the SVG into the
64
- // DOM and styling resolves without any cooperation from the host stylesheet.
65
- const embedCss = generateSvgEmbedCss('transitrix');
66
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
67
- <style>${embedCss}</style>
68
- ${titleSvg}
69
- ${parts.join('\n')}
70
- </svg>`;
104
+ return renderBlocksLayoutSvg(layout, { title: titleSvg, embedCssTheme: 'transitrix' });
71
105
  }
@@ -1,4 +1,30 @@
1
+ /**
2
+ * Browser-safe SVG renderer for the canonical FGCA and FGA notations.
3
+ *
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
7
+ * pulls in VS Code-specific concerns (themes, title block, save dialogs,
8
+ * spacing/scope controls); this module is the host-neutral subset — pure
9
+ * column layout → SVG with no VS Code APIs, no `node:fs`, no `node:path`.
10
+ *
11
+ * FGCA and FGA share the same renderer; FGA only differs by hiding the
12
+ * `change` column (`hideChanges` / the layout's Goal → Activity collapse),
13
+ * exactly as the extension preview does.
14
+ */
15
+ import { layoutFGCAPreview } from '../fgca/preview-layout.js';
1
16
  import type { FGCADoc } from '../fgca/validate.js';
17
+ type FgcaLayout = ReturnType<typeof layoutFGCAPreview>;
18
+ /**
19
+ * Canonical FGCA/FGA body — the column headers, node rects and edge paths that
20
+ * go inside the `<svg>`, shared verbatim by the VS Code preview (`buildSvg`)
21
+ * and the host-neutral wrapper below. Excludes the host-specific title block
22
+ * and the (identical) `<defs>` arrow marker. Coordinates are absolute (the
23
+ * layout's own); the VS Code path wraps this in a `translate(0, titleH)` group
24
+ * to make room for its title block, while the host-neutral wrapper renders it
25
+ * untranslated.
26
+ */
27
+ export declare function renderFgcaBody(columns: FgcaLayout['columns'], nodes: FgcaLayout['nodes'], edges: FgcaLayout['edges'], curvature: number, entryCurvature: number | undefined): string;
2
28
  export interface RenderFgcaOptions {
3
29
  /** `'fga'` hides the Changes column; defaults to `'fgca'`. */
4
30
  variant?: 'fgca' | 'fga';
@@ -10,3 +36,4 @@ export interface RenderFgcaOptions {
10
36
  entryCurvature?: number;
11
37
  }
12
38
  export declare function renderFgcaSvg(doc: FGCADoc, options?: RenderFgcaOptions): string;
39
+ export {};
@@ -17,18 +17,21 @@ import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js
17
17
  import { generateSvgEmbedCss } from '../theme/index.js';
18
18
  import { escXml } from './render-util.js';
19
19
  const COL_LABELS = {
20
- factor: 'Factors (F)',
20
+ driver: 'Drivers (D)',
21
21
  goal: 'Goals (G)',
22
22
  change: 'Changes (C)',
23
23
  activity: 'Activities (A)',
24
24
  };
25
- export function renderFgcaSvg(doc, options = {}) {
26
- const { variant = 'fgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
27
- const hideChanges = variant === 'fga';
28
- const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, { hideChanges });
29
- if (nodes.length === 0) {
30
- return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
31
- }
25
+ /**
26
+ * Canonical FGCA/FGA body — the column headers, node rects and edge paths that
27
+ * go inside the `<svg>`, shared verbatim by the VS Code preview (`buildSvg`)
28
+ * and the host-neutral wrapper below. Excludes the host-specific title block
29
+ * and the (identical) `<defs>` arrow marker. Coordinates are absolute (the
30
+ * layout's own); the VS Code path wraps this in a `translate(0, titleH)` group
31
+ * to make room for its title block, while the host-neutral wrapper renders it
32
+ * untranslated.
33
+ */
34
+ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature) {
32
35
  const headerSvg = columns
33
36
  .map(({ col, x }) => [
34
37
  `<rect class="diagram-node layer-${col}" x="${x}" y="${PAD}" width="${NODE_W}" height="${HEADER_H}" rx="6"/>`,
@@ -56,19 +59,32 @@ export function renderFgcaSvg(doc, options = {}) {
56
59
  }
57
60
  }
58
61
  const twoLines = line2.length > 0;
59
- const y1 = twoLines ? n.y + NODE_H / 2 - 8 : n.y + NODE_H / 2 + 5;
60
- const y2 = y1 + 18;
62
+ const y1 = twoLines ? n.y + 16 : n.y + 26;
63
+ const y2 = n.y + 32;
64
+ const idY = twoLines ? n.y + 54 : n.y + 50;
65
+ const entityId = n.id.slice(n.id.indexOf('_') + 1);
61
66
  return [
62
67
  `<rect class="diagram-node layer-${n.col}" x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="8"/>`,
63
- `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle">${escXml(line1)}</text>`,
68
+ `<text class="text-primary" x="${n.x + NODE_W / 2}" y="${y1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>`,
64
69
  twoLines
65
- ? `<text class="text-secondary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle">${escXml(line2)}</text>`
70
+ ? `<text class="text-secondary" x="${n.x + NODE_W / 2}" y="${y2}" text-anchor="middle" dominant-baseline="central">${escXml(line2)}</text>`
66
71
  : '',
72
+ `<text class="text-id" x="${n.x + NODE_W / 2}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(entityId)}</text>`,
67
73
  ]
68
74
  .filter(Boolean)
69
75
  .join('\n');
70
76
  })
71
77
  .join('\n');
78
+ return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
79
+ }
80
+ export function renderFgcaSvg(doc, options = {}) {
81
+ const { variant = 'fgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
82
+ const hideChanges = variant === 'fga';
83
+ const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, { hideChanges });
84
+ if (nodes.length === 0) {
85
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
86
+ }
87
+ const body = renderFgcaBody(columns, nodes, edges, curvature, entryCurvature);
72
88
  const titleSvg = title
73
89
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
74
90
  : '';
@@ -84,8 +100,6 @@ export function renderFgcaSvg(doc, options = {}) {
84
100
  </marker>
85
101
  </defs>
86
102
  ${titleSvg}
87
- ${headerSvg}
88
- ${nodeSvg}
89
- ${edgeSvg}
103
+ ${body}
90
104
  </svg>`;
91
105
  }
@@ -1,7 +1,32 @@
1
- import type { GoalTree } from '../goals/types.js';
1
+ import type { GoalTree, GoalTreeLayout } from '../goals/types.js';
2
+ import { type ThemeId } from '../theme/index.js';
2
3
  export interface RenderGoalsOptions {
3
4
  treeName?: string;
4
5
  curvature?: number;
5
6
  entryCurvature?: number;
6
7
  }
8
+ /**
9
+ * Host-neutral goals renderer (IntelliJ/UI). Lays the tree out with the default
10
+ * spacing, then delegates the actual SVG emission to {@link renderGoalsLayoutSvg}
11
+ * with the shared theme CSS embedded so the output is self-contained.
12
+ */
7
13
  export declare function renderGoalsSvg(tree: GoalTree, options?: RenderGoalsOptions): string;
14
+ export interface RenderGoalsLayoutOptions {
15
+ curvature?: number;
16
+ entryCurvature?: number;
17
+ /** Extra vertical space reserved at the top of the canvas (e.g. for a title block). */
18
+ topInset?: number;
19
+ /** Raw SVG injected immediately after `<defs>` — a header line or a full title block. */
20
+ title?: string;
21
+ /** When set, the theme CSS is embedded as `<style>` so the SVG is self-contained. */
22
+ embedCssTheme?: ThemeId;
23
+ }
24
+ /**
25
+ * The single goals SVG emitter shared by every host. Takes an already-computed
26
+ * {@link GoalTreeLayout} (callers decide spacing/scope) and produces the `<svg>`.
27
+ * Hosts wrap it with their own chrome:
28
+ * - IntelliJ/UI via {@link renderGoalsSvg} (embedded CSS + simple header);
29
+ * - VS Code's goals preview (rich title block, no embedded CSS — the webview
30
+ * and the export path own styling).
31
+ */
32
+ export declare function renderGoalsLayoutSvg(layout: GoalTreeLayout, options?: RenderGoalsLayoutOptions): string;
@@ -13,6 +13,7 @@
13
13
  import { layoutGoalTree } from '../goals/layout.js';
14
14
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
15
15
  import { generateSvgEmbedCss } from '../theme/index.js';
16
+ import { escXml } from './render-util.js';
16
17
  const NODE_W = 250;
17
18
  const NODE_H = 60;
18
19
  const RANK_SEP = 100;
@@ -20,13 +21,11 @@ const NODE_SEP = 24;
20
21
  const PAD = 24;
21
22
  const LABEL_MAX = 36;
22
23
  const LABEL_TRIM = 34;
23
- function escXml(s) {
24
- return s
25
- .replace(/&/g, '&amp;')
26
- .replace(/</g, '&lt;')
27
- .replace(/>/g, '&gt;')
28
- .replace(/"/g, '&quot;');
29
- }
24
+ /**
25
+ * Host-neutral goals renderer (IntelliJ/UI). Lays the tree out with the default
26
+ * spacing, then delegates the actual SVG emission to {@link renderGoalsLayoutSvg}
27
+ * with the shared theme CSS embedded so the output is self-contained.
28
+ */
30
29
  export function renderGoalsSvg(tree, options = {}) {
31
30
  const { treeName = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature } = options;
32
31
  const layout = layoutGoalTree(tree, {
@@ -38,10 +37,29 @@ export function renderGoalsSvg(tree, options = {}) {
38
37
  if (layout.nodes.length === 0) {
39
38
  return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
40
39
  }
40
+ const title = treeName
41
+ ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
42
+ : '';
43
+ // Embed the shared theme CSS so the JCEF host page only needs to drop the SVG
44
+ // into the DOM — styling resolves without help from the host stylesheet. The
45
+ // VS Code preview omits this (its webview supplies the CSS) and embeds it only
46
+ // on export via `prepareSvgForExport`.
47
+ return renderGoalsLayoutSvg(layout, { curvature, entryCurvature, title, embedCssTheme: 'transitrix' });
48
+ }
49
+ /**
50
+ * The single goals SVG emitter shared by every host. Takes an already-computed
51
+ * {@link GoalTreeLayout} (callers decide spacing/scope) and produces the `<svg>`.
52
+ * Hosts wrap it with their own chrome:
53
+ * - IntelliJ/UI via {@link renderGoalsSvg} (embedded CSS + simple header);
54
+ * - VS Code's goals preview (rich title block, no embedded CSS — the webview
55
+ * and the export path own styling).
56
+ */
57
+ export function renderGoalsLayoutSvg(layout, options = {}) {
58
+ const { curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, topInset = 0, title = '', embedCssTheme } = options;
41
59
  const w = layout.bounds.width + PAD * 2;
42
- const h = layout.bounds.height + PAD * 2;
60
+ const h = layout.bounds.height + PAD * 2 + topInset;
43
61
  const ox = -layout.bounds.x + PAD;
44
- const oy = -layout.bounds.y + PAD;
62
+ const oy = -layout.bounds.y + PAD + topInset;
45
63
  const nodeMap = new Map(layout.nodes.map((n) => [n.id, n]));
46
64
  function edgePath(e) {
47
65
  const s = nodeMap.get(e.source);
@@ -70,22 +88,14 @@ export function renderGoalsSvg(tree, options = {}) {
70
88
  </g>`;
71
89
  })
72
90
  .join('\n');
73
- const titleSvg = treeName
74
- ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Goal tree — ${treeName}`)}</text>`
75
- : '';
76
- // Embed the shared theme CSS inside the SVG so the rendered output is
77
- // self-contained — the JCEF host page only needs to drop the SVG into the
78
- // DOM and styling resolves without any cooperation from the host stylesheet.
79
- // Matches what the VS Code path produces via `prepareSvgForExport`.
80
- const embedCss = generateSvgEmbedCss('transitrix');
81
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
82
- <style>${embedCss}</style>
91
+ const styleLine = embedCssTheme ? `\n<style>${generateSvgEmbedCss(embedCssTheme)}</style>` : '';
92
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${styleLine}
83
93
  <defs>
84
94
  <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
85
95
  <path d="M0,0 L0,6 L8,3 z" class="arrow-fill"/>
86
96
  </marker>
87
97
  </defs>
88
- ${titleSvg}
98
+ ${title}
89
99
  ${nodeSvg}
90
100
  ${edgeSvg}
91
101
  </svg>`;
@@ -1,4 +1,14 @@
1
- import type { ProcessBlueprintFile } from '../process-blueprint/types.js';
1
+ import type { ProcessBlueprintFile, ProcessBlueprintLayout } from '../process-blueprint/types.js';
2
+ /**
3
+ * Canonical Process Blueprint body — everything inside the `<svg>` except the
4
+ * host-specific title block. Shared verbatim by the VS Code preview and the
5
+ * host-neutral wrapper below so both surfaces stay pixel-identical.
6
+ *
7
+ * Layering: a clipped group holds the row fills and grid lines; pills and
8
+ * compliance chips render on top (so grid lines sit under pill content); the
9
+ * outer rounded border is drawn last so corner cells never overflow it.
10
+ */
11
+ export declare function renderProcessBlueprintBody(layout: ProcessBlueprintLayout, ox: number, oy: number, clipId?: string): string;
2
12
  export interface RenderProcessBlueprintOptions {
3
13
  title?: string;
4
14
  }