@diagrammo/dgmo 0.2.22 → 0.2.24
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/cli.cjs +119 -113
- package/dist/index.cjs +6311 -2344
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +263 -2
- package/dist/index.d.ts +263 -2
- package/dist/index.js +6269 -2320
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/c4/layout.ts +2137 -0
- package/src/c4/parser.ts +809 -0
- package/src/c4/renderer.ts +1916 -0
- package/src/c4/types.ts +86 -0
- package/src/class/renderer.ts +2 -2
- package/src/cli.ts +54 -10
- package/src/d3.ts +148 -10
- package/src/dgmo-router.ts +13 -0
- package/src/er/renderer.ts +2 -2
- package/src/graph/flowchart-renderer.ts +1 -1
- package/src/index.ts +54 -0
- package/src/initiative-status/layout.ts +217 -0
- package/src/initiative-status/parser.ts +246 -0
- package/src/initiative-status/renderer.ts +837 -0
- package/src/initiative-status/types.ts +43 -0
- package/src/kanban/renderer.ts +20 -2
- package/src/org/layout.ts +34 -16
- package/src/org/renderer.ts +31 -10
- package/src/org/resolver.ts +3 -1
- package/src/render.ts +9 -1
- package/src/sequence/participant-inference.ts +1 -0
- package/src/sequence/renderer.ts +12 -6
|
@@ -0,0 +1,837 @@
|
|
|
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 { contrastText } from '../palettes/color-utils';
|
|
9
|
+
import type { PaletteColors } from '../palettes';
|
|
10
|
+
import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
|
|
11
|
+
import type { ParticipantType } from '../sequence/parser';
|
|
12
|
+
import type { ISLayoutResult, ISLayoutNode, ISLayoutEdge, ISLayoutGroup } from './layout';
|
|
13
|
+
import { parseInitiativeStatus } from './parser';
|
|
14
|
+
import { layoutInitiativeStatus } from './layout';
|
|
15
|
+
|
|
16
|
+
// ============================================================
|
|
17
|
+
// Constants
|
|
18
|
+
// ============================================================
|
|
19
|
+
|
|
20
|
+
const DIAGRAM_PADDING = 20;
|
|
21
|
+
const MAX_SCALE = 3;
|
|
22
|
+
const NODE_FONT_SIZE = 13;
|
|
23
|
+
const MIN_NODE_FONT_SIZE = 9;
|
|
24
|
+
const EDGE_LABEL_FONT_SIZE = 11;
|
|
25
|
+
const EDGE_STROKE_WIDTH = 2;
|
|
26
|
+
const NODE_STROKE_WIDTH = 2;
|
|
27
|
+
const NODE_RX = 8;
|
|
28
|
+
const ARROWHEAD_W = 10;
|
|
29
|
+
const ARROWHEAD_H = 7;
|
|
30
|
+
const CHAR_WIDTH_RATIO = 0.6; // approx char width / font size for Helvetica
|
|
31
|
+
const NODE_TEXT_PADDING = 12; // horizontal padding inside node for text
|
|
32
|
+
const SERVICE_RX = 10;
|
|
33
|
+
const GROUP_EXTRA_PADDING = 8;
|
|
34
|
+
const GROUP_LABEL_FONT_SIZE = 11;
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// Color helpers
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
function mix(a: string, b: string, pct: number): string {
|
|
41
|
+
const parse = (h: string) => {
|
|
42
|
+
const r = h.replace('#', '');
|
|
43
|
+
const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
|
|
44
|
+
return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
|
|
45
|
+
};
|
|
46
|
+
const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
|
|
47
|
+
const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
|
|
48
|
+
return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function statusColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
52
|
+
switch (status) {
|
|
53
|
+
case 'done': return palette.colors.green;
|
|
54
|
+
case 'wip': return palette.colors.yellow;
|
|
55
|
+
case 'todo': return palette.colors.red;
|
|
56
|
+
case 'na': return isDark ? palette.colors.gray : '#2e3440';
|
|
57
|
+
default: return palette.textMuted;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function nodeFill(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
62
|
+
const color = statusColor(status, palette, isDark);
|
|
63
|
+
return mix(color, isDark ? palette.surface : palette.bg, 30);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function nodeStroke(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
67
|
+
return statusColor(status, palette, isDark);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nodeTextColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
71
|
+
const fill = nodeFill(status, palette, isDark);
|
|
72
|
+
return contrastText(fill, '#eceff4', '#2e3440');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function edgeStrokeColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
76
|
+
return statusColor(status, palette, isDark);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================
|
|
80
|
+
// Edge path generator
|
|
81
|
+
// ============================================================
|
|
82
|
+
|
|
83
|
+
const lineGenerator = d3Shape.line<{ x: number; y: number }>()
|
|
84
|
+
.x((d) => d.x)
|
|
85
|
+
.y((d) => d.y)
|
|
86
|
+
.curve(d3Shape.curveBasis);
|
|
87
|
+
|
|
88
|
+
// ============================================================
|
|
89
|
+
// Text fitting — wrap or shrink to fit fixed-size nodes
|
|
90
|
+
// ============================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Splits a word at camelCase boundaries.
|
|
94
|
+
* "MyProVenue" → ["MyPro", "Venue"]
|
|
95
|
+
* "HTMLParser" → ["HTML", "Parser"]
|
|
96
|
+
* "getUserID" → ["get", "User", "ID"]
|
|
97
|
+
*/
|
|
98
|
+
function splitCamelCase(word: string): string[] {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
let start = 0;
|
|
101
|
+
for (let i = 1; i < word.length; i++) {
|
|
102
|
+
const prev = word[i - 1];
|
|
103
|
+
const curr = word[i];
|
|
104
|
+
const next = i + 1 < word.length ? word[i + 1] : '';
|
|
105
|
+
// aB → split before B (lowercase → uppercase)
|
|
106
|
+
const lowerToUpper = prev >= 'a' && prev <= 'z' && curr >= 'A' && curr <= 'Z';
|
|
107
|
+
// ABc → split before B when followed by lowercase (end of uppercase run)
|
|
108
|
+
const upperRunEnd =
|
|
109
|
+
prev >= 'A' && prev <= 'Z' && curr >= 'A' && curr <= 'Z' && next >= 'a' && next <= 'z';
|
|
110
|
+
if (lowerToUpper || upperRunEnd) {
|
|
111
|
+
parts.push(word.slice(start, i));
|
|
112
|
+
start = i;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
parts.push(word.slice(start));
|
|
116
|
+
return parts.length > 1 ? parts : [word];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface FittedText {
|
|
120
|
+
lines: string[];
|
|
121
|
+
fontSize: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fitTextToNode(label: string, nodeWidth: number, nodeHeight: number): FittedText {
|
|
125
|
+
const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
|
|
126
|
+
const lineHeight = 1.3;
|
|
127
|
+
|
|
128
|
+
// Try at full font size first, then shrink
|
|
129
|
+
for (let fontSize = NODE_FONT_SIZE; fontSize >= MIN_NODE_FONT_SIZE; fontSize--) {
|
|
130
|
+
const charWidth = fontSize * CHAR_WIDTH_RATIO;
|
|
131
|
+
const maxCharsPerLine = Math.floor(maxTextWidth / charWidth);
|
|
132
|
+
const maxLines = Math.floor((nodeHeight - 8) / (fontSize * lineHeight));
|
|
133
|
+
|
|
134
|
+
if (maxCharsPerLine < 2 || maxLines < 1) continue;
|
|
135
|
+
|
|
136
|
+
// If it fits on one line, done
|
|
137
|
+
if (label.length <= maxCharsPerLine) {
|
|
138
|
+
return { lines: [label], fontSize };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Try word-wrapping
|
|
142
|
+
const words = label.split(/\s+/);
|
|
143
|
+
const lines: string[] = [];
|
|
144
|
+
let current = '';
|
|
145
|
+
|
|
146
|
+
for (const word of words) {
|
|
147
|
+
const test = current ? `${current} ${word}` : word;
|
|
148
|
+
if (test.length <= maxCharsPerLine) {
|
|
149
|
+
current = test;
|
|
150
|
+
} else {
|
|
151
|
+
if (current) lines.push(current);
|
|
152
|
+
current = word;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (current) lines.push(current);
|
|
156
|
+
|
|
157
|
+
// If all lines fit, check each line width
|
|
158
|
+
if (lines.length <= maxLines && lines.every((l) => l.length <= maxCharsPerLine)) {
|
|
159
|
+
return { lines, fontSize };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Try splitting long words on camelCase boundaries and re-wrapping
|
|
163
|
+
const camelWords: string[] = [];
|
|
164
|
+
for (const word of words) {
|
|
165
|
+
if (word.length > maxCharsPerLine) {
|
|
166
|
+
camelWords.push(...splitCamelCase(word));
|
|
167
|
+
} else {
|
|
168
|
+
camelWords.push(word);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const camelLines: string[] = [];
|
|
173
|
+
let camelCurrent = '';
|
|
174
|
+
for (const word of camelWords) {
|
|
175
|
+
const test = camelCurrent ? `${camelCurrent} ${word}` : word;
|
|
176
|
+
if (test.length <= maxCharsPerLine) {
|
|
177
|
+
camelCurrent = test;
|
|
178
|
+
} else {
|
|
179
|
+
if (camelCurrent) camelLines.push(camelCurrent);
|
|
180
|
+
camelCurrent = word;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (camelCurrent) camelLines.push(camelCurrent);
|
|
184
|
+
|
|
185
|
+
if (camelLines.length <= maxLines && camelLines.every((l) => l.length <= maxCharsPerLine)) {
|
|
186
|
+
return { lines: camelLines, fontSize };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// If not at minimum font size yet, try shrinking before hard-breaking
|
|
190
|
+
if (fontSize > MIN_NODE_FONT_SIZE) continue;
|
|
191
|
+
|
|
192
|
+
// At minimum font size — hard-break as last resort
|
|
193
|
+
const hardLines: string[] = [];
|
|
194
|
+
for (const line of camelLines) {
|
|
195
|
+
if (line.length <= maxCharsPerLine) {
|
|
196
|
+
hardLines.push(line);
|
|
197
|
+
} else {
|
|
198
|
+
for (let i = 0; i < line.length; i += maxCharsPerLine) {
|
|
199
|
+
hardLines.push(line.slice(i, i + maxCharsPerLine));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (hardLines.length <= maxLines) {
|
|
205
|
+
return { lines: hardLines, fontSize };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Last resort: smallest font, truncate with ellipsis
|
|
210
|
+
const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
|
|
211
|
+
const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
|
|
212
|
+
const truncated = label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
|
|
213
|
+
return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ============================================================
|
|
217
|
+
// Shape renderers — each draws within a centered (0,0) coordinate system
|
|
218
|
+
// ============================================================
|
|
219
|
+
|
|
220
|
+
type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
221
|
+
|
|
222
|
+
/** Default rectangle */
|
|
223
|
+
function renderShapeRect(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
224
|
+
g.append('rect')
|
|
225
|
+
.attr('x', -w / 2).attr('y', -h / 2)
|
|
226
|
+
.attr('width', w).attr('height', h)
|
|
227
|
+
.attr('rx', NODE_RX).attr('ry', NODE_RX)
|
|
228
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Service — more rounded rectangle */
|
|
232
|
+
function renderShapeService(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
233
|
+
g.append('rect')
|
|
234
|
+
.attr('x', -w / 2).attr('y', -h / 2)
|
|
235
|
+
.attr('width', w).attr('height', h)
|
|
236
|
+
.attr('rx', SERVICE_RX).attr('ry', SERVICE_RX)
|
|
237
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Actor — stick figure (no fill box) */
|
|
241
|
+
function renderShapeActor(g: D3G, w: number, h: number, s: string): void {
|
|
242
|
+
// Stick figure centered in top ~70% of the box, label goes below
|
|
243
|
+
const figH = h * 0.65;
|
|
244
|
+
const topY = -h / 2;
|
|
245
|
+
const headR = Math.min(figH * 0.22, w * 0.12);
|
|
246
|
+
const headY = topY + headR + 2;
|
|
247
|
+
const bodyTopY = headY + headR + 1;
|
|
248
|
+
const bodyBottomY = topY + figH * 0.75;
|
|
249
|
+
const legY = topY + figH;
|
|
250
|
+
const armSpan = Math.min(16, w * 0.18);
|
|
251
|
+
const legSpan = Math.min(12, w * 0.14);
|
|
252
|
+
const sw = 2.5;
|
|
253
|
+
|
|
254
|
+
g.append('circle')
|
|
255
|
+
.attr('cx', 0).attr('cy', headY).attr('r', headR)
|
|
256
|
+
.attr('fill', 'none').attr('stroke', s).attr('stroke-width', sw);
|
|
257
|
+
g.append('line')
|
|
258
|
+
.attr('x1', 0).attr('y1', bodyTopY).attr('x2', 0).attr('y2', bodyBottomY)
|
|
259
|
+
.attr('stroke', s).attr('stroke-width', sw);
|
|
260
|
+
g.append('line')
|
|
261
|
+
.attr('x1', -armSpan).attr('y1', bodyTopY + 4).attr('x2', armSpan).attr('y2', bodyTopY + 4)
|
|
262
|
+
.attr('stroke', s).attr('stroke-width', sw);
|
|
263
|
+
g.append('line')
|
|
264
|
+
.attr('x1', 0).attr('y1', bodyBottomY).attr('x2', -legSpan).attr('y2', legY)
|
|
265
|
+
.attr('stroke', s).attr('stroke-width', sw);
|
|
266
|
+
g.append('line')
|
|
267
|
+
.attr('x1', 0).attr('y1', bodyBottomY).attr('x2', legSpan).attr('y2', legY)
|
|
268
|
+
.attr('stroke', s).attr('stroke-width', sw);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Database — vertical cylinder */
|
|
272
|
+
function renderShapeDatabase(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
273
|
+
const ry = 7;
|
|
274
|
+
const topY = -h / 2 + ry;
|
|
275
|
+
const bodyH = h - ry * 2;
|
|
276
|
+
|
|
277
|
+
// Bottom ellipse
|
|
278
|
+
g.append('ellipse')
|
|
279
|
+
.attr('cx', 0).attr('cy', topY + bodyH).attr('rx', w / 2).attr('ry', ry)
|
|
280
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
281
|
+
// Body (covers bottom ellipse top arc)
|
|
282
|
+
g.append('rect')
|
|
283
|
+
.attr('x', -w / 2).attr('y', topY).attr('width', w).attr('height', bodyH)
|
|
284
|
+
.attr('fill', f).attr('stroke', 'none');
|
|
285
|
+
// Side lines
|
|
286
|
+
g.append('line')
|
|
287
|
+
.attr('x1', -w / 2).attr('y1', topY).attr('x2', -w / 2).attr('y2', topY + bodyH)
|
|
288
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
289
|
+
g.append('line')
|
|
290
|
+
.attr('x1', w / 2).attr('y1', topY).attr('x2', w / 2).attr('y2', topY + bodyH)
|
|
291
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
292
|
+
// Top ellipse cap
|
|
293
|
+
g.append('ellipse')
|
|
294
|
+
.attr('cx', 0).attr('cy', topY).attr('rx', w / 2).attr('ry', ry)
|
|
295
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Queue — horizontal cylinder (pipe) */
|
|
299
|
+
function renderShapeQueue(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
300
|
+
const rx = 10;
|
|
301
|
+
const leftX = -w / 2 + rx;
|
|
302
|
+
const bodyW = w - rx * 2;
|
|
303
|
+
|
|
304
|
+
// Right ellipse (back)
|
|
305
|
+
g.append('ellipse')
|
|
306
|
+
.attr('cx', leftX + bodyW).attr('cy', 0).attr('rx', rx).attr('ry', h / 2)
|
|
307
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
308
|
+
// Body
|
|
309
|
+
g.append('rect')
|
|
310
|
+
.attr('x', leftX).attr('y', -h / 2).attr('width', bodyW).attr('height', h)
|
|
311
|
+
.attr('fill', f).attr('stroke', 'none');
|
|
312
|
+
// Top and bottom lines
|
|
313
|
+
g.append('line')
|
|
314
|
+
.attr('x1', leftX).attr('y1', -h / 2).attr('x2', leftX + bodyW).attr('y2', -h / 2)
|
|
315
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
316
|
+
g.append('line')
|
|
317
|
+
.attr('x1', leftX).attr('y1', h / 2).attr('x2', leftX + bodyW).attr('y2', h / 2)
|
|
318
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
319
|
+
// Left ellipse (front)
|
|
320
|
+
g.append('ellipse')
|
|
321
|
+
.attr('cx', leftX).attr('cy', 0).attr('rx', rx).attr('ry', h / 2)
|
|
322
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Cache — dashed cylinder */
|
|
326
|
+
function renderShapeCache(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
327
|
+
const ry = 7;
|
|
328
|
+
const topY = -h / 2 + ry;
|
|
329
|
+
const bodyH = h - ry * 2;
|
|
330
|
+
const dash = '4 3';
|
|
331
|
+
|
|
332
|
+
g.append('ellipse')
|
|
333
|
+
.attr('cx', 0).attr('cy', topY + bodyH).attr('rx', w / 2).attr('ry', ry)
|
|
334
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
335
|
+
g.append('rect')
|
|
336
|
+
.attr('x', -w / 2).attr('y', topY).attr('width', w).attr('height', bodyH)
|
|
337
|
+
.attr('fill', f).attr('stroke', 'none');
|
|
338
|
+
g.append('line')
|
|
339
|
+
.attr('x1', -w / 2).attr('y1', topY).attr('x2', -w / 2).attr('y2', topY + bodyH)
|
|
340
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
341
|
+
g.append('line')
|
|
342
|
+
.attr('x1', w / 2).attr('y1', topY).attr('x2', w / 2).attr('y2', topY + bodyH)
|
|
343
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
344
|
+
g.append('ellipse')
|
|
345
|
+
.attr('cx', 0).attr('cy', topY).attr('rx', w / 2).attr('ry', ry)
|
|
346
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Networking — hexagon */
|
|
350
|
+
function renderShapeNetworking(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
351
|
+
const inset = 16;
|
|
352
|
+
const points = [
|
|
353
|
+
`${-w / 2 + inset},${-h / 2}`,
|
|
354
|
+
`${w / 2 - inset},${-h / 2}`,
|
|
355
|
+
`${w / 2},0`,
|
|
356
|
+
`${w / 2 - inset},${h / 2}`,
|
|
357
|
+
`${-w / 2 + inset},${h / 2}`,
|
|
358
|
+
`${-w / 2},0`,
|
|
359
|
+
].join(' ');
|
|
360
|
+
g.append('polygon')
|
|
361
|
+
.attr('points', points)
|
|
362
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Frontend — monitor with stand */
|
|
366
|
+
function renderShapeFrontend(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
367
|
+
const screenH = h - 10;
|
|
368
|
+
// Screen
|
|
369
|
+
g.append('rect')
|
|
370
|
+
.attr('x', -w / 2).attr('y', -h / 2).attr('width', w).attr('height', screenH)
|
|
371
|
+
.attr('rx', 3).attr('ry', 3)
|
|
372
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
373
|
+
// Stand
|
|
374
|
+
g.append('line')
|
|
375
|
+
.attr('x1', 0).attr('y1', -h / 2 + screenH).attr('x2', 0).attr('y2', h / 2 - 2)
|
|
376
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
377
|
+
// Base
|
|
378
|
+
g.append('line')
|
|
379
|
+
.attr('x1', -14).attr('y1', h / 2 - 2).attr('x2', 14).attr('y2', h / 2 - 2)
|
|
380
|
+
.attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** External — dashed rectangle */
|
|
384
|
+
function renderShapeExternal(g: D3G, w: number, h: number, f: string, s: string): void {
|
|
385
|
+
g.append('rect')
|
|
386
|
+
.attr('x', -w / 2).attr('y', -h / 2)
|
|
387
|
+
.attr('width', w).attr('height', h)
|
|
388
|
+
.attr('rx', NODE_RX).attr('ry', NODE_RX)
|
|
389
|
+
.attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH)
|
|
390
|
+
.attr('stroke-dasharray', '6 3');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Dispatch to the right shape renderer */
|
|
394
|
+
function renderNodeShape(
|
|
395
|
+
g: D3G,
|
|
396
|
+
shape: ParticipantType,
|
|
397
|
+
w: number,
|
|
398
|
+
h: number,
|
|
399
|
+
fillColor: string,
|
|
400
|
+
strokeColor: string
|
|
401
|
+
): void {
|
|
402
|
+
switch (shape) {
|
|
403
|
+
case 'actor': renderShapeActor(g, w, h, strokeColor); break;
|
|
404
|
+
case 'database': renderShapeDatabase(g, w, h, fillColor, strokeColor); break;
|
|
405
|
+
case 'queue': renderShapeQueue(g, w, h, fillColor, strokeColor); break;
|
|
406
|
+
case 'cache': renderShapeCache(g, w, h, fillColor, strokeColor); break;
|
|
407
|
+
case 'networking': renderShapeNetworking(g, w, h, fillColor, strokeColor); break;
|
|
408
|
+
case 'frontend': renderShapeFrontend(g, w, h, fillColor, strokeColor); break;
|
|
409
|
+
case 'external': renderShapeExternal(g, w, h, fillColor, strokeColor); break;
|
|
410
|
+
case 'service': renderShapeService(g, w, h, fillColor, strokeColor); break;
|
|
411
|
+
default: renderShapeRect(g, w, h, fillColor, strokeColor); break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ============================================================
|
|
416
|
+
// Main renderer
|
|
417
|
+
// ============================================================
|
|
418
|
+
|
|
419
|
+
export function renderInitiativeStatus(
|
|
420
|
+
container: HTMLDivElement,
|
|
421
|
+
parsed: ParsedInitiativeStatus,
|
|
422
|
+
layout: ISLayoutResult,
|
|
423
|
+
palette: PaletteColors,
|
|
424
|
+
isDark: boolean,
|
|
425
|
+
onClickItem?: (lineNumber: number) => void,
|
|
426
|
+
exportDims?: { width?: number; height?: number }
|
|
427
|
+
): void {
|
|
428
|
+
// Clear existing content
|
|
429
|
+
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
430
|
+
|
|
431
|
+
const width = exportDims?.width ?? container.clientWidth;
|
|
432
|
+
const height = exportDims?.height ?? container.clientHeight;
|
|
433
|
+
if (width <= 0 || height <= 0) return;
|
|
434
|
+
|
|
435
|
+
const titleHeight = parsed.title ? 40 : 0;
|
|
436
|
+
|
|
437
|
+
// Scale to fit
|
|
438
|
+
const diagramW = layout.width;
|
|
439
|
+
const diagramH = layout.height;
|
|
440
|
+
const availH = height - titleHeight;
|
|
441
|
+
const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
|
|
442
|
+
const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
|
|
443
|
+
const scale = Math.min(MAX_SCALE, scaleX, scaleY);
|
|
444
|
+
|
|
445
|
+
const scaledW = diagramW * scale;
|
|
446
|
+
const scaledH = diagramH * scale;
|
|
447
|
+
const offsetX = (width - scaledW) / 2;
|
|
448
|
+
const offsetY = titleHeight + DIAGRAM_PADDING;
|
|
449
|
+
|
|
450
|
+
// Create SVG
|
|
451
|
+
const svg = d3Selection
|
|
452
|
+
.select(container)
|
|
453
|
+
.append('svg')
|
|
454
|
+
.attr('width', width)
|
|
455
|
+
.attr('height', height)
|
|
456
|
+
.style('font-family', FONT_FAMILY);
|
|
457
|
+
|
|
458
|
+
// Defs: arrowhead markers per status color
|
|
459
|
+
const defs = svg.append('defs');
|
|
460
|
+
const markerColors = new Set<string>();
|
|
461
|
+
for (const edge of layout.edges) {
|
|
462
|
+
markerColors.add(edgeStrokeColor(edge.status, palette, isDark));
|
|
463
|
+
}
|
|
464
|
+
// Default marker
|
|
465
|
+
markerColors.add(palette.textMuted);
|
|
466
|
+
|
|
467
|
+
for (const color of markerColors) {
|
|
468
|
+
const id = `is-arrow-${color.replace('#', '')}`;
|
|
469
|
+
defs
|
|
470
|
+
.append('marker')
|
|
471
|
+
.attr('id', id)
|
|
472
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
|
|
473
|
+
.attr('refX', ARROWHEAD_W)
|
|
474
|
+
.attr('refY', ARROWHEAD_H / 2)
|
|
475
|
+
.attr('markerWidth', ARROWHEAD_W)
|
|
476
|
+
.attr('markerHeight', ARROWHEAD_H)
|
|
477
|
+
.attr('orient', 'auto')
|
|
478
|
+
.append('polygon')
|
|
479
|
+
.attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
|
|
480
|
+
.attr('fill', color);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Title
|
|
484
|
+
if (parsed.title) {
|
|
485
|
+
const titleEl = svg
|
|
486
|
+
.append('text')
|
|
487
|
+
.attr('class', 'chart-title')
|
|
488
|
+
.attr('x', width / 2)
|
|
489
|
+
.attr('y', 30)
|
|
490
|
+
.attr('text-anchor', 'middle')
|
|
491
|
+
.attr('fill', palette.text)
|
|
492
|
+
.attr('font-size', '20px')
|
|
493
|
+
.attr('font-weight', '700')
|
|
494
|
+
.style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
|
|
495
|
+
.text(parsed.title);
|
|
496
|
+
|
|
497
|
+
if (parsed.titleLineNumber) {
|
|
498
|
+
titleEl.attr('data-line-number', parsed.titleLineNumber);
|
|
499
|
+
if (onClickItem) {
|
|
500
|
+
titleEl
|
|
501
|
+
.on('click', () => onClickItem(parsed.titleLineNumber!))
|
|
502
|
+
.on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
|
|
503
|
+
.on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Content group
|
|
509
|
+
const contentG = svg
|
|
510
|
+
.append('g')
|
|
511
|
+
.attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
|
|
512
|
+
|
|
513
|
+
// Helper: interpolate a point at parameter t (0–1) along a polyline
|
|
514
|
+
function interpolatePolyline(
|
|
515
|
+
pts: { x: number; y: number }[],
|
|
516
|
+
t: number
|
|
517
|
+
): { x: number; y: number } {
|
|
518
|
+
if (pts.length < 2) return pts[0];
|
|
519
|
+
// Compute cumulative segment lengths
|
|
520
|
+
const segLens: number[] = [];
|
|
521
|
+
let total = 0;
|
|
522
|
+
for (let i = 1; i < pts.length; i++) {
|
|
523
|
+
const dx = pts[i].x - pts[i - 1].x;
|
|
524
|
+
const dy = pts[i].y - pts[i - 1].y;
|
|
525
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
526
|
+
segLens.push(d);
|
|
527
|
+
total += d;
|
|
528
|
+
}
|
|
529
|
+
const target = t * total;
|
|
530
|
+
let accum = 0;
|
|
531
|
+
for (let i = 0; i < segLens.length; i++) {
|
|
532
|
+
if (accum + segLens[i] >= target) {
|
|
533
|
+
const frac = segLens[i] > 0 ? (target - accum) / segLens[i] : 0;
|
|
534
|
+
return {
|
|
535
|
+
x: pts[i].x + (pts[i + 1].x - pts[i].x) * frac,
|
|
536
|
+
y: pts[i].y + (pts[i + 1].y - pts[i].y) * frac,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
accum += segLens[i];
|
|
540
|
+
}
|
|
541
|
+
return pts[pts.length - 1];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Compute label positions — place each label ON its own edge path.
|
|
545
|
+
// Start at t=0.5 (midpoint). If two labels overlap, slide them apart
|
|
546
|
+
// along their respective paths.
|
|
547
|
+
interface LabelPlacement {
|
|
548
|
+
x: number;
|
|
549
|
+
y: number;
|
|
550
|
+
w: number;
|
|
551
|
+
h: number;
|
|
552
|
+
edgeIdx: number;
|
|
553
|
+
t: number; // parameter along path
|
|
554
|
+
points: { x: number; y: number }[];
|
|
555
|
+
}
|
|
556
|
+
const labelPlacements: LabelPlacement[] = [];
|
|
557
|
+
|
|
558
|
+
for (let ei = 0; ei < layout.edges.length; ei++) {
|
|
559
|
+
const edge = layout.edges[ei];
|
|
560
|
+
if (!edge.label || edge.points.length < 2) continue;
|
|
561
|
+
|
|
562
|
+
const t = 0.5;
|
|
563
|
+
const pt = interpolatePolyline(edge.points, t);
|
|
564
|
+
const labelLen = edge.label.length;
|
|
565
|
+
const bgW = labelLen * 7 + 10;
|
|
566
|
+
const bgH = 18;
|
|
567
|
+
|
|
568
|
+
labelPlacements.push({
|
|
569
|
+
x: pt.x,
|
|
570
|
+
y: pt.y,
|
|
571
|
+
w: bgW,
|
|
572
|
+
h: bgH,
|
|
573
|
+
edgeIdx: ei,
|
|
574
|
+
t,
|
|
575
|
+
points: edge.points,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Resolve overlaps by sliding labels along their own paths
|
|
580
|
+
const MIN_LABEL_GAP = 6;
|
|
581
|
+
for (let pass = 0; pass < 8; pass++) {
|
|
582
|
+
let moved = false;
|
|
583
|
+
for (let i = 0; i < labelPlacements.length; i++) {
|
|
584
|
+
for (let j = i + 1; j < labelPlacements.length; j++) {
|
|
585
|
+
const a = labelPlacements[i];
|
|
586
|
+
const b = labelPlacements[j];
|
|
587
|
+
const overlapX = Math.abs(a.x - b.x) < (a.w + b.w) / 2 + MIN_LABEL_GAP;
|
|
588
|
+
const overlapY = Math.abs(a.y - b.y) < (a.h + b.h) / 2 + MIN_LABEL_GAP;
|
|
589
|
+
if (overlapX && overlapY) {
|
|
590
|
+
// Slide each label along its own path in opposite directions
|
|
591
|
+
const step = 0.08;
|
|
592
|
+
a.t = Math.max(0.15, a.t - step);
|
|
593
|
+
b.t = Math.min(0.85, b.t + step);
|
|
594
|
+
const ptA = interpolatePolyline(a.points, a.t);
|
|
595
|
+
const ptB = interpolatePolyline(b.points, b.t);
|
|
596
|
+
a.x = ptA.x;
|
|
597
|
+
a.y = ptA.y;
|
|
598
|
+
b.x = ptB.x;
|
|
599
|
+
b.y = ptB.y;
|
|
600
|
+
moved = true;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (!moved) break;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Build lookup from edge index to label placement
|
|
608
|
+
const labelMap = new Map<number, LabelPlacement>();
|
|
609
|
+
for (const lp of labelPlacements) labelMap.set(lp.edgeIdx, lp);
|
|
610
|
+
|
|
611
|
+
// Render groups (background layer, before edges and nodes)
|
|
612
|
+
for (const group of layout.groups) {
|
|
613
|
+
if (group.width === 0 && group.height === 0) continue;
|
|
614
|
+
const gx = group.x - GROUP_EXTRA_PADDING;
|
|
615
|
+
const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
|
|
616
|
+
const gw = group.width + GROUP_EXTRA_PADDING * 2;
|
|
617
|
+
const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
|
|
618
|
+
|
|
619
|
+
const groupStatusColor = group.status
|
|
620
|
+
? statusColor(group.status, palette, isDark)
|
|
621
|
+
: palette.textMuted;
|
|
622
|
+
// More subdued than nodes: 15% status color vs 30% for nodes
|
|
623
|
+
const fillColor = mix(groupStatusColor, isDark ? palette.surface : palette.bg, 15);
|
|
624
|
+
const strokeColor = mix(groupStatusColor, palette.textMuted, 50);
|
|
625
|
+
|
|
626
|
+
const groupG = contentG
|
|
627
|
+
.append('g')
|
|
628
|
+
.attr('class', 'is-group')
|
|
629
|
+
.attr('data-line-number', String(group.lineNumber));
|
|
630
|
+
|
|
631
|
+
groupG
|
|
632
|
+
.append('rect')
|
|
633
|
+
.attr('x', gx)
|
|
634
|
+
.attr('y', gy)
|
|
635
|
+
.attr('width', gw)
|
|
636
|
+
.attr('height', gh)
|
|
637
|
+
.attr('rx', 6)
|
|
638
|
+
.attr('fill', fillColor)
|
|
639
|
+
.attr('stroke', strokeColor)
|
|
640
|
+
.attr('stroke-width', 1)
|
|
641
|
+
.attr('stroke-opacity', 0.5);
|
|
642
|
+
|
|
643
|
+
groupG
|
|
644
|
+
.append('text')
|
|
645
|
+
.attr('x', gx + 8)
|
|
646
|
+
.attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
|
|
647
|
+
.attr('fill', strokeColor)
|
|
648
|
+
.attr('font-size', GROUP_LABEL_FONT_SIZE)
|
|
649
|
+
.attr('font-weight', 'bold')
|
|
650
|
+
.attr('opacity', 0.7)
|
|
651
|
+
.attr('class', 'is-group-label')
|
|
652
|
+
.text(group.label);
|
|
653
|
+
|
|
654
|
+
if (onClickItem) {
|
|
655
|
+
groupG.style('cursor', 'pointer').on('click', () => {
|
|
656
|
+
onClickItem(group.lineNumber);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Render edges (below nodes)
|
|
662
|
+
for (let ei = 0; ei < layout.edges.length; ei++) {
|
|
663
|
+
const edge = layout.edges[ei];
|
|
664
|
+
if (edge.points.length < 2) continue;
|
|
665
|
+
const edgeColor = edgeStrokeColor(edge.status, palette, isDark);
|
|
666
|
+
const markerId = `is-arrow-${edgeColor.replace('#', '')}`;
|
|
667
|
+
|
|
668
|
+
const edgeG = contentG
|
|
669
|
+
.append('g')
|
|
670
|
+
.attr('class', 'is-edge-group')
|
|
671
|
+
.attr('data-line-number', String(edge.lineNumber));
|
|
672
|
+
|
|
673
|
+
const pathD = lineGenerator(edge.points);
|
|
674
|
+
if (pathD) {
|
|
675
|
+
edgeG
|
|
676
|
+
.append('path')
|
|
677
|
+
.attr('d', pathD)
|
|
678
|
+
.attr('fill', 'none')
|
|
679
|
+
.attr('stroke', edgeColor)
|
|
680
|
+
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
681
|
+
.attr('marker-end', `url(#${markerId})`)
|
|
682
|
+
.attr('class', 'is-edge');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Edge label placed on its own path
|
|
686
|
+
const lp = labelMap.get(ei);
|
|
687
|
+
if (edge.label && lp) {
|
|
688
|
+
edgeG
|
|
689
|
+
.append('rect')
|
|
690
|
+
.attr('x', lp.x - lp.w / 2)
|
|
691
|
+
.attr('y', lp.y - lp.h / 2 - 1)
|
|
692
|
+
.attr('width', lp.w)
|
|
693
|
+
.attr('height', lp.h)
|
|
694
|
+
.attr('rx', 3)
|
|
695
|
+
.attr('fill', palette.bg)
|
|
696
|
+
.attr('opacity', 0.9)
|
|
697
|
+
.attr('class', 'is-edge-label-bg');
|
|
698
|
+
|
|
699
|
+
edgeG
|
|
700
|
+
.append('text')
|
|
701
|
+
.attr('x', lp.x)
|
|
702
|
+
.attr('y', lp.y + 4)
|
|
703
|
+
.attr('text-anchor', 'middle')
|
|
704
|
+
.attr('fill', edgeColor)
|
|
705
|
+
.attr('font-size', EDGE_LABEL_FONT_SIZE)
|
|
706
|
+
.attr('class', 'is-edge-label')
|
|
707
|
+
.text(edge.label);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (onClickItem) {
|
|
711
|
+
edgeG.style('cursor', 'pointer').on('click', () => {
|
|
712
|
+
onClickItem(edge.lineNumber);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Render nodes (top layer)
|
|
718
|
+
for (const node of layout.nodes) {
|
|
719
|
+
const nodeG = contentG
|
|
720
|
+
.append('g')
|
|
721
|
+
.attr('transform', `translate(${node.x}, ${node.y})`)
|
|
722
|
+
.attr('class', 'is-node')
|
|
723
|
+
.attr('data-line-number', String(node.lineNumber));
|
|
724
|
+
|
|
725
|
+
if (onClickItem) {
|
|
726
|
+
nodeG.style('cursor', 'pointer').on('click', () => {
|
|
727
|
+
onClickItem(node.lineNumber);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Transparent hit-area rect — ensures the full bounding box captures
|
|
732
|
+
// clicks for shapes with gaps (actors, frontends, databases, etc.)
|
|
733
|
+
nodeG
|
|
734
|
+
.append('rect')
|
|
735
|
+
.attr('x', -node.width / 2)
|
|
736
|
+
.attr('y', -node.height / 2)
|
|
737
|
+
.attr('width', node.width)
|
|
738
|
+
.attr('height', node.height)
|
|
739
|
+
.attr('fill', 'transparent')
|
|
740
|
+
.attr('class', 'is-node-hit-area');
|
|
741
|
+
|
|
742
|
+
const fill = nodeFill(node.status, palette, isDark);
|
|
743
|
+
const stroke = nodeStroke(node.status, palette, isDark);
|
|
744
|
+
renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
|
|
745
|
+
|
|
746
|
+
// Label placement: actors put label below the figure, others center inside
|
|
747
|
+
const isActor = node.shape === 'actor';
|
|
748
|
+
if (isActor) {
|
|
749
|
+
const textColor = nodeTextColor(node.status, palette, isDark);
|
|
750
|
+
const fitted = fitTextToNode(node.label, node.width, node.height * 0.35);
|
|
751
|
+
const labelY = node.height / 2 - fitted.fontSize * 0.3;
|
|
752
|
+
for (let li = 0; li < fitted.lines.length; li++) {
|
|
753
|
+
nodeG
|
|
754
|
+
.append('text')
|
|
755
|
+
.attr('x', 0)
|
|
756
|
+
.attr('y', labelY + li * fitted.fontSize * 1.3)
|
|
757
|
+
.attr('text-anchor', 'middle')
|
|
758
|
+
.attr('dominant-baseline', 'central')
|
|
759
|
+
.attr('fill', textColor)
|
|
760
|
+
.attr('font-size', fitted.fontSize)
|
|
761
|
+
.attr('font-weight', '600')
|
|
762
|
+
.text(fitted.lines[li]);
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
const fitted = fitTextToNode(node.label, node.width, node.height);
|
|
766
|
+
const textColor = nodeTextColor(node.status, palette, isDark);
|
|
767
|
+
const totalTextHeight = fitted.lines.length * fitted.fontSize * 1.3;
|
|
768
|
+
const startY = -totalTextHeight / 2 + fitted.fontSize * 0.65;
|
|
769
|
+
|
|
770
|
+
for (let li = 0; li < fitted.lines.length; li++) {
|
|
771
|
+
nodeG
|
|
772
|
+
.append('text')
|
|
773
|
+
.attr('x', 0)
|
|
774
|
+
.attr('y', startY + li * fitted.fontSize * 1.3)
|
|
775
|
+
.attr('text-anchor', 'middle')
|
|
776
|
+
.attr('dominant-baseline', 'central')
|
|
777
|
+
.attr('fill', textColor)
|
|
778
|
+
.attr('font-size', fitted.fontSize)
|
|
779
|
+
.attr('font-weight', '600')
|
|
780
|
+
.text(fitted.lines[li]);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ============================================================
|
|
787
|
+
// Export convenience function
|
|
788
|
+
// ============================================================
|
|
789
|
+
|
|
790
|
+
export function renderInitiativeStatusForExport(
|
|
791
|
+
content: string,
|
|
792
|
+
theme: 'light' | 'dark' | 'transparent',
|
|
793
|
+
palette: PaletteColors
|
|
794
|
+
): string {
|
|
795
|
+
const parsed = parseInitiativeStatus(content);
|
|
796
|
+
if (parsed.error || parsed.nodes.length === 0) return '';
|
|
797
|
+
|
|
798
|
+
const layout = layoutInitiativeStatus(parsed);
|
|
799
|
+
const isDark = theme === 'dark';
|
|
800
|
+
|
|
801
|
+
const titleOffset = parsed.title ? 40 : 0;
|
|
802
|
+
const exportWidth = layout.width + DIAGRAM_PADDING * 2;
|
|
803
|
+
const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
|
|
804
|
+
|
|
805
|
+
const container = document.createElement('div');
|
|
806
|
+
container.style.width = `${exportWidth}px`;
|
|
807
|
+
container.style.height = `${exportHeight}px`;
|
|
808
|
+
container.style.position = 'absolute';
|
|
809
|
+
container.style.left = '-9999px';
|
|
810
|
+
document.body.appendChild(container);
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
renderInitiativeStatus(
|
|
814
|
+
container,
|
|
815
|
+
parsed,
|
|
816
|
+
layout,
|
|
817
|
+
palette,
|
|
818
|
+
isDark,
|
|
819
|
+
undefined,
|
|
820
|
+
{ width: exportWidth, height: exportHeight }
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
const svgEl = container.querySelector('svg');
|
|
824
|
+
if (!svgEl) return '';
|
|
825
|
+
|
|
826
|
+
if (theme === 'transparent') {
|
|
827
|
+
svgEl.style.background = 'none';
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
831
|
+
svgEl.style.fontFamily = FONT_FAMILY;
|
|
832
|
+
|
|
833
|
+
return svgEl.outerHTML;
|
|
834
|
+
} finally {
|
|
835
|
+
document.body.removeChild(container);
|
|
836
|
+
}
|
|
837
|
+
}
|