@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.
- package/dist/activities/layout.js +7 -7
- package/dist/activities/types.d.ts +4 -0
- package/dist/capability-map/dsm-schema.d.ts +1 -1
- package/dist/capability-map/dsm-schema.js +1 -1
- package/dist/capability-map/layout-tree.d.ts +5 -1
- package/dist/capability-map/layout-tree.js +11 -9
- package/dist/capability-map/render-capability-tree.d.ts +2 -9
- package/dist/capability-map/render-capability-tree.js +16 -28
- package/dist/fgca/preview-layout.d.ts +8 -4
- package/dist/fgca/preview-layout.js +13 -13
- package/dist/node-size-presets.d.ts +50 -0
- package/dist/node-size-presets.js +103 -0
- package/dist/process-blueprint/layout.js +4 -47
- package/dist/webview/__tests__/entity-text-layout.test.d.ts +1 -0
- package/dist/webview/__tests__/entity-text-layout.test.js +43 -0
- package/dist/webview/__tests__/entry.test.js +3 -3
- package/dist/webview/__tests__/render-blocks.test.js +1 -7
- package/dist/webview/entity-text-layout.d.ts +95 -0
- package/dist/webview/entity-text-layout.js +279 -0
- package/dist/webview/entry.js +7 -3
- package/dist/webview/notation-style.d.ts +1 -1
- package/dist/webview/notation-style.js +1 -1
- package/dist/webview/render-activities.d.ts +3 -1
- package/dist/webview/render-activities.js +29 -32
- package/dist/webview/render-blocks.d.ts +4 -1
- package/dist/webview/render-blocks.js +27 -108
- package/dist/webview/render-fgca.d.ts +15 -8
- package/dist/webview/render-fgca.js +41 -44
- package/dist/webview/render-goals.d.ts +2 -0
- package/dist/webview/render-goals.js +21 -36
- package/dist/webview/render-process-blueprint.d.ts +4 -10
- package/dist/webview/render-process-blueprint.js +18 -64
- package/package.json +1 -1
- package/src/activities/layout.ts +12 -7
- package/src/activities/types.ts +4 -0
- package/src/capability-map/dsm-schema.ts +1 -1
- package/src/capability-map/index.ts +1 -1
- package/src/capability-map/layout-tree.ts +16 -8
- package/src/capability-map/render-capability-tree.ts +18 -30
- package/src/fgca/preview-layout.ts +21 -15
- package/src/node-size-presets.ts +157 -0
- package/src/process-blueprint/layout.ts +4 -48
- package/src/webview/__tests__/entity-text-layout.test.ts +52 -0
- package/src/webview/__tests__/entry.test.ts +3 -3
- package/src/webview/__tests__/render-blocks.test.ts +1 -9
- package/src/webview/entity-text-layout.ts +355 -0
- package/src/webview/entry.ts +8 -4
- package/src/webview/notation-style.ts +1 -1
- package/src/webview/render-activities.ts +38 -27
- package/src/webview/render-blocks.ts +35 -118
- package/src/webview/render-fgca.ts +59 -44
- package/src/webview/render-goals.ts +22 -34
- package/src/webview/render-process-blueprint.ts +30 -65
package/src/webview/entry.ts
CHANGED
|
@@ -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/
|
|
34
|
-
import type { CapabilityMapFile } from '../capability-map/
|
|
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: '
|
|
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: '
|
|
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,
|
|
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
|
|
59
|
-
|
|
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,
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
-
* preview panel. The VS Code path lives in `extension/src/
|
|
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
|
-
*
|
|
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
|
|
18
|
-
FGCA_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
|
|
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="${
|
|
57
|
-
`<text class="text-header" x="${x +
|
|
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="${
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
/** `'
|
|
107
|
-
variant?: '
|
|
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 {
|
|
118
|
-
|
|
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, {
|
|
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
|
|
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:
|
|
44
|
-
nodeHeight:
|
|
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
|
-
|
|
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');
|