@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.
@@ -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
+ }