@diagrammo/dgmo 0.2.21 → 0.2.23

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.
@@ -0,0 +1,834 @@
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
+ // Lines don't fit — try hard-breaking long words
190
+ const hardLines: string[] = [];
191
+ for (const line of camelLines) {
192
+ if (line.length <= maxCharsPerLine) {
193
+ hardLines.push(line);
194
+ } else {
195
+ for (let i = 0; i < line.length; i += maxCharsPerLine) {
196
+ hardLines.push(line.slice(i, i + maxCharsPerLine));
197
+ }
198
+ }
199
+ }
200
+
201
+ if (hardLines.length <= maxLines) {
202
+ return { lines: hardLines, fontSize };
203
+ }
204
+ }
205
+
206
+ // Last resort: smallest font, truncate with ellipsis
207
+ const charWidth = MIN_NODE_FONT_SIZE * CHAR_WIDTH_RATIO;
208
+ const maxChars = Math.floor((nodeWidth - NODE_TEXT_PADDING * 2) / charWidth);
209
+ const truncated = label.length > maxChars ? label.slice(0, maxChars - 1) + '\u2026' : label;
210
+ return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
211
+ }
212
+
213
+ // ============================================================
214
+ // Shape renderers — each draws within a centered (0,0) coordinate system
215
+ // ============================================================
216
+
217
+ type D3G = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
218
+
219
+ /** Default rectangle */
220
+ function renderShapeRect(g: D3G, w: number, h: number, f: string, s: string): void {
221
+ g.append('rect')
222
+ .attr('x', -w / 2).attr('y', -h / 2)
223
+ .attr('width', w).attr('height', h)
224
+ .attr('rx', NODE_RX).attr('ry', NODE_RX)
225
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
226
+ }
227
+
228
+ /** Service — more rounded rectangle */
229
+ function renderShapeService(g: D3G, w: number, h: number, f: string, s: string): void {
230
+ g.append('rect')
231
+ .attr('x', -w / 2).attr('y', -h / 2)
232
+ .attr('width', w).attr('height', h)
233
+ .attr('rx', SERVICE_RX).attr('ry', SERVICE_RX)
234
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
235
+ }
236
+
237
+ /** Actor — stick figure (no fill box) */
238
+ function renderShapeActor(g: D3G, w: number, h: number, s: string): void {
239
+ // Stick figure centered in top ~70% of the box, label goes below
240
+ const figH = h * 0.65;
241
+ const topY = -h / 2;
242
+ const headR = Math.min(figH * 0.22, w * 0.12);
243
+ const headY = topY + headR + 2;
244
+ const bodyTopY = headY + headR + 1;
245
+ const bodyBottomY = topY + figH * 0.75;
246
+ const legY = topY + figH;
247
+ const armSpan = Math.min(16, w * 0.18);
248
+ const legSpan = Math.min(12, w * 0.14);
249
+ const sw = 2.5;
250
+
251
+ g.append('circle')
252
+ .attr('cx', 0).attr('cy', headY).attr('r', headR)
253
+ .attr('fill', 'none').attr('stroke', s).attr('stroke-width', sw);
254
+ g.append('line')
255
+ .attr('x1', 0).attr('y1', bodyTopY).attr('x2', 0).attr('y2', bodyBottomY)
256
+ .attr('stroke', s).attr('stroke-width', sw);
257
+ g.append('line')
258
+ .attr('x1', -armSpan).attr('y1', bodyTopY + 4).attr('x2', armSpan).attr('y2', bodyTopY + 4)
259
+ .attr('stroke', s).attr('stroke-width', sw);
260
+ g.append('line')
261
+ .attr('x1', 0).attr('y1', bodyBottomY).attr('x2', -legSpan).attr('y2', legY)
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
+ }
267
+
268
+ /** Database — vertical cylinder */
269
+ function renderShapeDatabase(g: D3G, w: number, h: number, f: string, s: string): void {
270
+ const ry = 7;
271
+ const topY = -h / 2 + ry;
272
+ const bodyH = h - ry * 2;
273
+
274
+ // Bottom ellipse
275
+ g.append('ellipse')
276
+ .attr('cx', 0).attr('cy', topY + bodyH).attr('rx', w / 2).attr('ry', ry)
277
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
278
+ // Body (covers bottom ellipse top arc)
279
+ g.append('rect')
280
+ .attr('x', -w / 2).attr('y', topY).attr('width', w).attr('height', bodyH)
281
+ .attr('fill', f).attr('stroke', 'none');
282
+ // Side lines
283
+ g.append('line')
284
+ .attr('x1', -w / 2).attr('y1', topY).attr('x2', -w / 2).attr('y2', topY + bodyH)
285
+ .attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
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
+ // Top ellipse cap
290
+ g.append('ellipse')
291
+ .attr('cx', 0).attr('cy', topY).attr('rx', w / 2).attr('ry', ry)
292
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
293
+ }
294
+
295
+ /** Queue — horizontal cylinder (pipe) */
296
+ function renderShapeQueue(g: D3G, w: number, h: number, f: string, s: string): void {
297
+ const rx = 10;
298
+ const leftX = -w / 2 + rx;
299
+ const bodyW = w - rx * 2;
300
+
301
+ // Right ellipse (back)
302
+ g.append('ellipse')
303
+ .attr('cx', leftX + bodyW).attr('cy', 0).attr('rx', rx).attr('ry', h / 2)
304
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
305
+ // Body
306
+ g.append('rect')
307
+ .attr('x', leftX).attr('y', -h / 2).attr('width', bodyW).attr('height', h)
308
+ .attr('fill', f).attr('stroke', 'none');
309
+ // Top and bottom lines
310
+ g.append('line')
311
+ .attr('x1', leftX).attr('y1', -h / 2).attr('x2', leftX + bodyW).attr('y2', -h / 2)
312
+ .attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
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
+ // Left ellipse (front)
317
+ g.append('ellipse')
318
+ .attr('cx', leftX).attr('cy', 0).attr('rx', rx).attr('ry', h / 2)
319
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
320
+ }
321
+
322
+ /** Cache — dashed cylinder */
323
+ function renderShapeCache(g: D3G, w: number, h: number, f: string, s: string): void {
324
+ const ry = 7;
325
+ const topY = -h / 2 + ry;
326
+ const bodyH = h - ry * 2;
327
+ const dash = '4 3';
328
+
329
+ g.append('ellipse')
330
+ .attr('cx', 0).attr('cy', topY + bodyH).attr('rx', w / 2).attr('ry', ry)
331
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
332
+ g.append('rect')
333
+ .attr('x', -w / 2).attr('y', topY).attr('width', w).attr('height', bodyH)
334
+ .attr('fill', f).attr('stroke', 'none');
335
+ g.append('line')
336
+ .attr('x1', -w / 2).attr('y1', topY).attr('x2', -w / 2).attr('y2', topY + bodyH)
337
+ .attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH).attr('stroke-dasharray', dash);
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('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).attr('stroke-dasharray', dash);
344
+ }
345
+
346
+ /** Networking — hexagon */
347
+ function renderShapeNetworking(g: D3G, w: number, h: number, f: string, s: string): void {
348
+ const inset = 16;
349
+ const points = [
350
+ `${-w / 2 + inset},${-h / 2}`,
351
+ `${w / 2 - inset},${-h / 2}`,
352
+ `${w / 2},0`,
353
+ `${w / 2 - inset},${h / 2}`,
354
+ `${-w / 2 + inset},${h / 2}`,
355
+ `${-w / 2},0`,
356
+ ].join(' ');
357
+ g.append('polygon')
358
+ .attr('points', points)
359
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
360
+ }
361
+
362
+ /** Frontend — monitor with stand */
363
+ function renderShapeFrontend(g: D3G, w: number, h: number, f: string, s: string): void {
364
+ const screenH = h - 10;
365
+ // Screen
366
+ g.append('rect')
367
+ .attr('x', -w / 2).attr('y', -h / 2).attr('width', w).attr('height', screenH)
368
+ .attr('rx', 3).attr('ry', 3)
369
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
370
+ // Stand
371
+ g.append('line')
372
+ .attr('x1', 0).attr('y1', -h / 2 + screenH).attr('x2', 0).attr('y2', h / 2 - 2)
373
+ .attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
374
+ // Base
375
+ g.append('line')
376
+ .attr('x1', -14).attr('y1', h / 2 - 2).attr('x2', 14).attr('y2', h / 2 - 2)
377
+ .attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH);
378
+ }
379
+
380
+ /** External — dashed rectangle */
381
+ function renderShapeExternal(g: D3G, w: number, h: number, f: string, s: string): void {
382
+ g.append('rect')
383
+ .attr('x', -w / 2).attr('y', -h / 2)
384
+ .attr('width', w).attr('height', h)
385
+ .attr('rx', NODE_RX).attr('ry', NODE_RX)
386
+ .attr('fill', f).attr('stroke', s).attr('stroke-width', NODE_STROKE_WIDTH)
387
+ .attr('stroke-dasharray', '6 3');
388
+ }
389
+
390
+ /** Dispatch to the right shape renderer */
391
+ function renderNodeShape(
392
+ g: D3G,
393
+ shape: ParticipantType,
394
+ w: number,
395
+ h: number,
396
+ fillColor: string,
397
+ strokeColor: string
398
+ ): void {
399
+ switch (shape) {
400
+ case 'actor': renderShapeActor(g, w, h, strokeColor); break;
401
+ case 'database': renderShapeDatabase(g, w, h, fillColor, strokeColor); break;
402
+ case 'queue': renderShapeQueue(g, w, h, fillColor, strokeColor); break;
403
+ case 'cache': renderShapeCache(g, w, h, fillColor, strokeColor); break;
404
+ case 'networking': renderShapeNetworking(g, w, h, fillColor, strokeColor); break;
405
+ case 'frontend': renderShapeFrontend(g, w, h, fillColor, strokeColor); break;
406
+ case 'external': renderShapeExternal(g, w, h, fillColor, strokeColor); break;
407
+ case 'service': renderShapeService(g, w, h, fillColor, strokeColor); break;
408
+ default: renderShapeRect(g, w, h, fillColor, strokeColor); break;
409
+ }
410
+ }
411
+
412
+ // ============================================================
413
+ // Main renderer
414
+ // ============================================================
415
+
416
+ export function renderInitiativeStatus(
417
+ container: HTMLDivElement,
418
+ parsed: ParsedInitiativeStatus,
419
+ layout: ISLayoutResult,
420
+ palette: PaletteColors,
421
+ isDark: boolean,
422
+ onClickItem?: (lineNumber: number) => void,
423
+ exportDims?: { width?: number; height?: number }
424
+ ): void {
425
+ // Clear existing content
426
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
427
+
428
+ const width = exportDims?.width ?? container.clientWidth;
429
+ const height = exportDims?.height ?? container.clientHeight;
430
+ if (width <= 0 || height <= 0) return;
431
+
432
+ const titleHeight = parsed.title ? 40 : 0;
433
+
434
+ // Scale to fit
435
+ const diagramW = layout.width;
436
+ const diagramH = layout.height;
437
+ const availH = height - titleHeight;
438
+ const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
439
+ const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
440
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
441
+
442
+ const scaledW = diagramW * scale;
443
+ const scaledH = diagramH * scale;
444
+ const offsetX = (width - scaledW) / 2;
445
+ const offsetY = titleHeight + DIAGRAM_PADDING;
446
+
447
+ // Create SVG
448
+ const svg = d3Selection
449
+ .select(container)
450
+ .append('svg')
451
+ .attr('width', width)
452
+ .attr('height', height)
453
+ .style('font-family', FONT_FAMILY);
454
+
455
+ // Defs: arrowhead markers per status color
456
+ const defs = svg.append('defs');
457
+ const markerColors = new Set<string>();
458
+ for (const edge of layout.edges) {
459
+ markerColors.add(edgeStrokeColor(edge.status, palette, isDark));
460
+ }
461
+ // Default marker
462
+ markerColors.add(palette.textMuted);
463
+
464
+ for (const color of markerColors) {
465
+ const id = `is-arrow-${color.replace('#', '')}`;
466
+ defs
467
+ .append('marker')
468
+ .attr('id', id)
469
+ .attr('viewBox', `0 0 ${ARROWHEAD_W} ${ARROWHEAD_H}`)
470
+ .attr('refX', ARROWHEAD_W)
471
+ .attr('refY', ARROWHEAD_H / 2)
472
+ .attr('markerWidth', ARROWHEAD_W)
473
+ .attr('markerHeight', ARROWHEAD_H)
474
+ .attr('orient', 'auto')
475
+ .append('polygon')
476
+ .attr('points', `0,0 ${ARROWHEAD_W},${ARROWHEAD_H / 2} 0,${ARROWHEAD_H}`)
477
+ .attr('fill', color);
478
+ }
479
+
480
+ // Title
481
+ if (parsed.title) {
482
+ const titleEl = svg
483
+ .append('text')
484
+ .attr('class', 'chart-title')
485
+ .attr('x', width / 2)
486
+ .attr('y', 30)
487
+ .attr('text-anchor', 'middle')
488
+ .attr('fill', palette.text)
489
+ .attr('font-size', '20px')
490
+ .attr('font-weight', '700')
491
+ .style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
492
+ .text(parsed.title);
493
+
494
+ if (parsed.titleLineNumber) {
495
+ titleEl.attr('data-line-number', parsed.titleLineNumber);
496
+ if (onClickItem) {
497
+ titleEl
498
+ .on('click', () => onClickItem(parsed.titleLineNumber!))
499
+ .on('mouseenter', function () { d3Selection.select(this).attr('opacity', 0.7); })
500
+ .on('mouseleave', function () { d3Selection.select(this).attr('opacity', 1); });
501
+ }
502
+ }
503
+ }
504
+
505
+ // Content group
506
+ const contentG = svg
507
+ .append('g')
508
+ .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
509
+
510
+ // Helper: interpolate a point at parameter t (0–1) along a polyline
511
+ function interpolatePolyline(
512
+ pts: { x: number; y: number }[],
513
+ t: number
514
+ ): { x: number; y: number } {
515
+ if (pts.length < 2) return pts[0];
516
+ // Compute cumulative segment lengths
517
+ const segLens: number[] = [];
518
+ let total = 0;
519
+ for (let i = 1; i < pts.length; i++) {
520
+ const dx = pts[i].x - pts[i - 1].x;
521
+ const dy = pts[i].y - pts[i - 1].y;
522
+ const d = Math.sqrt(dx * dx + dy * dy);
523
+ segLens.push(d);
524
+ total += d;
525
+ }
526
+ const target = t * total;
527
+ let accum = 0;
528
+ for (let i = 0; i < segLens.length; i++) {
529
+ if (accum + segLens[i] >= target) {
530
+ const frac = segLens[i] > 0 ? (target - accum) / segLens[i] : 0;
531
+ return {
532
+ x: pts[i].x + (pts[i + 1].x - pts[i].x) * frac,
533
+ y: pts[i].y + (pts[i + 1].y - pts[i].y) * frac,
534
+ };
535
+ }
536
+ accum += segLens[i];
537
+ }
538
+ return pts[pts.length - 1];
539
+ }
540
+
541
+ // Compute label positions — place each label ON its own edge path.
542
+ // Start at t=0.5 (midpoint). If two labels overlap, slide them apart
543
+ // along their respective paths.
544
+ interface LabelPlacement {
545
+ x: number;
546
+ y: number;
547
+ w: number;
548
+ h: number;
549
+ edgeIdx: number;
550
+ t: number; // parameter along path
551
+ points: { x: number; y: number }[];
552
+ }
553
+ const labelPlacements: LabelPlacement[] = [];
554
+
555
+ for (let ei = 0; ei < layout.edges.length; ei++) {
556
+ const edge = layout.edges[ei];
557
+ if (!edge.label || edge.points.length < 2) continue;
558
+
559
+ const t = 0.5;
560
+ const pt = interpolatePolyline(edge.points, t);
561
+ const labelLen = edge.label.length;
562
+ const bgW = labelLen * 7 + 10;
563
+ const bgH = 18;
564
+
565
+ labelPlacements.push({
566
+ x: pt.x,
567
+ y: pt.y,
568
+ w: bgW,
569
+ h: bgH,
570
+ edgeIdx: ei,
571
+ t,
572
+ points: edge.points,
573
+ });
574
+ }
575
+
576
+ // Resolve overlaps by sliding labels along their own paths
577
+ const MIN_LABEL_GAP = 6;
578
+ for (let pass = 0; pass < 8; pass++) {
579
+ let moved = false;
580
+ for (let i = 0; i < labelPlacements.length; i++) {
581
+ for (let j = i + 1; j < labelPlacements.length; j++) {
582
+ const a = labelPlacements[i];
583
+ const b = labelPlacements[j];
584
+ const overlapX = Math.abs(a.x - b.x) < (a.w + b.w) / 2 + MIN_LABEL_GAP;
585
+ const overlapY = Math.abs(a.y - b.y) < (a.h + b.h) / 2 + MIN_LABEL_GAP;
586
+ if (overlapX && overlapY) {
587
+ // Slide each label along its own path in opposite directions
588
+ const step = 0.08;
589
+ a.t = Math.max(0.15, a.t - step);
590
+ b.t = Math.min(0.85, b.t + step);
591
+ const ptA = interpolatePolyline(a.points, a.t);
592
+ const ptB = interpolatePolyline(b.points, b.t);
593
+ a.x = ptA.x;
594
+ a.y = ptA.y;
595
+ b.x = ptB.x;
596
+ b.y = ptB.y;
597
+ moved = true;
598
+ }
599
+ }
600
+ }
601
+ if (!moved) break;
602
+ }
603
+
604
+ // Build lookup from edge index to label placement
605
+ const labelMap = new Map<number, LabelPlacement>();
606
+ for (const lp of labelPlacements) labelMap.set(lp.edgeIdx, lp);
607
+
608
+ // Render groups (background layer, before edges and nodes)
609
+ for (const group of layout.groups) {
610
+ if (group.width === 0 && group.height === 0) continue;
611
+ const gx = group.x - GROUP_EXTRA_PADDING;
612
+ const gy = group.y - GROUP_EXTRA_PADDING - GROUP_LABEL_FONT_SIZE - 4;
613
+ const gw = group.width + GROUP_EXTRA_PADDING * 2;
614
+ const gh = group.height + GROUP_EXTRA_PADDING * 2 + GROUP_LABEL_FONT_SIZE + 4;
615
+
616
+ const groupStatusColor = group.status
617
+ ? statusColor(group.status, palette, isDark)
618
+ : palette.textMuted;
619
+ // More subdued than nodes: 15% status color vs 30% for nodes
620
+ const fillColor = mix(groupStatusColor, isDark ? palette.surface : palette.bg, 15);
621
+ const strokeColor = mix(groupStatusColor, palette.textMuted, 50);
622
+
623
+ const groupG = contentG
624
+ .append('g')
625
+ .attr('class', 'is-group')
626
+ .attr('data-line-number', String(group.lineNumber));
627
+
628
+ groupG
629
+ .append('rect')
630
+ .attr('x', gx)
631
+ .attr('y', gy)
632
+ .attr('width', gw)
633
+ .attr('height', gh)
634
+ .attr('rx', 6)
635
+ .attr('fill', fillColor)
636
+ .attr('stroke', strokeColor)
637
+ .attr('stroke-width', 1)
638
+ .attr('stroke-opacity', 0.5);
639
+
640
+ groupG
641
+ .append('text')
642
+ .attr('x', gx + 8)
643
+ .attr('y', gy + GROUP_LABEL_FONT_SIZE + 4)
644
+ .attr('fill', strokeColor)
645
+ .attr('font-size', GROUP_LABEL_FONT_SIZE)
646
+ .attr('font-weight', 'bold')
647
+ .attr('opacity', 0.7)
648
+ .attr('class', 'is-group-label')
649
+ .text(group.label);
650
+
651
+ if (onClickItem) {
652
+ groupG.style('cursor', 'pointer').on('click', () => {
653
+ onClickItem(group.lineNumber);
654
+ });
655
+ }
656
+ }
657
+
658
+ // Render edges (below nodes)
659
+ for (let ei = 0; ei < layout.edges.length; ei++) {
660
+ const edge = layout.edges[ei];
661
+ if (edge.points.length < 2) continue;
662
+ const edgeColor = edgeStrokeColor(edge.status, palette, isDark);
663
+ const markerId = `is-arrow-${edgeColor.replace('#', '')}`;
664
+
665
+ const edgeG = contentG
666
+ .append('g')
667
+ .attr('class', 'is-edge-group')
668
+ .attr('data-line-number', String(edge.lineNumber));
669
+
670
+ const pathD = lineGenerator(edge.points);
671
+ if (pathD) {
672
+ edgeG
673
+ .append('path')
674
+ .attr('d', pathD)
675
+ .attr('fill', 'none')
676
+ .attr('stroke', edgeColor)
677
+ .attr('stroke-width', EDGE_STROKE_WIDTH)
678
+ .attr('marker-end', `url(#${markerId})`)
679
+ .attr('class', 'is-edge');
680
+ }
681
+
682
+ // Edge label placed on its own path
683
+ const lp = labelMap.get(ei);
684
+ if (edge.label && lp) {
685
+ edgeG
686
+ .append('rect')
687
+ .attr('x', lp.x - lp.w / 2)
688
+ .attr('y', lp.y - lp.h / 2 - 1)
689
+ .attr('width', lp.w)
690
+ .attr('height', lp.h)
691
+ .attr('rx', 3)
692
+ .attr('fill', palette.bg)
693
+ .attr('opacity', 0.9)
694
+ .attr('class', 'is-edge-label-bg');
695
+
696
+ edgeG
697
+ .append('text')
698
+ .attr('x', lp.x)
699
+ .attr('y', lp.y + 4)
700
+ .attr('text-anchor', 'middle')
701
+ .attr('fill', edgeColor)
702
+ .attr('font-size', EDGE_LABEL_FONT_SIZE)
703
+ .attr('class', 'is-edge-label')
704
+ .text(edge.label);
705
+ }
706
+
707
+ if (onClickItem) {
708
+ edgeG.style('cursor', 'pointer').on('click', () => {
709
+ onClickItem(edge.lineNumber);
710
+ });
711
+ }
712
+ }
713
+
714
+ // Render nodes (top layer)
715
+ for (const node of layout.nodes) {
716
+ const nodeG = contentG
717
+ .append('g')
718
+ .attr('transform', `translate(${node.x}, ${node.y})`)
719
+ .attr('class', 'is-node')
720
+ .attr('data-line-number', String(node.lineNumber));
721
+
722
+ if (onClickItem) {
723
+ nodeG.style('cursor', 'pointer').on('click', () => {
724
+ onClickItem(node.lineNumber);
725
+ });
726
+ }
727
+
728
+ // Transparent hit-area rect — ensures the full bounding box captures
729
+ // clicks for shapes with gaps (actors, frontends, databases, etc.)
730
+ nodeG
731
+ .append('rect')
732
+ .attr('x', -node.width / 2)
733
+ .attr('y', -node.height / 2)
734
+ .attr('width', node.width)
735
+ .attr('height', node.height)
736
+ .attr('fill', 'transparent')
737
+ .attr('class', 'is-node-hit-area');
738
+
739
+ const fill = nodeFill(node.status, palette, isDark);
740
+ const stroke = nodeStroke(node.status, palette, isDark);
741
+ renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
742
+
743
+ // Label placement: actors put label below the figure, others center inside
744
+ const isActor = node.shape === 'actor';
745
+ if (isActor) {
746
+ const textColor = nodeTextColor(node.status, palette, isDark);
747
+ const fitted = fitTextToNode(node.label, node.width, node.height * 0.35);
748
+ const labelY = node.height / 2 - fitted.fontSize * 0.3;
749
+ for (let li = 0; li < fitted.lines.length; li++) {
750
+ nodeG
751
+ .append('text')
752
+ .attr('x', 0)
753
+ .attr('y', labelY + li * fitted.fontSize * 1.3)
754
+ .attr('text-anchor', 'middle')
755
+ .attr('dominant-baseline', 'central')
756
+ .attr('fill', textColor)
757
+ .attr('font-size', fitted.fontSize)
758
+ .attr('font-weight', '600')
759
+ .text(fitted.lines[li]);
760
+ }
761
+ } else {
762
+ const fitted = fitTextToNode(node.label, node.width, node.height);
763
+ const textColor = nodeTextColor(node.status, palette, isDark);
764
+ const totalTextHeight = fitted.lines.length * fitted.fontSize * 1.3;
765
+ const startY = -totalTextHeight / 2 + fitted.fontSize * 0.65;
766
+
767
+ for (let li = 0; li < fitted.lines.length; li++) {
768
+ nodeG
769
+ .append('text')
770
+ .attr('x', 0)
771
+ .attr('y', startY + li * fitted.fontSize * 1.3)
772
+ .attr('text-anchor', 'middle')
773
+ .attr('dominant-baseline', 'central')
774
+ .attr('fill', textColor)
775
+ .attr('font-size', fitted.fontSize)
776
+ .attr('font-weight', '600')
777
+ .text(fitted.lines[li]);
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ // ============================================================
784
+ // Export convenience function
785
+ // ============================================================
786
+
787
+ export function renderInitiativeStatusForExport(
788
+ content: string,
789
+ theme: 'light' | 'dark' | 'transparent',
790
+ palette: PaletteColors
791
+ ): string {
792
+ const parsed = parseInitiativeStatus(content);
793
+ if (parsed.error || parsed.nodes.length === 0) return '';
794
+
795
+ const layout = layoutInitiativeStatus(parsed);
796
+ const isDark = theme === 'dark';
797
+
798
+ const titleOffset = parsed.title ? 40 : 0;
799
+ const exportWidth = layout.width + DIAGRAM_PADDING * 2;
800
+ const exportHeight = layout.height + DIAGRAM_PADDING * 2 + titleOffset;
801
+
802
+ const container = document.createElement('div');
803
+ container.style.width = `${exportWidth}px`;
804
+ container.style.height = `${exportHeight}px`;
805
+ container.style.position = 'absolute';
806
+ container.style.left = '-9999px';
807
+ document.body.appendChild(container);
808
+
809
+ try {
810
+ renderInitiativeStatus(
811
+ container,
812
+ parsed,
813
+ layout,
814
+ palette,
815
+ isDark,
816
+ undefined,
817
+ { width: exportWidth, height: exportHeight }
818
+ );
819
+
820
+ const svgEl = container.querySelector('svg');
821
+ if (!svgEl) return '';
822
+
823
+ if (theme === 'transparent') {
824
+ svgEl.style.background = 'none';
825
+ }
826
+
827
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
828
+ svgEl.style.fontFamily = FONT_FAMILY;
829
+
830
+ return svgEl.outerHTML;
831
+ } finally {
832
+ document.body.removeChild(container);
833
+ }
834
+ }