@diagrammo/dgmo 0.8.5 → 0.8.6
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/.claude/commands/dgmo.md +33 -0
- package/.cursorrules +20 -2
- package/.github/copilot-instructions.md +20 -2
- package/.windsurfrules +20 -2
- package/AGENTS.md +23 -3
- package/dist/cli.cjs +189 -190
- package/dist/editor.cjs +3 -18
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +3 -18
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +4 -21
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +4 -21
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +2785 -2996
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +56 -56
- package/dist/index.d.ts +56 -56
- package/dist/index.js +2780 -2989
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +97 -25
- package/gallery/fixtures/boxes-and-lines.dgmo +64 -0
- package/package.json +1 -1
- package/src/boxes-and-lines/collapse.ts +78 -0
- package/src/boxes-and-lines/layout.ts +319 -0
- package/src/boxes-and-lines/parser.ts +694 -0
- package/src/boxes-and-lines/renderer.ts +848 -0
- package/src/boxes-and-lines/types.ts +40 -0
- package/src/c4/parser.ts +10 -5
- package/src/c4/renderer.ts +232 -56
- package/src/chart.ts +9 -4
- package/src/cli.ts +6 -5
- package/src/completion.ts +25 -33
- package/src/d3.ts +26 -27
- package/src/dgmo-router.ts +3 -7
- package/src/echarts.ts +38 -2
- package/src/editor/keywords.ts +4 -19
- package/src/er/parser.ts +10 -4
- package/src/gantt/parser.ts +7 -4
- package/src/gantt/renderer.ts +3 -5
- package/src/index.ts +17 -26
- package/src/infra/parser.ts +7 -5
- package/src/infra/renderer.ts +2 -2
- package/src/kanban/parser.ts +7 -5
- package/src/kanban/renderer.ts +43 -18
- package/src/org/parser.ts +7 -4
- package/src/org/renderer.ts +40 -29
- package/src/sequence/parser.ts +11 -5
- package/src/sequence/renderer.ts +114 -45
- package/src/sitemap/parser.ts +8 -4
- package/src/sitemap/renderer.ts +137 -57
- package/src/utils/legend-svg.ts +44 -20
- package/src/utils/parsing.ts +1 -1
- package/src/utils/tag-groups.ts +21 -1
- package/gallery/fixtures/initiative-status-full.dgmo +0 -46
- package/gallery/fixtures/initiative-status-phases.dgmo +0 -29
- package/gallery/fixtures/initiative-status.dgmo +0 -9
- package/src/initiative-status/collapse.ts +0 -76
- package/src/initiative-status/filter.ts +0 -63
- package/src/initiative-status/layout.ts +0 -650
- package/src/initiative-status/parser.ts +0 -629
- package/src/initiative-status/renderer.ts +0 -1199
- package/src/initiative-status/types.ts +0 -57
|
@@ -1,1199 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Initiative Status Diagram — D3 SVG Renderer
|
|
3
|
-
// ============================================================
|
|
4
|
-
|
|
5
|
-
import * as d3Selection from 'd3-selection';
|
|
6
|
-
import * as d3Shape from 'd3-shape';
|
|
7
|
-
import { FONT_FAMILY } from '../fonts';
|
|
8
|
-
import { runInExportContainer, extractExportSvg } from '../utils/export-container';
|
|
9
|
-
import {
|
|
10
|
-
LEGEND_HEIGHT,
|
|
11
|
-
LEGEND_PILL_PAD,
|
|
12
|
-
LEGEND_PILL_FONT_SIZE,
|
|
13
|
-
LEGEND_CAPSULE_PAD,
|
|
14
|
-
LEGEND_DOT_R,
|
|
15
|
-
LEGEND_ENTRY_FONT_SIZE,
|
|
16
|
-
LEGEND_ENTRY_DOT_GAP,
|
|
17
|
-
LEGEND_ENTRY_TRAIL,
|
|
18
|
-
LEGEND_GROUP_GAP,
|
|
19
|
-
measureLegendText,
|
|
20
|
-
} from '../utils/legend-constants';
|
|
21
|
-
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
|
|
22
|
-
import { contrastText, mix } from '../palettes/color-utils';
|
|
23
|
-
import type { TagGroup } from '../utils/tag-groups';
|
|
24
|
-
import type { PaletteColors } from '../palettes';
|
|
25
|
-
import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
|
|
26
|
-
import type { ParticipantType } from '../sequence/parser';
|
|
27
|
-
import type { ISLayoutResult } from './layout';
|
|
28
|
-
import { parseInitiativeStatus } from './parser';
|
|
29
|
-
import { layoutInitiativeStatus } from './layout';
|
|
30
|
-
|
|
31
|
-
// ============================================================
|
|
32
|
-
// Constants
|
|
33
|
-
// ============================================================
|
|
34
|
-
|
|
35
|
-
const DIAGRAM_PADDING = 20;
|
|
36
|
-
const MAX_SCALE = 3;
|
|
37
|
-
const NODE_FONT_SIZE = 13;
|
|
38
|
-
const MIN_NODE_FONT_SIZE = 9;
|
|
39
|
-
const EDGE_LABEL_FONT_SIZE = 11;
|
|
40
|
-
const EDGE_STROKE_WIDTH = 2;
|
|
41
|
-
const NODE_STROKE_WIDTH = 2;
|
|
42
|
-
const NODE_RX = 8;
|
|
43
|
-
const ARROWHEAD_W = 5;
|
|
44
|
-
const ARROWHEAD_H = 4;
|
|
45
|
-
const CHAR_WIDTH_RATIO = 0.6; // approx char width / font size for Helvetica
|
|
46
|
-
const NODE_TEXT_PADDING = 12; // horizontal padding inside node for text
|
|
47
|
-
const SERVICE_RX = 10;
|
|
48
|
-
const GROUP_EXTRA_PADDING = 8;
|
|
49
|
-
const GROUP_LABEL_FONT_SIZE = 11;
|
|
50
|
-
const COLLAPSE_BAR_HEIGHT = 6;
|
|
51
|
-
|
|
52
|
-
// ============================================================
|
|
53
|
-
// Color helpers
|
|
54
|
-
// ============================================================
|
|
55
|
-
|
|
56
|
-
function statusColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
57
|
-
switch (status) {
|
|
58
|
-
case 'done': return palette.colors.green;
|
|
59
|
-
case 'doing': return palette.colors.blue;
|
|
60
|
-
case 'blocked': return palette.colors.orange;
|
|
61
|
-
case 'todo': return palette.colors.red;
|
|
62
|
-
case 'na': return isDark ? palette.colors.gray : '#2e3440';
|
|
63
|
-
default: return palette.textMuted;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function nodeFill(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
68
|
-
const color = statusColor(status, palette, isDark);
|
|
69
|
-
return mix(color, isDark ? palette.surface : palette.bg, 30);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function nodeStroke(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
73
|
-
return statusColor(status, palette, isDark);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function nodeTextColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
77
|
-
const fill = nodeFill(status, palette, isDark);
|
|
78
|
-
return contrastText(fill, '#eceff4', '#2e3440');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function edgeStrokeColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
82
|
-
return statusColor(status, palette, isDark);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ============================================================
|
|
86
|
-
// Legend helpers
|
|
87
|
-
// ============================================================
|
|
88
|
-
|
|
89
|
-
interface ISLegendEntry {
|
|
90
|
-
label: string;
|
|
91
|
-
statusKey: InitiativeStatus;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const IS_STATUS_LABELS: Record<string, string> = {
|
|
95
|
-
done: 'Done',
|
|
96
|
-
doing: 'In Progress',
|
|
97
|
-
blocked: 'Blocked',
|
|
98
|
-
todo: 'To Do',
|
|
99
|
-
na: 'N/A',
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'blocked', 'doing', 'done', 'na'];
|
|
103
|
-
|
|
104
|
-
function collectStatuses(parsed: ParsedInitiativeStatus): ISLegendEntry[] {
|
|
105
|
-
const present = new Set<string>();
|
|
106
|
-
for (const n of parsed.nodes) {
|
|
107
|
-
if (n.status) present.add(n.status);
|
|
108
|
-
}
|
|
109
|
-
return IS_STATUS_ORDER
|
|
110
|
-
.filter((s) => s !== null && present.has(s))
|
|
111
|
-
.map((s) => ({ label: IS_STATUS_LABELS[s!], statusKey: s }));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const LEGEND_GROUP_NAME = 'Status';
|
|
115
|
-
|
|
116
|
-
function legendEntriesWidth(entries: ISLegendEntry[]): number {
|
|
117
|
-
let w = 0;
|
|
118
|
-
for (const e of entries) {
|
|
119
|
-
w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
120
|
-
}
|
|
121
|
-
return w;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ============================================================
|
|
125
|
-
// Edge path generator
|
|
126
|
-
// ============================================================
|
|
127
|
-
|
|
128
|
-
// curveMonotoneX: interpolates through all control points and guarantees no
|
|
129
|
-
// Y-overshoot between consecutive points. Works for both our 4-point elbows
|
|
130
|
-
// (adjacent-rank) and dagre's fixed waypoints (multi-rank).
|
|
131
|
-
const lineGenerator = d3Shape.line<{ x: number; y: number }>()
|
|
132
|
-
.x((d) => d.x)
|
|
133
|
-
.y((d) => d.y)
|
|
134
|
-
.curve(d3Shape.curveMonotoneX);
|
|
135
|
-
|
|
136
|
-
// ============================================================
|
|
137
|
-
// Text fitting — wrap or shrink to fit fixed-size nodes
|
|
138
|
-
// ============================================================
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Splits a word at camelCase boundaries.
|
|
142
|
-
* "MyProVenue" → ["MyPro", "Venue"]
|
|
143
|
-
* "HTMLParser" → ["HTML", "Parser"]
|
|
144
|
-
* "getUserID" → ["get", "User", "ID"]
|
|
145
|
-
*/
|
|
146
|
-
function splitCamelCase(word: string): string[] {
|
|
147
|
-
const parts: string[] = [];
|
|
148
|
-
let start = 0;
|
|
149
|
-
for (let i = 1; i < word.length; i++) {
|
|
150
|
-
const prev = word[i - 1];
|
|
151
|
-
const curr = word[i];
|
|
152
|
-
const next = i + 1 < word.length ? word[i + 1] : '';
|
|
153
|
-
// aB → split before B (lowercase → uppercase)
|
|
154
|
-
const lowerToUpper = prev >= 'a' && prev <= 'z' && curr >= 'A' && curr <= 'Z';
|
|
155
|
-
// ABc → split before B when followed by lowercase (end of uppercase run)
|
|
156
|
-
const upperRunEnd =
|
|
157
|
-
prev >= 'A' && prev <= 'Z' && curr >= 'A' && curr <= 'Z' && next >= 'a' && next <= 'z';
|
|
158
|
-
if (lowerToUpper || upperRunEnd) {
|
|
159
|
-
parts.push(word.slice(start, i));
|
|
160
|
-
start = i;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
parts.push(word.slice(start));
|
|
164
|
-
return parts.length > 1 ? parts : [word];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
interface FittedText {
|
|
168
|
-
lines: string[];
|
|
169
|
-
fontSize: number;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function fitTextToNode(label: string, nodeWidth: number, nodeHeight: number): FittedText {
|
|
173
|
-
const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
|
|
174
|
-
const lineHeight = 1.3;
|
|
175
|
-
|
|
176
|
-
// Try at full font size first, then shrink
|
|
177
|
-
for (let fontSize = NODE_FONT_SIZE; fontSize >= MIN_NODE_FONT_SIZE; fontSize--) {
|
|
178
|
-
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
179
|
-
const maxCharsPerLine = Math.floor(maxTextWidth / charWidth);
|
|
180
|
-
const maxLines = Math.floor((nodeHeight - 8) / (fontSize * lineHeight));
|
|
181
|
-
|
|
182
|
-
if (maxCharsPerLine < 2 || maxLines < 1) continue;
|
|
183
|
-
|
|
184
|
-
// If it fits on one line, done
|
|
185
|
-
if (label.length <= maxCharsPerLine) {
|
|
186
|
-
return { lines: [label], fontSize };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Try word-wrapping
|
|
190
|
-
const words = label.split(/\s+/);
|
|
191
|
-
const lines: string[] = [];
|
|
192
|
-
let current = '';
|
|
193
|
-
|
|
194
|
-
for (const word of words) {
|
|
195
|
-
const test = current ? `${current} ${word}` : word;
|
|
196
|
-
if (test.length <= maxCharsPerLine) {
|
|
197
|
-
current = test;
|
|
198
|
-
} else {
|
|
199
|
-
if (current) lines.push(current);
|
|
200
|
-
current = word;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
if (current) lines.push(current);
|
|
204
|
-
|
|
205
|
-
// If all lines fit, check each line width
|
|
206
|
-
if (lines.length <= maxLines && lines.every((l) => l.length <= maxCharsPerLine)) {
|
|
207
|
-
return { lines, fontSize };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Try splitting long words on camelCase boundaries and re-wrapping
|
|
211
|
-
const camelWords: string[] = [];
|
|
212
|
-
for (const word of words) {
|
|
213
|
-
if (word.length > maxCharsPerLine) {
|
|
214
|
-
camelWords.push(...splitCamelCase(word));
|
|
215
|
-
} else {
|
|
216
|
-
camelWords.push(word);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const camelLines: string[] = [];
|
|
221
|
-
let camelCurrent = '';
|
|
222
|
-
for (const word of camelWords) {
|
|
223
|
-
const test = camelCurrent ? `${camelCurrent} ${word}` : word;
|
|
224
|
-
if (test.length <= maxCharsPerLine) {
|
|
225
|
-
camelCurrent = test;
|
|
226
|
-
} else {
|
|
227
|
-
if (camelCurrent) camelLines.push(camelCurrent);
|
|
228
|
-
camelCurrent = word;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (camelCurrent) camelLines.push(camelCurrent);
|
|
232
|
-
|
|
233
|
-
if (camelLines.length <= maxLines && camelLines.every((l) => l.length <= maxCharsPerLine)) {
|
|
234
|
-
return { lines: camelLines, fontSize };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// If not at minimum font size yet, try shrinking before hard-breaking
|
|
238
|
-
if (fontSize > MIN_NODE_FONT_SIZE) continue;
|
|
239
|
-
|
|
240
|
-
// At minimum font size — hard-break as last resort
|
|
241
|
-
const hardLines: string[] = [];
|
|
242
|
-
for (const line of camelLines) {
|
|
243
|
-
if (line.length <= maxCharsPerLine) {
|
|
244
|
-
hardLines.push(line);
|
|
245
|
-
} else {
|
|
246
|
-
for (let i = 0; i < line.length; i += maxCharsPerLine) {
|
|
247
|
-
hardLines.push(line.slice(i, i + maxCharsPerLine));
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (hardLines.length <= maxLines) {
|
|
253
|
-
return { lines: hardLines, fontSize };
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Last resort: smallest font, truncate with ellipsis
|
|
258
|
-
const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
259
|
-
const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
|
|
260
|
-
const truncated = label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
|
|
261
|
-
return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ============================================================
|
|
265
|
-
// Shape renderers — each draws within a centered (0,0) coordinate system
|
|
266
|
-
// ============================================================
|
|
267
|
-
|
|
268
|
-
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
269
|
-
|
|
270
|
-
/** Default rectangle */
|
|
271
|
-
function renderShapeRect(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
272
|
-
g.append('rect')
|
|
273
|
-
.attr('x', -w / 2).attr('y', -h / 2)
|
|
274
|
-
.attr('width', w).attr('height', h)
|
|
275
|
-
.attr('rx', NODE_RX).attr('ry', NODE_RX)
|
|
276
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Service — more rounded rectangle */
|
|
280
|
-
function renderShapeService(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
281
|
-
g.append('rect')
|
|
282
|
-
.attr('x', -w / 2).attr('y', -h / 2)
|
|
283
|
-
.attr('width', w).attr('height', h)
|
|
284
|
-
.attr('rx', SERVICE_RX).attr('ry', SERVICE_RX)
|
|
285
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Actor — stick figure (no fill box) */
|
|
289
|
-
function renderShapeActor(g: D3G, w: number, h: number, s: string): void {
|
|
290
|
-
// Stick figure centered in top ~70% of the box, label goes below
|
|
291
|
-
const figH = h * 0.65;
|
|
292
|
-
const topY = -h / 2;
|
|
293
|
-
const headR = Math.min(figH * 0.22, w * 0.12);
|
|
294
|
-
const headY = topY + headR + 2;
|
|
295
|
-
const bodyTopY = headY + headR + 1;
|
|
296
|
-
const bodyBottomY = topY + figH * 0.75;
|
|
297
|
-
const legY = topY + figH;
|
|
298
|
-
const armSpan = Math.min(16, w * 0.18);
|
|
299
|
-
const legSpan = Math.min(12, w * 0.14);
|
|
300
|
-
const sw = 2.5;
|
|
301
|
-
|
|
302
|
-
g.append('circle')
|
|
303
|
-
.attr('cx', 0).attr('cy', headY).attr('r', headR)
|
|
304
|
-
.attr('fill', 'none').attr('stroke', s).attr('stroke-width', sw);
|
|
305
|
-
g.append('line')
|
|
306
|
-
.attr('x1', 0).attr('y1', bodyTopY).attr('x2', 0).attr('y2', bodyBottomY)
|
|
307
|
-
.attr('stroke', s).attr('stroke-width', sw);
|
|
308
|
-
g.append('line')
|
|
309
|
-
.attr('x1', -armSpan).attr('y1', bodyTopY + 4).attr('x2', armSpan).attr('y2', bodyTopY + 4)
|
|
310
|
-
.attr('stroke', s).attr('stroke-width', sw);
|
|
311
|
-
g.append('line')
|
|
312
|
-
.attr('x1', 0).attr('y1', bodyBottomY).attr('x2', -legSpan).attr('y2', legY)
|
|
313
|
-
.attr('stroke', s).attr('stroke-width', sw);
|
|
314
|
-
g.append('line')
|
|
315
|
-
.attr('x1', 0).attr('y1', bodyBottomY).attr('x2', legSpan).attr('y2', legY)
|
|
316
|
-
.attr('stroke', s).attr('stroke-width', sw);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Database — vertical cylinder */
|
|
320
|
-
function renderShapeDatabase(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
321
|
-
const ry = 7;
|
|
322
|
-
const topY = -h / 2 + ry;
|
|
323
|
-
const bodyH = h - ry * 2;
|
|
324
|
-
|
|
325
|
-
// Bottom ellipse
|
|
326
|
-
g.append('ellipse')
|
|
327
|
-
.attr('cx', 0).attr('cy', topY + bodyH).attr('rx', w / 2).attr('ry', ry)
|
|
328
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
329
|
-
// Body (covers bottom ellipse top arc)
|
|
330
|
-
g.append('rect')
|
|
331
|
-
.attr('x', -w / 2).attr('y', topY).attr('width', w).attr('height', bodyH)
|
|
332
|
-
.attr('fill', f).attr('stroke', 'none');
|
|
333
|
-
// Side lines
|
|
334
|
-
g.append('line')
|
|
335
|
-
.attr('x1', -w / 2).attr('y1', topY).attr('x2', -w / 2).attr('y2', topY + bodyH)
|
|
336
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
337
|
-
g.append('line')
|
|
338
|
-
.attr('x1', w / 2).attr('y1', topY).attr('x2', w / 2).attr('y2', topY + bodyH)
|
|
339
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
340
|
-
// Top ellipse cap
|
|
341
|
-
g.append('ellipse')
|
|
342
|
-
.attr('cx', 0).attr('cy', topY).attr('rx', w / 2).attr('ry', ry)
|
|
343
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Queue — horizontal cylinder (pipe) */
|
|
347
|
-
function renderShapeQueue(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
348
|
-
const rx = 10;
|
|
349
|
-
const leftX = -w / 2 + rx;
|
|
350
|
-
const bodyW = w - rx * 2;
|
|
351
|
-
|
|
352
|
-
// Right ellipse (back)
|
|
353
|
-
g.append('ellipse')
|
|
354
|
-
.attr('cx', leftX + bodyW).attr('cy', 0).attr('rx', rx).attr('ry', h / 2)
|
|
355
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
356
|
-
// Body
|
|
357
|
-
g.append('rect')
|
|
358
|
-
.attr('x', leftX).attr('y', -h / 2).attr('width', bodyW).attr('height', h)
|
|
359
|
-
.attr('fill', f).attr('stroke', 'none');
|
|
360
|
-
// Top and bottom lines
|
|
361
|
-
g.append('line')
|
|
362
|
-
.attr('x1', leftX).attr('y1', -h / 2).attr('x2', leftX + bodyW).attr('y2', -h / 2)
|
|
363
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
364
|
-
g.append('line')
|
|
365
|
-
.attr('x1', leftX).attr('y1', h / 2).attr('x2', leftX + bodyW).attr('y2', h / 2)
|
|
366
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
367
|
-
// Left ellipse (front)
|
|
368
|
-
g.append('ellipse')
|
|
369
|
-
.attr('cx', leftX).attr('cy', 0).attr('rx', rx).attr('ry', h / 2)
|
|
370
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** Cache — dashed cylinder */
|
|
374
|
-
function renderShapeCache(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
375
|
-
const ry = 7;
|
|
376
|
-
const topY = -h / 2 + ry;
|
|
377
|
-
const bodyH = h - ry * 2;
|
|
378
|
-
const dash = '4 3';
|
|
379
|
-
|
|
380
|
-
g.append('ellipse')
|
|
381
|
-
.attr('cx', 0).attr('cy', topY + bodyH).attr('rx', w / 2).attr('ry', ry)
|
|
382
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
383
|
-
g.append('rect')
|
|
384
|
-
.attr('x', -w / 2).attr('y', topY).attr('width', w).attr('height', bodyH)
|
|
385
|
-
.attr('fill', f).attr('stroke', 'none');
|
|
386
|
-
g.append('line')
|
|
387
|
-
.attr('x1', -w / 2).attr('y1', topY).attr('x2', -w / 2).attr('y2', topY + bodyH)
|
|
388
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
389
|
-
g.append('line')
|
|
390
|
-
.attr('x1', w / 2).attr('y1', topY).attr('x2', w / 2).attr('y2', topY + bodyH)
|
|
391
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
392
|
-
g.append('ellipse')
|
|
393
|
-
.attr('cx', 0).attr('cy', topY).attr('rx', w / 2).attr('ry', ry)
|
|
394
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Networking — hexagon */
|
|
398
|
-
function renderShapeNetworking(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
399
|
-
const inset = 16;
|
|
400
|
-
const points = [
|
|
401
|
-
`${-w / 2 + inset},${-h / 2}`,
|
|
402
|
-
`${w / 2 - inset},${-h / 2}`,
|
|
403
|
-
`${w / 2},0`,
|
|
404
|
-
`${w / 2 - inset},${h / 2}`,
|
|
405
|
-
`${-w / 2 + inset},${h / 2}`,
|
|
406
|
-
`${-w / 2},0`,
|
|
407
|
-
].join(' ');
|
|
408
|
-
g.append('polygon')
|
|
409
|
-
.attr('points', points)
|
|
410
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Frontend — monitor with stand */
|
|
414
|
-
function renderShapeFrontend(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
415
|
-
const screenH = h - 10;
|
|
416
|
-
// Screen
|
|
417
|
-
g.append('rect')
|
|
418
|
-
.attr('x', -w / 2).attr('y', -h / 2).attr('width', w).attr('height', screenH)
|
|
419
|
-
.attr('rx', 3).attr('ry', 3)
|
|
420
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
421
|
-
// Stand
|
|
422
|
-
g.append('line')
|
|
423
|
-
.attr('x1', 0).attr('y1', -h / 2 + screenH).attr('x2', 0).attr('y2', h / 2 - 2)
|
|
424
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
425
|
-
// Base
|
|
426
|
-
g.append('line')
|
|
427
|
-
.attr('x1', -14).attr('y1', h / 2 - 2).attr('x2', 14).attr('y2', h / 2 - 2)
|
|
428
|
-
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** External — dashed rectangle */
|
|
432
|
-
function renderShapeExternal(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
433
|
-
g.append('rect')
|
|
434
|
-
.attr('x', -w / 2).attr('y', -h / 2)
|
|
435
|
-
.attr('width', w).attr('height', h)
|
|
436
|
-
.attr('rx', NODE_RX).attr('ry', NODE_RX)
|
|
437
|
-
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH)
|
|
438
|
-
.attr('stroke-dasharray', '6 3');
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** Dispatch to the right shape renderer */
|
|
442
|
-
function renderNodeShape(
|
|
443
|
-
g: D3G,
|
|
444
|
-
shape: ParticipantType,
|
|
445
|
-
w: number,
|
|
446
|
-
h: number,
|
|
447
|
-
fillColor: string,
|
|
448
|
-
strokeColor: string
|
|
449
|
-
): void {
|
|
450
|
-
switch (shape) {
|
|
451
|
-
case 'actor': renderShapeActor(g, w, h, strokeColor); break;
|
|
452
|
-
case 'database': renderShapeDatabase(g, w, h, fillColor, strokeColor); break;
|
|
453
|
-
case 'queue': renderShapeQueue(g, w, h, fillColor, strokeColor); break;
|
|
454
|
-
case 'cache': renderShapeCache(g, w, h, fillColor, strokeColor); break;
|
|
455
|
-
case 'networking': renderShapeNetworking(g, w, h, fillColor, strokeColor); break;
|
|
456
|
-
case 'frontend': renderShapeFrontend(g, w, h, fillColor, strokeColor); break;
|
|
457
|
-
case 'external': renderShapeExternal(g, w, h, fillColor, strokeColor); break;
|
|
458
|
-
case 'service': renderShapeService(g, w, h, fillColor, strokeColor); break;
|
|
459
|
-
default: renderShapeRect(g, w, h, fillColor, strokeColor); break;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// ============================================================
|
|
464
|
-
// Main renderer
|
|
465
|
-
// ============================================================
|
|
466
|
-
|
|
467
|
-
export interface ISRenderOptions {
|
|
468
|
-
onClickItem?: (lineNumber: number) => void;
|
|
469
|
-
exportDims?: { width?: number; height?: number };
|
|
470
|
-
legendActive?: boolean | null;
|
|
471
|
-
activeTagGroup?: string | null;
|
|
472
|
-
hiddenTagValues?: Map<string, Set<string>>;
|
|
473
|
-
tagGroups?: TagGroup[];
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
export function renderInitiativeStatus(
|
|
477
|
-
container: HTMLDivElement,
|
|
478
|
-
parsed: ParsedInitiativeStatus,
|
|
479
|
-
layout: ISLayoutResult,
|
|
480
|
-
palette: PaletteColors,
|
|
481
|
-
isDark: boolean,
|
|
482
|
-
options?: ISRenderOptions
|
|
483
|
-
): void {
|
|
484
|
-
const {
|
|
485
|
-
onClickItem,
|
|
486
|
-
exportDims,
|
|
487
|
-
legendActive,
|
|
488
|
-
activeTagGroup,
|
|
489
|
-
hiddenTagValues,
|
|
490
|
-
tagGroups,
|
|
491
|
-
} = options ?? {};
|
|
492
|
-
// Clear existing content
|
|
493
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
494
|
-
|
|
495
|
-
const width = exportDims?.width ?? container.clientWidth;
|
|
496
|
-
const height = exportDims?.height ?? container.clientHeight;
|
|
497
|
-
if (width <= 0 || height <= 0) return;
|
|
498
|
-
|
|
499
|
-
const legendEntries = collectStatuses(parsed);
|
|
500
|
-
const hasLegend = legendEntries.length > 1;
|
|
501
|
-
const isLegendExpanded = legendActive !== false;
|
|
502
|
-
|
|
503
|
-
const effectiveTagGroups = tagGroups ?? parsed.tagGroups ?? [];
|
|
504
|
-
const hasTagGroups = effectiveTagGroups.length > 0;
|
|
505
|
-
|
|
506
|
-
const titleHeight = parsed.title ? 40 : 0;
|
|
507
|
-
const LEGEND_FIXED_GAP = 8;
|
|
508
|
-
const legendReserve = (hasLegend || hasTagGroups) ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
|
|
509
|
-
|
|
510
|
-
// Scale to fit
|
|
511
|
-
const diagramW = layout.width;
|
|
512
|
-
const diagramH = layout.height;
|
|
513
|
-
const availH = height - titleHeight - legendReserve;
|
|
514
|
-
const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
|
|
515
|
-
const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
|
|
516
|
-
const scale = Math.min(MAX_SCALE, scaleX, scaleY);
|
|
517
|
-
|
|
518
|
-
const scaledW = diagramW * scale;
|
|
519
|
-
const offsetX = (width - scaledW) / 2;
|
|
520
|
-
const offsetY = titleHeight + legendReserve + DIAGRAM_PADDING;
|
|
521
|
-
|
|
522
|
-
// Create SVG
|
|
523
|
-
const svg = d3Selection
|
|
524
|
-
.select(container)
|
|
525
|
-
.append('svg')
|
|
526
|
-
.attr('width', width)
|
|
527
|
-
.attr('height', height)
|
|
528
|
-
.style('font-family', FONT_FAMILY);
|
|
529
|
-
|
|
530
|
-
// Defs: arrowhead markers per status color
|
|
531
|
-
const defs = svg.append('defs');
|
|
532
|
-
const markerColors = new Set<string>();
|
|
533
|
-
for (const edge of layout.edges) {
|
|
534
|
-
markerColors.add(edgeStrokeColor(edge.status, palette, isDark));
|
|
535
|
-
}
|
|
536
|
-
// Default marker
|
|
537
|
-
markerColors.add(palette.textMuted);
|
|
538
|
-
|
|
539
|
-
for (const color of markerColors) {
|
|
540
|
-
const id = `is-arrow-${color.replace('#', '')}`;
|
|
541
|
-
defs
|
|
542
|
-
.append('marker')
|
|
543
|
-
.attr('id', id)
|
|
544
|
-
.attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
|
|
545
|
-
.attr('refX', ARROWHEAD_W)
|
|
546
|
-
.attr('refY', ARROWHEAD_H / 2)
|
|
547
|
-
.attr('markerWidth', ARROWHEAD_W)
|
|
548
|
-
.attr('markerHeight', ARROWHEAD_H)
|
|
549
|
-
.attr('orient', 'auto')
|
|
550
|
-
.append('polygon')
|
|
551
|
-
.attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
|
|
552
|
-
.attr('fill', color);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Title
|
|
556
|
-
if (parsed.title) {
|
|
557
|
-
const titleEl = svg
|
|
558
|
-
.append('text')
|
|
559
|
-
.attr('class', 'chart-title')
|
|
560
|
-
.attr('x', width / 2)
|
|
561
|
-
.attr('y', TITLE_Y)
|
|
562
|
-
.attr('text-anchor', 'middle')
|
|
563
|
-
.attr('fill', palette.text)
|
|
564
|
-
.attr('font-size', TITLE_FONT_SIZE)
|
|
565
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
566
|
-
.style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
|
|
567
|
-
.text(parsed.title);
|
|
568
|
-
|
|
569
|
-
if (parsed.titleLineNumber) {
|
|
570
|
-
titleEl.attr('data-line-number', parsed.titleLineNumber);
|
|
571
|
-
if (onClickItem) {
|
|
572
|
-
titleEl
|
|
573
|
-
.on('click', () => onClickItem(parsed.titleLineNumber!))
|
|
574
|
-
.on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
|
|
575
|
-
.on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// ── Legend ──
|
|
581
|
-
if (hasLegend || hasTagGroups) {
|
|
582
|
-
const groupBg = isDark
|
|
583
|
-
? mix(palette.surface, palette.bg, 50)
|
|
584
|
-
: mix(palette.surface, palette.bg, 30);
|
|
585
|
-
|
|
586
|
-
// Build legend groups: Status + tag groups
|
|
587
|
-
interface LegendGroup {
|
|
588
|
-
name: string;
|
|
589
|
-
key: string; // lowercase key for data attribute
|
|
590
|
-
isStatus: boolean;
|
|
591
|
-
entries: { label: string; color: string; value: string }[];
|
|
592
|
-
width: number; // total width when expanded
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const legendGroups: LegendGroup[] = [];
|
|
596
|
-
|
|
597
|
-
// Status group (always first if entries exist)
|
|
598
|
-
if (hasLegend) {
|
|
599
|
-
const statusEntries = legendEntries.map((e) => ({
|
|
600
|
-
label: e.label,
|
|
601
|
-
color: statusColor(e.statusKey, palette, isDark),
|
|
602
|
-
value: e.statusKey ?? 'na',
|
|
603
|
-
}));
|
|
604
|
-
const pillW = measureLegendText(LEGEND_GROUP_NAME, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
605
|
-
const entrW = legendEntriesWidth(legendEntries);
|
|
606
|
-
legendGroups.push({
|
|
607
|
-
name: LEGEND_GROUP_NAME,
|
|
608
|
-
key: 'status',
|
|
609
|
-
isStatus: true,
|
|
610
|
-
entries: statusEntries,
|
|
611
|
-
width: LEGEND_CAPSULE_PAD * 2 + pillW + LEGEND_ENTRY_TRAIL + entrW,
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Tag groups
|
|
616
|
-
for (const tg of effectiveTagGroups) {
|
|
617
|
-
const entries = tg.entries.map((e) => ({
|
|
618
|
-
label: e.value,
|
|
619
|
-
color: e.color || palette.textMuted,
|
|
620
|
-
value: e.value.toLowerCase(),
|
|
621
|
-
}));
|
|
622
|
-
const pillW = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
623
|
-
let entrW = 0;
|
|
624
|
-
for (const e of entries) {
|
|
625
|
-
entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
626
|
-
}
|
|
627
|
-
legendGroups.push({
|
|
628
|
-
name: tg.name,
|
|
629
|
-
key: tg.name.toLowerCase(),
|
|
630
|
-
isStatus: false,
|
|
631
|
-
entries,
|
|
632
|
-
width: LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entrW,
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Determine which group is active/expanded
|
|
637
|
-
const activeKey = activeTagGroup?.toLowerCase() ?? null;
|
|
638
|
-
const isStatusExpanded = isLegendExpanded && activeKey === null;
|
|
639
|
-
|
|
640
|
-
// When a tag group is active, only show that group (mutual exclusion).
|
|
641
|
-
// When no tag group is active, show all pills (Status expanded + tag pills minified).
|
|
642
|
-
const visibleLegendGroups = activeKey !== null
|
|
643
|
-
? legendGroups.filter((lg) => !lg.isStatus && lg.key === activeKey)
|
|
644
|
-
: legendGroups;
|
|
645
|
-
|
|
646
|
-
// Compute total legend width
|
|
647
|
-
let totalLegendW = 0;
|
|
648
|
-
for (const lg of visibleLegendGroups) {
|
|
649
|
-
const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
|
|
650
|
-
const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
651
|
-
totalLegendW += isActive ? lg.width : pillW;
|
|
652
|
-
totalLegendW += LEGEND_GROUP_GAP;
|
|
653
|
-
}
|
|
654
|
-
totalLegendW -= LEGEND_GROUP_GAP; // remove trailing gap
|
|
655
|
-
|
|
656
|
-
const legendX = (width - totalLegendW) / 2;
|
|
657
|
-
const legendY = titleHeight;
|
|
658
|
-
|
|
659
|
-
const legendRow = svg
|
|
660
|
-
.append('g')
|
|
661
|
-
.attr('class', 'is-legend-row')
|
|
662
|
-
.attr('transform', `translate(${legendX}, ${legendY})`);
|
|
663
|
-
|
|
664
|
-
let cursorX = 0;
|
|
665
|
-
|
|
666
|
-
for (const lg of visibleLegendGroups) {
|
|
667
|
-
const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
|
|
668
|
-
const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
669
|
-
const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
670
|
-
const groupW = isActive ? lg.width : pillW;
|
|
671
|
-
|
|
672
|
-
const gEl = legendRow
|
|
673
|
-
.append('g')
|
|
674
|
-
.attr('transform', `translate(${cursorX}, 0)`)
|
|
675
|
-
.attr('class', 'is-legend-group')
|
|
676
|
-
.attr('data-legend-group', lg.key)
|
|
677
|
-
.style('cursor', 'pointer');
|
|
678
|
-
|
|
679
|
-
// Mark inactive pills so exports can hide them
|
|
680
|
-
if (!isActive) {
|
|
681
|
-
gEl.attr('data-export-ignore', 'true');
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
if (isActive) {
|
|
685
|
-
// Outer capsule background
|
|
686
|
-
gEl.append('rect')
|
|
687
|
-
.attr('width', groupW)
|
|
688
|
-
.attr('height', LEGEND_HEIGHT)
|
|
689
|
-
.attr('rx', LEGEND_HEIGHT / 2)
|
|
690
|
-
.attr('fill', groupBg);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
694
|
-
const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
695
|
-
|
|
696
|
-
// Pill background
|
|
697
|
-
gEl.append('rect')
|
|
698
|
-
.attr('x', pillXOff)
|
|
699
|
-
.attr('y', pillYOff)
|
|
700
|
-
.attr('width', pillW)
|
|
701
|
-
.attr('height', pillH)
|
|
702
|
-
.attr('rx', pillH / 2)
|
|
703
|
-
.attr('fill', isActive ? palette.bg : groupBg);
|
|
704
|
-
|
|
705
|
-
// Active pill border
|
|
706
|
-
if (isActive) {
|
|
707
|
-
gEl.append('rect')
|
|
708
|
-
.attr('x', pillXOff)
|
|
709
|
-
.attr('y', pillYOff)
|
|
710
|
-
.attr('width', pillW)
|
|
711
|
-
.attr('height', pillH)
|
|
712
|
-
.attr('rx', pillH / 2)
|
|
713
|
-
.attr('fill', 'none')
|
|
714
|
-
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
715
|
-
.attr('stroke-width', 0.75);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// Pill text
|
|
719
|
-
gEl.append('text')
|
|
720
|
-
.attr('x', pillXOff + pillW / 2)
|
|
721
|
-
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
722
|
-
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
723
|
-
.attr('font-weight', '500')
|
|
724
|
-
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
725
|
-
.attr('text-anchor', 'middle')
|
|
726
|
-
.attr('font-family', FONT_FAMILY)
|
|
727
|
-
.text(lg.name);
|
|
728
|
-
|
|
729
|
-
// Entries inside capsule (active only)
|
|
730
|
-
if (isActive) {
|
|
731
|
-
// Determine which values are hidden for this group
|
|
732
|
-
const hiddenSet = !lg.isStatus ? hiddenTagValues?.get(lg.key) : undefined;
|
|
733
|
-
|
|
734
|
-
// Render each entry in its own <g> with local coordinates,
|
|
735
|
-
// positioned via transform so we can reflow after measuring.
|
|
736
|
-
const entryStartX = pillXOff + pillW + 4;
|
|
737
|
-
const entryData: { g: d3Selection.Selection<SVGGElement, unknown, null, undefined>; textEl: SVGTextElement; estimatedW: number }[] = [];
|
|
738
|
-
let estimatedX = entryStartX;
|
|
739
|
-
|
|
740
|
-
for (const entry of lg.entries) {
|
|
741
|
-
const isHidden = hiddenSet?.has(entry.value) ?? false;
|
|
742
|
-
const estimatedTextW = measureLegendText(entry.label, LEGEND_ENTRY_FONT_SIZE);
|
|
743
|
-
|
|
744
|
-
const entryG = gEl.append('g')
|
|
745
|
-
.attr('data-legend-entry', entry.value)
|
|
746
|
-
.attr('transform', `translate(${estimatedX}, 0)`)
|
|
747
|
-
.style('cursor', 'pointer');
|
|
748
|
-
|
|
749
|
-
// Transparent hit-area rect
|
|
750
|
-
const entryW = LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + estimatedTextW + LEGEND_ENTRY_TRAIL;
|
|
751
|
-
entryG.append('rect')
|
|
752
|
-
.attr('x', -2)
|
|
753
|
-
.attr('y', 0)
|
|
754
|
-
.attr('width', entryW + 4)
|
|
755
|
-
.attr('height', LEGEND_HEIGHT)
|
|
756
|
-
.attr('fill', 'transparent');
|
|
757
|
-
|
|
758
|
-
if (isHidden) {
|
|
759
|
-
entryG.append('circle')
|
|
760
|
-
.attr('cx', LEGEND_DOT_R)
|
|
761
|
-
.attr('cy', LEGEND_HEIGHT / 2)
|
|
762
|
-
.attr('r', LEGEND_DOT_R)
|
|
763
|
-
.attr('fill', 'none')
|
|
764
|
-
.attr('stroke', entry.color)
|
|
765
|
-
.attr('stroke-width', 1.2)
|
|
766
|
-
.attr('opacity', 0.5);
|
|
767
|
-
} else {
|
|
768
|
-
entryG.append('circle')
|
|
769
|
-
.attr('cx', LEGEND_DOT_R)
|
|
770
|
-
.attr('cy', LEGEND_HEIGHT / 2)
|
|
771
|
-
.attr('r', LEGEND_DOT_R)
|
|
772
|
-
.attr('fill', entry.color);
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const textEl = entryG.append('text')
|
|
776
|
-
.attr('x', LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
|
|
777
|
-
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
778
|
-
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
779
|
-
.attr('fill', palette.textMuted)
|
|
780
|
-
.attr('font-family', FONT_FAMILY)
|
|
781
|
-
.attr('opacity', isHidden ? 0.4 : 1)
|
|
782
|
-
.attr('text-decoration', isHidden ? 'line-through' : 'none')
|
|
783
|
-
.text(entry.label);
|
|
784
|
-
|
|
785
|
-
entryData.push({ g: entryG, textEl: textEl.node()!, estimatedW: estimatedTextW });
|
|
786
|
-
estimatedX += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + estimatedTextW + LEGEND_ENTRY_TRAIL;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Reflow using measured text widths for even spacing
|
|
790
|
-
let reflowX = entryStartX;
|
|
791
|
-
for (const ed of entryData) {
|
|
792
|
-
const measuredW = ed.textEl.getComputedTextLength?.() ?? 0;
|
|
793
|
-
const textW = measuredW > 0 ? measuredW : ed.estimatedW;
|
|
794
|
-
ed.g.attr('transform', `translate(${reflowX}, 0)`);
|
|
795
|
-
// Update hit-area rect width to match actual width
|
|
796
|
-
const actualEntryW = LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + textW + LEGEND_ENTRY_TRAIL;
|
|
797
|
-
ed.g.select('rect').attr('width', actualEntryW + 4);
|
|
798
|
-
reflowX += actualEntryW;
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
cursorX += groupW + LEGEND_GROUP_GAP;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// Content group
|
|
808
|
-
const contentG = svg
|
|
809
|
-
.append('g')
|
|
810
|
-
.attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
|
|
811
|
-
|
|
812
|
-
// Helper: interpolate a point at parameter t (0–1) along a polyline
|
|
813
|
-
function interpolatePolyline(
|
|
814
|
-
pts: { x: number; y: number }[],
|
|
815
|
-
t: number
|
|
816
|
-
): { x: number; y: number } {
|
|
817
|
-
if (pts.length < 2) return pts[0];
|
|
818
|
-
// Compute cumulative segment lengths
|
|
819
|
-
const segLens: number[] = [];
|
|
820
|
-
let total = 0;
|
|
821
|
-
for (let i = 1; i < pts.length; i++) {
|
|
822
|
-
const dx = pts[i].x - pts[i - 1].x;
|
|
823
|
-
const dy = pts[i].y - pts[i - 1].y;
|
|
824
|
-
const d = Math.sqrt(dx * dx + dy * dy);
|
|
825
|
-
segLens.push(d);
|
|
826
|
-
total += d;
|
|
827
|
-
}
|
|
828
|
-
const target = t * total;
|
|
829
|
-
let accum = 0;
|
|
830
|
-
for (let i = 0; i < segLens.length; i++) {
|
|
831
|
-
if (accum + segLens[i] >= target) {
|
|
832
|
-
const frac = segLens[i] > 0 ? (target - accum) / segLens[i] : 0;
|
|
833
|
-
return {
|
|
834
|
-
x: pts[i].x + (pts[i + 1].x - pts[i].x) * frac,
|
|
835
|
-
y: pts[i].y + (pts[i + 1].y - pts[i].y) * frac,
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
accum += segLens[i];
|
|
839
|
-
}
|
|
840
|
-
return pts[pts.length - 1];
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Compute label positions — place each label ON its own edge path.
|
|
844
|
-
// Start at t=0.5 (midpoint). If two labels overlap, slide them apart
|
|
845
|
-
// along their respective paths.
|
|
846
|
-
interface LabelPlacement {
|
|
847
|
-
x: number;
|
|
848
|
-
y: number;
|
|
849
|
-
w: number;
|
|
850
|
-
h: number;
|
|
851
|
-
edgeIdx: number;
|
|
852
|
-
t: number; // parameter along path
|
|
853
|
-
points: { x: number; y: number }[];
|
|
854
|
-
}
|
|
855
|
-
const labelPlacements: LabelPlacement[] = [];
|
|
856
|
-
|
|
857
|
-
for (let ei = 0; ei < layout.edges.length; ei++) {
|
|
858
|
-
const edge = layout.edges[ei];
|
|
859
|
-
if (!edge.label || edge.points.length < 2) continue;
|
|
860
|
-
|
|
861
|
-
const t = 0.5;
|
|
862
|
-
const pt = interpolatePolyline(edge.points, t);
|
|
863
|
-
const labelLen = edge.label.length;
|
|
864
|
-
const bgW = labelLen * 7 + 10;
|
|
865
|
-
const bgH = 18;
|
|
866
|
-
|
|
867
|
-
labelPlacements.push({
|
|
868
|
-
x: pt.x,
|
|
869
|
-
y: pt.y,
|
|
870
|
-
w: bgW,
|
|
871
|
-
h: bgH,
|
|
872
|
-
edgeIdx: ei,
|
|
873
|
-
t,
|
|
874
|
-
points: edge.points,
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// Resolve overlaps by sliding labels along their own paths
|
|
879
|
-
const MIN_LABEL_GAP = 6;
|
|
880
|
-
for (let pass = 0; pass < 8; pass++) {
|
|
881
|
-
let moved = false;
|
|
882
|
-
for (let i = 0; i < labelPlacements.length; i++) {
|
|
883
|
-
for (let j = i + 1; j < labelPlacements.length; j++) {
|
|
884
|
-
const a = labelPlacements[i];
|
|
885
|
-
const b = labelPlacements[j];
|
|
886
|
-
const overlapX = Math.abs(a.x - b.x) < (a.w + b.w) / 2 + MIN_LABEL_GAP;
|
|
887
|
-
const overlapY = Math.abs(a.y - b.y) < (a.h + b.h) / 2 + MIN_LABEL_GAP;
|
|
888
|
-
if (overlapX && overlapY) {
|
|
889
|
-
// Slide each label along its own path in opposite directions
|
|
890
|
-
const step = 0.08;
|
|
891
|
-
a.t = Math.max(0.15, a.t - step);
|
|
892
|
-
b.t = Math.min(0.85, b.t + step);
|
|
893
|
-
const ptA = interpolatePolyline(a.points, a.t);
|
|
894
|
-
const ptB = interpolatePolyline(b.points, b.t);
|
|
895
|
-
a.x = ptA.x;
|
|
896
|
-
a.y = ptA.y;
|
|
897
|
-
b.x = ptB.x;
|
|
898
|
-
b.y = ptB.y;
|
|
899
|
-
moved = true;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
if (!moved) break;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
// Build lookup from edge index to label placement
|
|
907
|
-
const labelMap = new Map<number, LabelPlacement>();
|
|
908
|
-
for (const lp of labelPlacements) labelMap.set(lp.edgeIdx, lp);
|
|
909
|
-
|
|
910
|
-
// Render groups (background layer, before edges and nodes)
|
|
911
|
-
for (const group of layout.groups) {
|
|
912
|
-
if (group.collapsed) {
|
|
913
|
-
// ── Collapsed: node-like box (same fill/stroke as nodes) + drill-bar ──
|
|
914
|
-
const fillCol = nodeFill(group.status, palette, isDark);
|
|
915
|
-
const strokeCol = nodeStroke(group.status, palette, isDark);
|
|
916
|
-
const textCol = nodeTextColor(group.status, palette, isDark);
|
|
917
|
-
const clipId = `clip-group-${group.lineNumber}`;
|
|
918
|
-
|
|
919
|
-
const groupG = contentG
|
|
920
|
-
.append('g')
|
|
921
|
-
.attr('class', 'is-group is-group-collapsed')
|
|
922
|
-
.attr('data-line-number', String(group.lineNumber))
|
|
923
|
-
.attr('data-group-toggle', group.label)
|
|
924
|
-
.style('cursor', 'pointer');
|
|
925
|
-
|
|
926
|
-
// Clip path for drill-bar rounded corners
|
|
927
|
-
groupG.append('clipPath').attr('id', clipId)
|
|
928
|
-
.append('rect')
|
|
929
|
-
.attr('x', group.x).attr('y', group.y)
|
|
930
|
-
.attr('width', group.width).attr('height', group.height)
|
|
931
|
-
.attr('rx', NODE_RX);
|
|
932
|
-
|
|
933
|
-
// Main box
|
|
934
|
-
groupG.append('rect')
|
|
935
|
-
.attr('x', group.x).attr('y', group.y)
|
|
936
|
-
.attr('width', group.width).attr('height', group.height)
|
|
937
|
-
.attr('rx', NODE_RX)
|
|
938
|
-
.attr('fill', fillCol)
|
|
939
|
-
.attr('stroke', strokeCol)
|
|
940
|
-
.attr('stroke-width', NODE_STROKE_WIDTH);
|
|
941
|
-
|
|
942
|
-
// Drill-bar (6px bottom stripe, clipped to rounded corners)
|
|
943
|
-
groupG.append('rect')
|
|
944
|
-
.attr('x', group.x)
|
|
945
|
-
.attr('y', group.y + group.height - COLLAPSE_BAR_HEIGHT)
|
|
946
|
-
.attr('width', group.width)
|
|
947
|
-
.attr('height', COLLAPSE_BAR_HEIGHT)
|
|
948
|
-
.attr('fill', strokeCol)
|
|
949
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
950
|
-
.attr('class', 'is-collapse-bar');
|
|
951
|
-
|
|
952
|
-
// Label centered (above drill-bar)
|
|
953
|
-
groupG.append('text')
|
|
954
|
-
.attr('x', group.x + group.width / 2)
|
|
955
|
-
.attr('y', group.y + group.height / 2 - COLLAPSE_BAR_HEIGHT / 2)
|
|
956
|
-
.attr('text-anchor', 'middle')
|
|
957
|
-
.attr('dominant-baseline', 'central')
|
|
958
|
-
.attr('fill', textCol)
|
|
959
|
-
.attr('font-size', NODE_FONT_SIZE)
|
|
960
|
-
.attr('font-weight', 'bold')
|
|
961
|
-
.attr('font-family', FONT_FAMILY)
|
|
962
|
-
.text(group.label);
|
|
963
|
-
|
|
964
|
-
} else {
|
|
965
|
-
// ── Expanded: neutral background (no status color bleed) ──
|
|
966
|
-
if (group.width === 0 && group.height === 0) continue;
|
|
967
|
-
const gx = group.x - GROUP_EXTRA_PADDING;
|
|
968
|
-
const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
|
|
969
|
-
const gw = group.width + GROUP_EXTRA_PADDING * 2;
|
|
970
|
-
const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
|
|
971
|
-
|
|
972
|
-
const fillColor = isDark ? palette.surface : palette.bg;
|
|
973
|
-
const strokeColor = palette.textMuted;
|
|
974
|
-
|
|
975
|
-
const groupG = contentG
|
|
976
|
-
.append('g')
|
|
977
|
-
.attr('class', 'is-group')
|
|
978
|
-
.attr('data-line-number', String(group.lineNumber))
|
|
979
|
-
.attr('data-group-toggle', group.label)
|
|
980
|
-
.style('cursor', 'pointer');
|
|
981
|
-
|
|
982
|
-
groupG
|
|
983
|
-
.append('rect')
|
|
984
|
-
.attr('x', gx)
|
|
985
|
-
.attr('y', gy)
|
|
986
|
-
.attr('width', gw)
|
|
987
|
-
.attr('height', gh)
|
|
988
|
-
.attr('rx', 6)
|
|
989
|
-
.attr('fill', fillColor)
|
|
990
|
-
.attr('stroke', strokeColor)
|
|
991
|
-
.attr('stroke-opacity', 0.5);
|
|
992
|
-
|
|
993
|
-
groupG
|
|
994
|
-
.append('text')
|
|
995
|
-
.attr('x', gx + 8)
|
|
996
|
-
.attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
|
|
997
|
-
.attr('fill', strokeColor)
|
|
998
|
-
.attr('font-size', GROUP_LABEL_FONT_SIZE)
|
|
999
|
-
.attr('font-weight', 'bold')
|
|
1000
|
-
.attr('opacity', 0.7)
|
|
1001
|
-
.attr('class', 'is-group-label')
|
|
1002
|
-
.text(group.label);
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// Render edges (below nodes)
|
|
1008
|
-
for (let ei = 0; ei < layout.edges.length; ei++) {
|
|
1009
|
-
const edge = layout.edges[ei];
|
|
1010
|
-
if (edge.points.length < 2) continue;
|
|
1011
|
-
const edgeColor = edgeStrokeColor(edge.status, palette, isDark);
|
|
1012
|
-
const markerId = `is-arrow-${edgeColor.replace('#', '')}`;
|
|
1013
|
-
|
|
1014
|
-
const edgeG = contentG
|
|
1015
|
-
.append('g')
|
|
1016
|
-
.attr('class', 'is-edge-group')
|
|
1017
|
-
.attr('data-line-number', String(edge.lineNumber));
|
|
1018
|
-
|
|
1019
|
-
const pathD = lineGenerator(edge.points);
|
|
1020
|
-
if (pathD) {
|
|
1021
|
-
// Transparent wide hit area behind the visible edge
|
|
1022
|
-
edgeG
|
|
1023
|
-
.append('path')
|
|
1024
|
-
.attr('d', pathD)
|
|
1025
|
-
.attr('fill', 'none')
|
|
1026
|
-
.attr('stroke', 'transparent')
|
|
1027
|
-
.attr('stroke-width', Math.max(6, Math.round(16 / (edge.parallelCount ?? 1))));
|
|
1028
|
-
|
|
1029
|
-
const edgePath = edgeG
|
|
1030
|
-
.append('path')
|
|
1031
|
-
.attr('d', pathD)
|
|
1032
|
-
.attr('fill', 'none')
|
|
1033
|
-
.attr('stroke', edgeColor)
|
|
1034
|
-
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
1035
|
-
.attr('marker-end', `url(#${markerId})`)
|
|
1036
|
-
.attr('class', 'is-edge');
|
|
1037
|
-
|
|
1038
|
-
// Dashed stroke for 'todo' edges
|
|
1039
|
-
if (edge.status === 'todo') {
|
|
1040
|
-
edgePath.attr('stroke-dasharray', '6 3');
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// Edge label placed on its own path
|
|
1045
|
-
const lp = labelMap.get(ei);
|
|
1046
|
-
if (edge.label && lp) {
|
|
1047
|
-
edgeG
|
|
1048
|
-
.append('rect')
|
|
1049
|
-
.attr('x', lp.x - lp.w / 2)
|
|
1050
|
-
.attr('y', lp.y - lp.h / 2 - 1)
|
|
1051
|
-
.attr('width', lp.w)
|
|
1052
|
-
.attr('height', lp.h)
|
|
1053
|
-
.attr('rx', 3)
|
|
1054
|
-
.attr('fill', palette.bg)
|
|
1055
|
-
.attr('opacity', 0.9)
|
|
1056
|
-
.attr('class', 'is-edge-label-bg');
|
|
1057
|
-
|
|
1058
|
-
edgeG
|
|
1059
|
-
.append('text')
|
|
1060
|
-
.attr('x', lp.x)
|
|
1061
|
-
.attr('y', lp.y + 4)
|
|
1062
|
-
.attr('text-anchor', 'middle')
|
|
1063
|
-
.attr('fill', edgeColor)
|
|
1064
|
-
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|
|
1065
|
-
.attr('class', 'is-edge-label')
|
|
1066
|
-
.text(edge.label);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
if (onClickItem) {
|
|
1070
|
-
edgeG.style('cursor', 'pointer').on('click', () => {
|
|
1071
|
-
onClickItem(edge.lineNumber);
|
|
1072
|
-
});
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
// Render nodes (top layer)
|
|
1077
|
-
for (const node of layout.nodes) {
|
|
1078
|
-
const nodeG = contentG
|
|
1079
|
-
.append('g')
|
|
1080
|
-
.attr('transform', `translate(${node.x}, ${node.y})`)
|
|
1081
|
-
.attr('class', 'is-node')
|
|
1082
|
-
.attr('data-line-number', String(node.lineNumber))
|
|
1083
|
-
.attr('data-is-status', node.status ?? 'na');
|
|
1084
|
-
|
|
1085
|
-
// Tag data attributes for hover dimming
|
|
1086
|
-
if (node.metadata) {
|
|
1087
|
-
for (const [key, val] of Object.entries(node.metadata)) {
|
|
1088
|
-
nodeG.attr(`data-tag-${key}`, val.toLowerCase());
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
if (onClickItem) {
|
|
1093
|
-
nodeG.style('cursor', 'pointer').on('click', () => {
|
|
1094
|
-
onClickItem(node.lineNumber);
|
|
1095
|
-
});
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// Transparent hit-area rect — ensures the full bounding box captures
|
|
1099
|
-
// clicks for shapes with gaps (actors, frontends, databases, etc.)
|
|
1100
|
-
nodeG
|
|
1101
|
-
.append('rect')
|
|
1102
|
-
.attr('x', -node.width / 2)
|
|
1103
|
-
.attr('y', -node.height / 2)
|
|
1104
|
-
.attr('width', node.width)
|
|
1105
|
-
.attr('height', node.height)
|
|
1106
|
-
.attr('fill', 'transparent')
|
|
1107
|
-
.attr('class', 'is-node-hit-area');
|
|
1108
|
-
|
|
1109
|
-
// Always use status coloring regardless of legend state
|
|
1110
|
-
const fill = nodeFill(node.status, palette, isDark);
|
|
1111
|
-
const stroke = nodeStroke(node.status, palette, isDark);
|
|
1112
|
-
renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
|
|
1113
|
-
|
|
1114
|
-
// Apply dashed border for 'todo' status
|
|
1115
|
-
if (node.status === 'todo') {
|
|
1116
|
-
nodeG.selectAll('rect, ellipse, polygon, circle')
|
|
1117
|
-
.each(function () {
|
|
1118
|
-
const el = d3Selection.select(this);
|
|
1119
|
-
// Only dash stroked elements (not fills or transparent hit areas)
|
|
1120
|
-
if (el.attr('stroke') && el.attr('stroke') !== 'none' && el.attr('stroke') !== 'transparent') {
|
|
1121
|
-
el.attr('stroke-dasharray', '6 3');
|
|
1122
|
-
}
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
const textColor = contrastText(fill, '#eceff4', '#2e3440');
|
|
1127
|
-
|
|
1128
|
-
// Label placement: actors put label below the figure, others center inside
|
|
1129
|
-
const isActor = node.shape === 'actor';
|
|
1130
|
-
if (isActor) {
|
|
1131
|
-
const fitted = fitTextToNode(node.label, node.width, node.height * 0.35);
|
|
1132
|
-
const labelY = node.height / 2 - fitted.fontSize * 0.3;
|
|
1133
|
-
for (let li = 0; li < fitted.lines.length; li++) {
|
|
1134
|
-
nodeG
|
|
1135
|
-
.append('text')
|
|
1136
|
-
.attr('x', 0)
|
|
1137
|
-
.attr('y', labelY + li * fitted.fontSize * 1.3)
|
|
1138
|
-
.attr('text-anchor', 'middle')
|
|
1139
|
-
.attr('dominant-baseline', 'central')
|
|
1140
|
-
.attr('fill', textColor)
|
|
1141
|
-
.attr('font-size', fitted.fontSize)
|
|
1142
|
-
.attr('font-weight', '600')
|
|
1143
|
-
.text(fitted.lines[li]);
|
|
1144
|
-
}
|
|
1145
|
-
} else {
|
|
1146
|
-
const fitted = fitTextToNode(node.label, node.width, node.height);
|
|
1147
|
-
const totalTextHeight = fitted.lines.length * fitted.fontSize * 1.3;
|
|
1148
|
-
const startY = -totalTextHeight / 2 + fitted.fontSize * 0.65;
|
|
1149
|
-
|
|
1150
|
-
for (let li = 0; li < fitted.lines.length; li++) {
|
|
1151
|
-
nodeG
|
|
1152
|
-
.append('text')
|
|
1153
|
-
.attr('x', 0)
|
|
1154
|
-
.attr('y', startY + li * fitted.fontSize * 1.3)
|
|
1155
|
-
.attr('text-anchor', 'middle')
|
|
1156
|
-
.attr('dominant-baseline', 'central')
|
|
1157
|
-
.attr('fill', textColor)
|
|
1158
|
-
.attr('font-size', fitted.fontSize)
|
|
1159
|
-
.attr('font-weight', '600')
|
|
1160
|
-
.text(fitted.lines[li]);
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// ============================================================
|
|
1167
|
-
// Export convenience function
|
|
1168
|
-
// ============================================================
|
|
1169
|
-
|
|
1170
|
-
export function renderInitiativeStatusForExport(
|
|
1171
|
-
content: string,
|
|
1172
|
-
theme: 'light' | 'dark' | 'transparent',
|
|
1173
|
-
palette: PaletteColors
|
|
1174
|
-
): string {
|
|
1175
|
-
const parsed = parseInitiativeStatus(content);
|
|
1176
|
-
if (parsed.error || parsed.nodes.length === 0) return '';
|
|
1177
|
-
|
|
1178
|
-
const layout = layoutInitiativeStatus(parsed);
|
|
1179
|
-
const isDark = theme === 'dark';
|
|
1180
|
-
|
|
1181
|
-
const legendEntries = collectStatuses(parsed);
|
|
1182
|
-
const EXPORT_LEGEND_GAP = 8;
|
|
1183
|
-
const legendReserve = legendEntries.length > 1 ? LEGEND_HEIGHT + EXPORT_LEGEND_GAP : 0;
|
|
1184
|
-
const titleOffset = parsed.title ? 40 : 0;
|
|
1185
|
-
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
1186
|
-
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset + legendReserve;
|
|
1187
|
-
|
|
1188
|
-
return runInExportContainer(exportWidth, exportHeight, (container) => {
|
|
1189
|
-
renderInitiativeStatus(
|
|
1190
|
-
container,
|
|
1191
|
-
parsed,
|
|
1192
|
-
layout,
|
|
1193
|
-
palette,
|
|
1194
|
-
isDark,
|
|
1195
|
-
{ exportDims: { width: exportWidth, height: exportHeight } }
|
|
1196
|
-
);
|
|
1197
|
-
return extractExportSvg(container, theme);
|
|
1198
|
-
});
|
|
1199
|
-
}
|