@transitrix/diagrams 1.3.0 → 1.4.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.
- package/dist/__tests__/yaml-normalize.test.js +9 -9
- package/dist/activities/__tests__/validate.test.js +77 -74
- package/dist/activities/validate.js +5 -12
- package/dist/activity-card/__tests__/example.test.js +2 -2
- package/dist/activity-card/__tests__/validate.test.js +29 -18
- package/dist/activity-card/validate.js +3 -10
- package/dist/actor/__tests__/validate.test.js +6 -0
- package/dist/actor/types.d.ts +1 -1
- package/dist/actor/types.js +2 -0
- package/dist/applications/types.d.ts +7 -0
- package/dist/applications/validate.js +18 -0
- package/dist/blocks/__tests__/layout.test.js +1 -1
- package/dist/blocks/layout.js +1 -1
- package/dist/business-service/__tests__/validate.test.d.ts +1 -0
- package/dist/business-service/__tests__/validate.test.js +135 -0
- package/dist/business-service/index.d.ts +3 -0
- package/dist/business-service/index.js +2 -0
- package/dist/business-service/types.d.ts +20 -0
- package/dist/business-service/types.js +3 -0
- package/dist/business-service/validate.d.ts +2 -0
- package/dist/business-service/validate.js +74 -0
- package/dist/fgca/__tests__/parse-canonical.test.js +10 -10
- package/dist/fgca/__tests__/resolver.test.js +7 -7
- package/dist/fgca/__tests__/validate.test.js +11 -11
- package/dist/fgca/parse-canonical.js +7 -11
- package/dist/fgca/resolver.d.ts +2 -2
- package/dist/fgca/resolver.js +5 -5
- package/dist/fgca/validate.js +4 -13
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/integration/__tests__/validate.test.d.ts +1 -0
- package/dist/integration/__tests__/validate.test.js +169 -0
- package/dist/integration/index.d.ts +2 -0
- package/dist/integration/index.js +1 -0
- package/dist/integration/types.d.ts +27 -0
- package/dist/integration/types.js +5 -0
- package/dist/integration/validate.d.ts +2 -0
- package/dist/integration/validate.js +94 -0
- package/dist/location/__tests__/validate.test.d.ts +1 -0
- package/dist/location/__tests__/validate.test.js +107 -0
- package/dist/location/index.d.ts +3 -0
- package/dist/location/index.js +2 -0
- package/dist/location/types.d.ts +20 -0
- package/dist/location/types.js +3 -0
- package/dist/location/validate.d.ts +2 -0
- package/dist/location/validate.js +63 -0
- package/dist/node/__tests__/validate.test.d.ts +1 -0
- package/dist/node/__tests__/validate.test.js +100 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +1 -0
- package/dist/node/types.d.ts +18 -0
- package/dist/node/types.js +3 -0
- package/dist/node/validate.d.ts +2 -0
- package/dist/node/validate.js +53 -0
- package/dist/repo-validate/__tests__/validate-repo.test.js +444 -0
- package/dist/repo-validate/validate-repo.js +224 -10
- package/dist/technology-service/__tests__/validate.test.d.ts +1 -0
- package/dist/technology-service/__tests__/validate.test.js +121 -0
- package/dist/technology-service/index.d.ts +2 -0
- package/dist/technology-service/index.js +1 -0
- package/dist/technology-service/types.d.ts +18 -0
- package/dist/technology-service/types.js +3 -0
- package/dist/technology-service/validate.d.ts +2 -0
- package/dist/technology-service/validate.js +64 -0
- package/dist/webview/__tests__/entry-notations.test.js +2 -4
- package/dist/webview/__tests__/render-activities.test.d.ts +1 -0
- package/dist/webview/__tests__/render-activities.test.js +91 -0
- package/dist/webview/__tests__/render-blocks.test.d.ts +1 -0
- package/dist/webview/__tests__/render-blocks.test.js +130 -0
- package/dist/webview/entry.d.ts +1 -1
- package/dist/webview/entry.js +8 -15
- package/dist/webview/render-activities.d.ts +12 -0
- package/dist/webview/render-activities.js +9 -3
- package/dist/webview/render-blocks.js +97 -18
- package/package.json +1 -1
- package/src/__tests__/yaml-normalize.test.ts +11 -11
- package/src/activities/__tests__/validate.test.ts +77 -74
- package/src/activities/validate.ts +5 -12
- package/src/activity-card/__tests__/example.test.ts +2 -2
- package/src/activity-card/__tests__/validate.test.ts +30 -18
- package/src/activity-card/validate.ts +3 -9
- package/src/actor/__tests__/validate.test.ts +8 -0
- package/src/actor/types.ts +2 -0
- package/src/applications/types.ts +8 -0
- package/src/applications/validate.ts +20 -0
- package/src/blocks/__tests__/layout.test.ts +1 -1
- package/src/blocks/layout.ts +1 -1
- package/src/business-service/__tests__/validate.test.ts +160 -0
- package/src/business-service/index.ts +3 -0
- package/src/business-service/types.ts +29 -0
- package/src/business-service/validate.ts +82 -0
- package/src/fgca/__tests__/parse-canonical.test.ts +10 -10
- package/src/fgca/__tests__/resolver.test.ts +7 -7
- package/src/fgca/__tests__/validate.test.ts +11 -11
- package/src/fgca/parse-canonical.ts +7 -11
- package/src/fgca/resolver.ts +5 -5
- package/src/fgca/validate.ts +4 -12
- package/src/index.ts +5 -0
- package/src/integration/__tests__/validate.test.ts +203 -0
- package/src/integration/index.ts +2 -0
- package/src/integration/types.ts +38 -0
- package/src/integration/validate.ts +104 -0
- package/src/location/__tests__/validate.test.ts +127 -0
- package/src/location/index.ts +3 -0
- package/src/location/types.ts +29 -0
- package/src/location/validate.ts +70 -0
- package/src/node/__tests__/validate.test.ts +118 -0
- package/src/node/index.ts +2 -0
- package/src/node/types.ts +27 -0
- package/src/node/validate.ts +59 -0
- package/src/repo-validate/__tests__/validate-repo.test.ts +550 -0
- package/src/repo-validate/validate-repo.ts +233 -10
- package/src/technology-service/__tests__/validate.test.ts +143 -0
- package/src/technology-service/index.ts +2 -0
- package/src/technology-service/types.ts +27 -0
- package/src/technology-service/validate.ts +71 -0
- package/src/webview/__tests__/entry-notations.test.ts +2 -4
- package/src/webview/__tests__/render-activities.test.ts +100 -0
- package/src/webview/__tests__/render-blocks.test.ts +155 -0
- package/src/webview/entry.ts +10 -19
- package/src/webview/render-activities.ts +29 -4
- package/src/webview/render-blocks.ts +101 -20
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the host-neutral nested-blocks SVG renderer used by both the
|
|
3
|
+
* VS Code blocks preview and the IntelliJ JCEF webview bundle. These tests pin
|
|
4
|
+
* the per-node label rule (CLAUDE.md "Entity block layout"): leaf nodes wrap
|
|
5
|
+
* the name to at most 3 lines and the ID to at most 2 lines, with `…`
|
|
6
|
+
* truncation beyond that; container headers stack name + ID, each truncated
|
|
7
|
+
* with `…` to fit the block width. No label may extend past the rect.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import type { BlocksFile } from '../../blocks/types.js';
|
|
12
|
+
import { renderBlocksSvg } from '../render-blocks.js';
|
|
13
|
+
|
|
14
|
+
// Approximate character widths used by the renderer when sizing labels.
|
|
15
|
+
// Kept in sync with `CHAR_W` / `CHAR_W_ID` in render-blocks.ts so the test
|
|
16
|
+
// can compute the same overflow bounds the renderer uses.
|
|
17
|
+
const CHAR_W = 7;
|
|
18
|
+
const CHAR_W_ID = 6;
|
|
19
|
+
|
|
20
|
+
// Same horizontal margin the renderer reserves so text never abuts the rect.
|
|
21
|
+
const TEXT_MARGIN_X = 8;
|
|
22
|
+
|
|
23
|
+
function leafDoc(name: string, id: string): BlocksFile {
|
|
24
|
+
return {
|
|
25
|
+
notation: 'blocks',
|
|
26
|
+
spec_version: '0.1',
|
|
27
|
+
nested_blocks: {
|
|
28
|
+
id: 'BLOCKS-T-1',
|
|
29
|
+
name: 'Test',
|
|
30
|
+
blocks: [{ id, name }],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function containerDoc(name: string, id: string, childId = 'CHILD', childName = 'Child'): BlocksFile {
|
|
36
|
+
return {
|
|
37
|
+
notation: 'blocks',
|
|
38
|
+
spec_version: '0.1',
|
|
39
|
+
nested_blocks: {
|
|
40
|
+
id: 'BLOCKS-T-1',
|
|
41
|
+
name: 'Test',
|
|
42
|
+
blocks: [
|
|
43
|
+
{
|
|
44
|
+
id,
|
|
45
|
+
name,
|
|
46
|
+
children: [{ id: childId, name: childName }],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pull every text-primary / text-id line out of an SVG, in document order. */
|
|
54
|
+
function extractLabels(svg: string): Array<{ cls: string; text: string }> {
|
|
55
|
+
const re = /<text class="(text-(?:primary|id))"[^>]*>([^<]*)<\/text>/g;
|
|
56
|
+
const out: Array<{ cls: string; text: string }> = [];
|
|
57
|
+
let m: RegExpExecArray | null;
|
|
58
|
+
while ((m = re.exec(svg))) out.push({ cls: m[1], text: m[2] });
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('renderBlocksSvg — leaf labels', () => {
|
|
63
|
+
it('wraps a long name to at most 3 lines and truncates with …', () => {
|
|
64
|
+
const longName =
|
|
65
|
+
'Personal data records that linger after consent withdrawal across many systems and ' +
|
|
66
|
+
'archives — known erasure gap';
|
|
67
|
+
const svg = renderBlocksSvg(leafDoc(longName, 'PII'));
|
|
68
|
+
const nameLines = extractLabels(svg).filter((l) => l.cls === 'text-primary');
|
|
69
|
+
expect(nameLines.length).toBeGreaterThan(0);
|
|
70
|
+
expect(nameLines.length).toBeLessThanOrEqual(3);
|
|
71
|
+
// If the name needed truncation, the final visible line must end with `…`.
|
|
72
|
+
if (nameLines.length === 3) {
|
|
73
|
+
expect(nameLines[2].text.endsWith('…')).toBe(true);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('wraps a long ID to at most 2 lines and truncates with …', () => {
|
|
78
|
+
const longId = 'VERY_LONG_BLOCK_IDENTIFIER_WITH_MANY_SEGMENTS_AND_MORE_AND_MORE';
|
|
79
|
+
const svg = renderBlocksSvg(leafDoc('Short name', longId));
|
|
80
|
+
const idLines = extractLabels(svg).filter((l) => l.cls === 'text-id');
|
|
81
|
+
expect(idLines.length).toBeGreaterThan(0);
|
|
82
|
+
expect(idLines.length).toBeLessThanOrEqual(2);
|
|
83
|
+
if (idLines.length === 2) {
|
|
84
|
+
expect(idLines[1].text.endsWith('…')).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('keeps every label inside the 160px leaf width (approximated by CHAR_W * length)', () => {
|
|
89
|
+
const svg = renderBlocksSvg(
|
|
90
|
+
leafDoc(
|
|
91
|
+
'Personal data records that linger after consent withdrawal across many systems',
|
|
92
|
+
'PII_RECORDS_WITH_A_FAIRLY_LONG_IDENTIFIER_HERE',
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
const labels = extractLabels(svg);
|
|
96
|
+
const inner = 160 - TEXT_MARGIN_X * 2;
|
|
97
|
+
for (const l of labels) {
|
|
98
|
+
const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
|
|
99
|
+
expect(l.text.length * cw).toBeLessThanOrEqual(inner);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('renderBlocksSvg — container headers', () => {
|
|
105
|
+
it('renders the name and ID as two separate stacked text elements', () => {
|
|
106
|
+
const svg = renderBlocksSvg(containerDoc('Active systems', 'ACTIVE_SYSTEMS'));
|
|
107
|
+
const labels = extractLabels(svg);
|
|
108
|
+
// Container header contributes one text-primary (name) and one text-id (id);
|
|
109
|
+
// the leaf child contributes another pair. So 2 of each kind in total.
|
|
110
|
+
const primaries = labels.filter((l) => l.cls === 'text-primary').map((l) => l.text);
|
|
111
|
+
const ids = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
|
|
112
|
+
expect(primaries).toContain('Active systems');
|
|
113
|
+
expect(ids).toContain('ACTIVE_SYSTEMS');
|
|
114
|
+
// The legacy "(id)" suffix on the name line must not appear — id has its own row.
|
|
115
|
+
for (const t of primaries) expect(t).not.toContain('(');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('truncates a long container ID so it does not overflow the block width', () => {
|
|
119
|
+
const svg = renderBlocksSvg(
|
|
120
|
+
containerDoc(
|
|
121
|
+
'Short name',
|
|
122
|
+
'VERY_LONG_CONTAINER_IDENTIFIER_THAT_WOULD_OTHERWISE_OVERFLOW',
|
|
123
|
+
'CHILD',
|
|
124
|
+
'Child',
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
const labels = extractLabels(svg);
|
|
128
|
+
// The container width derives from its single 160px child: 160 + 2*12 (padding) = 184.
|
|
129
|
+
const containerW = 160 + 24;
|
|
130
|
+
const inner = containerW - TEXT_MARGIN_X * 2;
|
|
131
|
+
for (const l of labels) {
|
|
132
|
+
const cw = l.cls === 'text-primary' ? CHAR_W : CHAR_W_ID;
|
|
133
|
+
expect(l.text.length * cw).toBeLessThanOrEqual(inner);
|
|
134
|
+
}
|
|
135
|
+
// The full ID was too long to fit on one line of `inner / CHAR_W_ID` chars, so
|
|
136
|
+
// the rendered ID must be the truncated form ending in `…`.
|
|
137
|
+
const renderedIds = labels.filter((l) => l.cls === 'text-id').map((l) => l.text);
|
|
138
|
+
expect(renderedIds.some((t) => t.endsWith('…'))).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('truncates a long container name with … to fit the block width', () => {
|
|
142
|
+
const longName =
|
|
143
|
+
'Active systems that are erasure-reachable within the standard 30-day operational window';
|
|
144
|
+
const svg = renderBlocksSvg(containerDoc(longName, 'ACTIVE'));
|
|
145
|
+
const labels = extractLabels(svg);
|
|
146
|
+
const containerW = 160 + 24;
|
|
147
|
+
const inner = containerW - TEXT_MARGIN_X * 2;
|
|
148
|
+
const primaries = labels.filter((l) => l.cls === 'text-primary');
|
|
149
|
+
for (const l of primaries) {
|
|
150
|
+
expect(l.text.length * CHAR_W).toBeLessThanOrEqual(inner);
|
|
151
|
+
}
|
|
152
|
+
// At least one primary line must have been truncated.
|
|
153
|
+
expect(primaries.some((l) => l.text.endsWith('…'))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/webview/entry.ts
CHANGED
|
@@ -93,10 +93,8 @@ export type NotationKind =
|
|
|
93
93
|
| 'goals'
|
|
94
94
|
| 'dgca'
|
|
95
95
|
| 'dga'
|
|
96
|
-
| '
|
|
97
|
-
| '
|
|
98
|
-
| 'activities'
|
|
99
|
-
| 'activity-card'
|
|
96
|
+
| 'action'
|
|
97
|
+
| 'action-card'
|
|
100
98
|
| 'applications'
|
|
101
99
|
| 'products'
|
|
102
100
|
| 'process-map'
|
|
@@ -109,10 +107,8 @@ const SUPPORTED_KINDS: readonly NotationKind[] = [
|
|
|
109
107
|
'goals',
|
|
110
108
|
'dgca',
|
|
111
109
|
'dga',
|
|
112
|
-
'
|
|
113
|
-
'
|
|
114
|
-
'activities',
|
|
115
|
-
'activity-card',
|
|
110
|
+
'action',
|
|
111
|
+
'action-card',
|
|
116
112
|
'applications',
|
|
117
113
|
'products',
|
|
118
114
|
'process-map',
|
|
@@ -186,11 +182,7 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
186
182
|
}
|
|
187
183
|
return r;
|
|
188
184
|
}
|
|
189
|
-
|
|
190
|
-
// deprecated aliases. All four share the same parse + renderer; only the
|
|
191
|
-
// Change column visibility differs (DGA/FGA collapse it).
|
|
192
|
-
case 'dgca':
|
|
193
|
-
case 'fgca': {
|
|
185
|
+
case 'dgca': {
|
|
194
186
|
const v = parseCanonicalFGCA(doc);
|
|
195
187
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
196
188
|
r.errors.push(...v.errors);
|
|
@@ -200,8 +192,7 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
200
192
|
}
|
|
201
193
|
return r;
|
|
202
194
|
}
|
|
203
|
-
case 'dga':
|
|
204
|
-
case 'fga': {
|
|
195
|
+
case 'dga': {
|
|
205
196
|
const v = parseCanonicalFGA(doc);
|
|
206
197
|
const r = emptyResult(kind, v.valid ? 'ok' : 'error');
|
|
207
198
|
r.errors.push(...v.errors);
|
|
@@ -211,12 +202,12 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
211
202
|
}
|
|
212
203
|
return r;
|
|
213
204
|
}
|
|
214
|
-
case '
|
|
215
|
-
return renderFromValidation('
|
|
205
|
+
case 'action':
|
|
206
|
+
return renderFromValidation('action', validateActivities(doc), () =>
|
|
216
207
|
renderActivitiesSvg(doc as ActivityDoc),
|
|
217
208
|
);
|
|
218
|
-
case '
|
|
219
|
-
return renderFromValidation('
|
|
209
|
+
case 'action-card':
|
|
210
|
+
return renderFromValidation('action-card', validateActivityCard(doc), () =>
|
|
220
211
|
renderActivityCardSvg(doc as ActivityCardDoc),
|
|
221
212
|
);
|
|
222
213
|
case 'process-blueprint':
|
|
@@ -144,6 +144,18 @@ export interface RenderActivitiesOptions {
|
|
|
144
144
|
curvature?: number;
|
|
145
145
|
/** Entry curvature at the target node; defaults to `curvature` when omitted. */
|
|
146
146
|
entryCurvature?: number;
|
|
147
|
+
/**
|
|
148
|
+
* When true (the default), activities with `activity_type === 'project'` are
|
|
149
|
+
* excluded from the network layout. Project container nodes add visual noise
|
|
150
|
+
* in the PSND view because the diagram itself already represents the project
|
|
151
|
+
* scope; suppressing them declutters the network without altering canonical
|
|
152
|
+
* data. Set to false to render all activities regardless of type.
|
|
153
|
+
*
|
|
154
|
+
* Convention: Network/diagram views suppress project nodes by default.
|
|
155
|
+
* Text/document views (Tree) keep them visible and compensate with the
|
|
156
|
+
* Action name in the view header.
|
|
157
|
+
*/
|
|
158
|
+
suppressProjectNodes?: boolean;
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
/**
|
|
@@ -161,15 +173,28 @@ export interface RenderActivitiesOptions {
|
|
|
161
173
|
* than short-circuiting.
|
|
162
174
|
*/
|
|
163
175
|
export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesOptions = {}): string {
|
|
164
|
-
const {
|
|
165
|
-
|
|
166
|
-
|
|
176
|
+
const {
|
|
177
|
+
title = '',
|
|
178
|
+
gaps = {},
|
|
179
|
+
curvature = DEFAULT_EDGE_CURVATURE,
|
|
180
|
+
entryCurvature,
|
|
181
|
+
suppressProjectNodes = true,
|
|
182
|
+
} = options;
|
|
183
|
+
|
|
184
|
+
// #421: Suppress project-type container nodes in the network layout by
|
|
185
|
+
// default (see suppressProjectNodes JSDoc). A shallow copy avoids mutating
|
|
186
|
+
// the caller's doc.
|
|
187
|
+
const renderDoc: ActivityDoc = suppressProjectNodes
|
|
188
|
+
? { ...doc, activities: (doc.activities ?? []).filter((a) => a.activity_type?.toLowerCase() !== 'project') }
|
|
189
|
+
: doc;
|
|
190
|
+
|
|
191
|
+
const layout: ActivitiesLayout = layoutActivities(renderDoc, gaps);
|
|
167
192
|
|
|
168
193
|
if (layout.nodes.length === 0) {
|
|
169
194
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0"></svg>`;
|
|
170
195
|
}
|
|
171
196
|
|
|
172
|
-
const cpm = computeCpm(
|
|
197
|
+
const cpm = computeCpm(renderDoc.activities ?? []);
|
|
173
198
|
const titleH = title ? 24 : 0;
|
|
174
199
|
const w = layout.bounds.width + N_PAD * 2;
|
|
175
200
|
const h = layout.bounds.height + N_PAD * 2 + titleH;
|
|
@@ -21,9 +21,53 @@ const PAD = 24;
|
|
|
21
21
|
|
|
22
22
|
// Approximate character width (px) at text-primary size (12px/600).
|
|
23
23
|
const CHAR_W = 7;
|
|
24
|
-
// Approximate character width (px) at text-id size (10px/600)
|
|
24
|
+
// Approximate character width (px) at text-id size (10px/600).
|
|
25
25
|
const CHAR_W_ID = 6;
|
|
26
26
|
|
|
27
|
+
// Vertical spacing between line centres.
|
|
28
|
+
const LINE_H = 14; // name lines (text-primary)
|
|
29
|
+
const LINE_H_ID = 12; // id lines (text-id)
|
|
30
|
+
const NAME_ID_GAP = 6; // gap between last name centre and first ID centre
|
|
31
|
+
|
|
32
|
+
/** Word-wrap `text` to at most `maxLines` lines of `maxChars` each. */
|
|
33
|
+
function wrapWords(text: string, maxChars: number, maxLines: number): string[] {
|
|
34
|
+
const safeMax = Math.max(2, maxChars);
|
|
35
|
+
const words = text.split(' ');
|
|
36
|
+
const lines: string[] = [];
|
|
37
|
+
let cur = '';
|
|
38
|
+
for (let i = 0; i < words.length; i++) {
|
|
39
|
+
const w = words[i];
|
|
40
|
+
const next = cur ? `${cur} ${w}` : w;
|
|
41
|
+
if (next.length <= safeMax) {
|
|
42
|
+
cur = next;
|
|
43
|
+
} else {
|
|
44
|
+
if (cur) lines.push(cur);
|
|
45
|
+
cur = '';
|
|
46
|
+
if (lines.length >= maxLines) break;
|
|
47
|
+
if (lines.length === maxLines - 1) {
|
|
48
|
+
const rest = words.slice(i).join(' ');
|
|
49
|
+
lines.push(rest.length <= safeMax ? rest : rest.slice(0, safeMax - 1) + '…');
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
cur = w.length <= safeMax ? w : w.slice(0, safeMax - 1) + '…';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (cur && lines.length < maxLines) lines.push(cur);
|
|
56
|
+
return lines.length ? lines : [text.slice(0, safeMax - 1) + '…'];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Split an ID (no spaces) across at most 2 lines, breaking at `_` or `-`. */
|
|
60
|
+
function wrapId(id: string, maxChars: number): string[] {
|
|
61
|
+
if (id.length <= maxChars) return [id];
|
|
62
|
+
const seg = id.slice(0, maxChars);
|
|
63
|
+
const sepIdx = Math.max(seg.lastIndexOf('_'), seg.lastIndexOf('-'));
|
|
64
|
+
const cut = sepIdx > Math.floor(maxChars / 3) ? sepIdx : maxChars - 1;
|
|
65
|
+
const isSep = id[cut] === '_' || id[cut] === '-';
|
|
66
|
+
const line1 = id.slice(0, cut);
|
|
67
|
+
const rest = id.slice(cut + (isSep ? 1 : 0));
|
|
68
|
+
return [line1, rest.length <= maxChars ? rest : rest.slice(0, maxChars - 1) + '…'];
|
|
69
|
+
}
|
|
70
|
+
|
|
27
71
|
export interface RenderBlocksOptions {
|
|
28
72
|
title?: string;
|
|
29
73
|
}
|
|
@@ -41,12 +85,25 @@ function levelClassForDepth(depth: number): string {
|
|
|
41
85
|
return `level-${idx}`;
|
|
42
86
|
}
|
|
43
87
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
// Reserve a small horizontal margin so text never abuts the rounded corners.
|
|
89
|
+
const TEXT_MARGIN_X = 8;
|
|
90
|
+
|
|
91
|
+
/** Truncate `text` to at most `maxChars` characters, at a word boundary when possible. */
|
|
92
|
+
function truncateLine(text: string, maxChars: number): string {
|
|
93
|
+
const safeMax = Math.max(2, maxChars);
|
|
94
|
+
if (text.length <= safeMax) return text;
|
|
95
|
+
const cut = text.slice(0, safeMax - 1);
|
|
48
96
|
const lastSpace = cut.lastIndexOf(' ');
|
|
49
|
-
return (lastSpace >
|
|
97
|
+
return (lastSpace > safeMax / 2 ? cut.slice(0, lastSpace) : cut) + '…';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Single-line ID truncation that prefers `_`/`-` separators before falling back to a hard cut. */
|
|
101
|
+
function truncateId(id: string, maxChars: number): string {
|
|
102
|
+
const safeMax = Math.max(2, maxChars);
|
|
103
|
+
if (id.length <= safeMax) return id;
|
|
104
|
+
const cut = id.slice(0, safeMax - 1);
|
|
105
|
+
const sepIdx = Math.max(cut.lastIndexOf('_'), cut.lastIndexOf('-'));
|
|
106
|
+
return (sepIdx > safeMax / 3 ? cut.slice(0, sepIdx) : cut) + '…';
|
|
50
107
|
}
|
|
51
108
|
|
|
52
109
|
function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]): void {
|
|
@@ -56,25 +113,49 @@ function emitBlockSvg(b: LaidOutBlock, ox: number, oy: number, parts: string[]):
|
|
|
56
113
|
`<rect class="diagram-node ${cls}" x="${b.x + ox}" y="${b.y + oy}" width="${b.width}" height="${b.height}" rx="6"/>`,
|
|
57
114
|
);
|
|
58
115
|
|
|
116
|
+
const innerW = Math.max(0, b.width - TEXT_MARGIN_X * 2);
|
|
59
117
|
const isLeaf = b.children.length === 0;
|
|
60
118
|
if (isLeaf) {
|
|
61
|
-
// Leaf block: name
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
119
|
+
// Leaf block: name (up to 3 lines) then ID (up to 2 lines), centred vertically.
|
|
120
|
+
const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
|
|
121
|
+
const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
|
|
122
|
+
const nameLines = wrapWords(b.name, maxCharsName, 3);
|
|
123
|
+
const idLines = wrapId(b.id, maxCharsId);
|
|
124
|
+
const nameTotalSpan = (nameLines.length - 1) * LINE_H;
|
|
125
|
+
const idTotalSpan = (idLines.length - 1) * LINE_H_ID;
|
|
126
|
+
const totalSpan = nameTotalSpan + NAME_ID_GAP + idTotalSpan;
|
|
127
|
+
const firstY = Math.round(b.y + oy + (b.height - totalSpan) / 2);
|
|
128
|
+
for (let i = 0; i < nameLines.length; i++) {
|
|
129
|
+
parts.push(
|
|
130
|
+
`<text class="text-primary" x="${cx}" y="${firstY + i * LINE_H}" text-anchor="middle" dominant-baseline="central">${escXml(nameLines[i])}</text>`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const idFirstY = firstY + nameTotalSpan + NAME_ID_GAP;
|
|
134
|
+
for (let i = 0; i < idLines.length; i++) {
|
|
135
|
+
parts.push(
|
|
136
|
+
`<text class="text-id" x="${cx}" y="${idFirstY + i * LINE_H_ID}" text-anchor="middle" dominant-baseline="central">${escXml(idLines[i])}</text>`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
// Container block: name (text-primary) above, ID (text-id, grey) below — both
|
|
141
|
+
// single-line, truncated with `…` to fit within `b.width`. The header strip is
|
|
142
|
+
// only `headerHeight` tall (default 28px), which is enough for one line each at
|
|
143
|
+
// the configured text sizes. Stacking the ID as its own text-id element keeps
|
|
144
|
+
// the wrapping/truncation rule consistent with leaves and guarantees no overflow
|
|
145
|
+
// for any combination of name/ID length.
|
|
146
|
+
const maxCharsName = Math.max(4, Math.floor(innerW / CHAR_W));
|
|
147
|
+
const maxCharsId = Math.max(4, Math.floor(innerW / CHAR_W_ID));
|
|
148
|
+
const nameText = truncateLine(b.name, maxCharsName);
|
|
149
|
+
const idText = truncateId(b.id, maxCharsId);
|
|
150
|
+
const headerMid = b.y + oy + b.headerHeight / 2;
|
|
151
|
+
const halfSpan = (LINE_H + LINE_H_ID) / 4;
|
|
152
|
+
const nameY = Math.round(headerMid - halfSpan);
|
|
153
|
+
const idY = Math.round(headerMid + halfSpan);
|
|
65
154
|
parts.push(
|
|
66
|
-
`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(
|
|
67
|
-
`<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(b.id)}</text>`,
|
|
155
|
+
`<text class="text-primary" x="${cx}" y="${nameY}" text-anchor="middle" dominant-baseline="central">${escXml(nameText)}</text>`,
|
|
68
156
|
);
|
|
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
157
|
parts.push(
|
|
77
|
-
`<text class="text-
|
|
158
|
+
`<text class="text-id" x="${cx}" y="${idY}" text-anchor="middle" dominant-baseline="central">${escXml(idText)}</text>`,
|
|
78
159
|
);
|
|
79
160
|
for (const c of b.children) emitBlockSvg(c, ox, oy, parts);
|
|
80
161
|
}
|