@diagrammo/dgmo 0.8.5 → 0.8.6

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