@diagrammo/dgmo 0.8.21 → 0.8.22

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 (93) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +143 -93
  4. package/dist/editor.cjs +17 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +17 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +12 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +12 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +19997 -14886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +331 -8
  15. package/dist/index.d.ts +331 -8
  16. package/dist/index.js +19984 -14889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-sitemap.md +18 -1
  19. package/docs/guide/chart-tech-radar.md +219 -0
  20. package/docs/guide/registry.json +1 -0
  21. package/docs/language-reference.md +116 -6
  22. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  23. package/gallery/fixtures/c4-full.dgmo +2 -2
  24. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  25. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  26. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  27. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  28. package/gallery/fixtures/gantt-full.dgmo +2 -2
  29. package/gallery/fixtures/gantt.dgmo +2 -2
  30. package/gallery/fixtures/infra-full.dgmo +2 -2
  31. package/gallery/fixtures/infra.dgmo +1 -1
  32. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  33. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  34. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  35. package/gallery/fixtures/tech-radar.dgmo +36 -0
  36. package/gallery/fixtures/timeline.dgmo +1 -1
  37. package/package.json +1 -1
  38. package/src/boxes-and-lines/layout.ts +309 -33
  39. package/src/boxes-and-lines/parser.ts +86 -10
  40. package/src/boxes-and-lines/renderer.ts +250 -91
  41. package/src/boxes-and-lines/types.ts +1 -1
  42. package/src/c4/layout.ts +8 -8
  43. package/src/c4/parser.ts +35 -2
  44. package/src/c4/renderer.ts +19 -3
  45. package/src/c4/types.ts +1 -0
  46. package/src/chart.ts +14 -7
  47. package/src/completion.ts +227 -0
  48. package/src/cycle/layout.ts +732 -0
  49. package/src/cycle/parser.ts +352 -0
  50. package/src/cycle/renderer.ts +539 -0
  51. package/src/cycle/types.ts +77 -0
  52. package/src/d3.ts +87 -8
  53. package/src/dgmo-router.ts +9 -0
  54. package/src/echarts.ts +7 -4
  55. package/src/editor/dgmo.grammar +5 -1
  56. package/src/editor/dgmo.grammar.js +1 -1
  57. package/src/editor/keywords.ts +14 -0
  58. package/src/gantt/parser.ts +2 -8
  59. package/src/graph/flowchart-parser.ts +15 -21
  60. package/src/graph/state-parser.ts +5 -10
  61. package/src/index.ts +50 -0
  62. package/src/infra/layout.ts +218 -74
  63. package/src/infra/parser.ts +30 -6
  64. package/src/infra/renderer.ts +14 -8
  65. package/src/infra/types.ts +10 -3
  66. package/src/journey-map/layout.ts +386 -0
  67. package/src/journey-map/parser.ts +540 -0
  68. package/src/journey-map/renderer.ts +1456 -0
  69. package/src/journey-map/types.ts +47 -0
  70. package/src/kanban/parser.ts +3 -10
  71. package/src/kanban/renderer.ts +31 -15
  72. package/src/mindmap/parser.ts +12 -18
  73. package/src/mindmap/renderer.ts +14 -13
  74. package/src/mindmap/text-wrap.ts +22 -12
  75. package/src/mindmap/types.ts +2 -2
  76. package/src/org/parser.ts +2 -6
  77. package/src/sequence/renderer.ts +144 -38
  78. package/src/sharing.ts +1 -0
  79. package/src/sitemap/layout.ts +21 -6
  80. package/src/sitemap/parser.ts +26 -17
  81. package/src/sitemap/renderer.ts +34 -0
  82. package/src/sitemap/types.ts +1 -0
  83. package/src/tech-radar/index.ts +14 -0
  84. package/src/tech-radar/interactive.ts +1058 -0
  85. package/src/tech-radar/layout.ts +190 -0
  86. package/src/tech-radar/parser.ts +385 -0
  87. package/src/tech-radar/renderer.ts +1159 -0
  88. package/src/tech-radar/shared.ts +187 -0
  89. package/src/tech-radar/types.ts +81 -0
  90. package/src/utils/description-helpers.ts +33 -0
  91. package/src/utils/legend-layout.ts +3 -1
  92. package/src/utils/parsing.ts +46 -7
  93. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,1159 @@
1
+ import * as d3Selection from 'd3-selection';
2
+ import { FONT_FAMILY } from '../fonts';
3
+ import { mix } from '../palettes/color-utils';
4
+ import type { PaletteColors } from '../palettes';
5
+ import type { D3ExportDimensions } from '../utils/d3-types';
6
+ import type { CompactViewState } from '../sharing';
7
+ import { parseInlineMarkdown } from '../utils/inline-markdown';
8
+ import type {
9
+ ParsedTechRadar,
10
+ QuadrantPosition,
11
+ TechRadarRenderOptions,
12
+ } from './types';
13
+ import {
14
+ computeRadarLayout,
15
+ getRadarGeometry,
16
+ getQuadrantArc,
17
+ POSITION_ORDER,
18
+ } from './layout';
19
+ import {
20
+ resolveQuadrantColor,
21
+ renderTrendIndicator,
22
+ DIM_OPACITY,
23
+ TREND_ITEMS,
24
+ } from './shared';
25
+ import { renderQuadrantFocus } from './interactive';
26
+ import { renderLegendD3 } from '../utils/legend-d3';
27
+ import { LEGEND_HEIGHT } from '../utils/legend-constants';
28
+ import type {
29
+ LegendConfig,
30
+ LegendState,
31
+ LegendCallbacks,
32
+ LegendPalette,
33
+ } from '../utils/legend-types';
34
+
35
+ // ============================================================
36
+ // Constants
37
+ // ============================================================
38
+
39
+ const BLIP_RADIUS = 12;
40
+ const BLIP_FONT_SIZE = 9;
41
+ const RING_LABEL_FONT_SIZE = 13;
42
+ const QUADRANT_LABEL_FONT_SIZE = 18;
43
+ const TITLE_FONT_SIZE = 18;
44
+ const LISTING_FONT_SIZE = 12;
45
+ const LISTING_HEADER_FONT_SIZE = 13;
46
+ const LISTING_TOP_MARGIN = 24;
47
+ const LISTING_COL_GAP = 16;
48
+ const LISTING_LINE_HEIGHT = 24;
49
+
50
+ // ============================================================
51
+ // SVG Init (local, matches d3.ts pattern)
52
+ // ============================================================
53
+
54
+ function initRadarSvg(
55
+ container: HTMLDivElement,
56
+ palette: PaletteColors,
57
+ exportDims?: D3ExportDimensions
58
+ ): {
59
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
60
+ width: number;
61
+ height: number;
62
+ textColor: string;
63
+ mutedColor: string;
64
+ bgColor: string;
65
+ } | null {
66
+ d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
67
+ const width = exportDims?.width ?? container.clientWidth;
68
+ const height = exportDims?.height ?? container.clientHeight;
69
+ if (width <= 0 || height <= 0) return null;
70
+ const textColor = palette.text;
71
+ const mutedColor = palette.border;
72
+ const bgColor = palette.bg;
73
+ const svg = d3Selection
74
+ .select(container)
75
+ .append('svg')
76
+ .attr('width', width)
77
+ .attr('height', height)
78
+ .style('background', bgColor);
79
+ return { svg, width, height, textColor, mutedColor, bgColor };
80
+ }
81
+
82
+ // ============================================================
83
+ // Main Renderer
84
+ // ============================================================
85
+
86
+ export function renderTechRadar(
87
+ container: HTMLDivElement,
88
+ parsed: ParsedTechRadar,
89
+ palette: PaletteColors,
90
+ isDark: boolean,
91
+ onClickItem?: (lineNumber: number) => void,
92
+ exportDims?: D3ExportDimensions,
93
+ viewState?: CompactViewState,
94
+ options?: TechRadarRenderOptions
95
+ ): void {
96
+ if (parsed.quadrants.length === 0 || parsed.rings.length === 0) return;
97
+
98
+ // If a quadrant is focused, delegate to the interactive module
99
+ // (but NOT for export — always export the full radar with blip legend)
100
+ if (viewState?.rq && !exportDims) {
101
+ renderQuadrantFocus(
102
+ container,
103
+ parsed,
104
+ viewState.rq as QuadrantPosition,
105
+ palette,
106
+ isDark,
107
+ onClickItem,
108
+ exportDims,
109
+ options
110
+ );
111
+ return;
112
+ }
113
+
114
+ // Determine if listing is visible — always show for export (blip legend is essential)
115
+ const showListing = exportDims ? true : (options?.showListing ?? false);
116
+ const listingHeight = showListing ? estimateListingHeight(parsed) : 0;
117
+
118
+ const init = initRadarSvg(container, palette, exportDims);
119
+ if (!init) return;
120
+ const { svg, width, height, textColor, mutedColor } = init;
121
+
122
+ const radarHeight = Math.max(
123
+ 200,
124
+ height - listingHeight - (showListing ? LISTING_TOP_MARGIN : 0)
125
+ );
126
+ const radarWidth = width;
127
+
128
+ // ── Title ──
129
+ const titleY = 24;
130
+ if (parsed.title) {
131
+ svg
132
+ .append('text')
133
+ .attr('x', radarWidth / 2)
134
+ .attr('y', titleY)
135
+ .attr('text-anchor', 'middle')
136
+ .attr('fill', textColor)
137
+ .attr('font-family', FONT_FAMILY)
138
+ .attr('font-size', TITLE_FONT_SIZE)
139
+ .attr('font-weight', 'bold')
140
+ .text(parsed.title);
141
+ }
142
+
143
+ // ── Legend controls (centered, standard legend system) ──
144
+ let legendReservedHeight = 0;
145
+ if (!exportDims && options?.onToggleListing) {
146
+ const legendY = parsed.title ? titleY + 8 : 4;
147
+ const legendG = svg
148
+ .append('g')
149
+ .attr('transform', `translate(0, ${legendY})`);
150
+
151
+ const legendConfig: LegendConfig = {
152
+ groups: [
153
+ {
154
+ name: 'Trends',
155
+ entries: TREND_ITEMS.map((item) => ({
156
+ value: item.label,
157
+ color: palette.textMuted,
158
+ })),
159
+ },
160
+ ],
161
+ position: { placement: 'top-center', titleRelation: 'below-title' },
162
+ mode: 'fixed',
163
+ controlsGroup: {
164
+ toggles: [
165
+ {
166
+ id: 'blip-legend',
167
+ type: 'toggle',
168
+ label: 'Blip Legend',
169
+ active: showListing,
170
+ onToggle: (active: boolean) => options.onToggleListing!(active),
171
+ },
172
+ ],
173
+ },
174
+ };
175
+ const legendState: LegendState = {
176
+ activeGroup: options?.activeLegendGroup ?? null,
177
+ controlsExpanded: options.controlsExpanded,
178
+ };
179
+ const legendPalette: LegendPalette = {
180
+ text: palette.text,
181
+ textMuted: palette.textMuted,
182
+ bg: palette.bg,
183
+ surface: palette.surface,
184
+ primary: palette.primary,
185
+ };
186
+ const legendCallbacks: LegendCallbacks = {
187
+ onGroupToggle: options.onLegendGroupToggle,
188
+ onControlsExpand: options.onToggleControlsExpand,
189
+ onControlsToggle: (id, active) => {
190
+ if (id === 'blip-legend' && options.onToggleListing) {
191
+ options.onToggleListing(active);
192
+ }
193
+ },
194
+ onEntryHover: (_groupName, entryValue) => {
195
+ if (!entryValue) {
196
+ // Hover out — restore all
197
+ svg
198
+ .selectAll<SVGElement, unknown>('[data-trend]')
199
+ .style('opacity', '1');
200
+ return;
201
+ }
202
+ // Map entry label back to trend value
203
+ const item = TREND_ITEMS.find((t) => t.label === entryValue);
204
+ if (!item) return;
205
+ const trendVal = item.trend ?? 'stable';
206
+ svg
207
+ .selectAll<SVGElement, unknown>('[data-trend]')
208
+ .style('opacity', function () {
209
+ return this.getAttribute('data-trend') === trendVal
210
+ ? '1'
211
+ : String(DIM_OPACITY);
212
+ });
213
+ },
214
+ };
215
+
216
+ renderLegendD3(
217
+ legendG,
218
+ legendConfig,
219
+ legendState,
220
+ legendPalette,
221
+ isDark,
222
+ legendCallbacks,
223
+ width
224
+ );
225
+ legendReservedHeight = LEGEND_HEIGHT + 8;
226
+ }
227
+
228
+ const radarTop = (parsed.title ? titleY + 16 : 8) + legendReservedHeight;
229
+ const radarAreaHeight = radarHeight - radarTop;
230
+ const radarAreaWidth = radarWidth;
231
+
232
+ const { cx, cy, maxRadius, ringBandWidth } = getRadarGeometry(
233
+ radarAreaWidth,
234
+ radarAreaHeight,
235
+ parsed.rings.length
236
+ );
237
+ const offsetY = radarTop;
238
+
239
+ const radarGroup = svg
240
+ .append('g')
241
+ .attr('transform', `translate(0, ${offsetY})`);
242
+
243
+ // ── Ring segments (per quadrant arc slices for hover highlighting) ──
244
+ for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
245
+ const innerR = ri * ringBandWidth;
246
+ const outerR = (ri + 1) * ringBandWidth;
247
+ const fillColor =
248
+ ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
249
+ const ringName = parsed.rings[ri].name;
250
+
251
+ for (const quadrant of parsed.quadrants) {
252
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
253
+ const path = buildArcSlicePath(
254
+ cx,
255
+ cy,
256
+ innerR,
257
+ outerR,
258
+ startAngle,
259
+ endAngle
260
+ );
261
+
262
+ radarGroup
263
+ .append('path')
264
+ .attr('d', path)
265
+ .attr('fill', fillColor)
266
+ .attr('stroke', mutedColor)
267
+ .attr('stroke-width', 0.5)
268
+ .attr('data-ring-segment', '')
269
+ .attr('data-quadrant', quadrant.position)
270
+ .attr('data-ring', ringName);
271
+ }
272
+ }
273
+
274
+ // ── Quadrant divider lines ──
275
+ radarGroup
276
+ .append('line')
277
+ .attr('x1', cx - maxRadius)
278
+ .attr('y1', cy)
279
+ .attr('x2', cx + maxRadius)
280
+ .attr('y2', cy)
281
+ .attr('stroke', mutedColor)
282
+ .attr('stroke-width', 1);
283
+ radarGroup
284
+ .append('line')
285
+ .attr('x1', cx)
286
+ .attr('y1', cy - maxRadius)
287
+ .attr('x2', cx)
288
+ .attr('y2', cy + maxRadius)
289
+ .attr('stroke', mutedColor)
290
+ .attr('stroke-width', 1);
291
+
292
+ // ── Ring labels (along vertical axis, centered — avoids horizontal collision) ──
293
+ for (let ri = 0; ri < parsed.rings.length; ri++) {
294
+ const rCenter = (ri + 0.5) * ringBandWidth;
295
+
296
+ if (ri === 0) {
297
+ // Innermost ring: dead center
298
+ radarGroup
299
+ .append('text')
300
+ .attr('x', cx)
301
+ .attr('y', cy)
302
+ .attr('text-anchor', 'middle')
303
+ .attr('dominant-baseline', 'central')
304
+ .attr('fill', textColor)
305
+ .attr('font-family', FONT_FAMILY)
306
+ .attr('font-size', RING_LABEL_FONT_SIZE)
307
+ .attr('font-weight', '600')
308
+ .attr('opacity', 0.5)
309
+ .text(parsed.rings[ri].name);
310
+ } else {
311
+ // Above center
312
+ radarGroup
313
+ .append('text')
314
+ .attr('x', cx)
315
+ .attr('y', cy - rCenter)
316
+ .attr('text-anchor', 'middle')
317
+ .attr('dominant-baseline', 'central')
318
+ .attr('fill', textColor)
319
+ .attr('font-family', FONT_FAMILY)
320
+ .attr('font-size', RING_LABEL_FONT_SIZE)
321
+ .attr('font-weight', '600')
322
+ .attr('opacity', 0.5)
323
+ .text(parsed.rings[ri].name);
324
+
325
+ // Below center (mirrored)
326
+ radarGroup
327
+ .append('text')
328
+ .attr('x', cx)
329
+ .attr('y', cy + rCenter)
330
+ .attr('text-anchor', 'middle')
331
+ .attr('dominant-baseline', 'central')
332
+ .attr('fill', textColor)
333
+ .attr('font-family', FONT_FAMILY)
334
+ .attr('font-size', RING_LABEL_FONT_SIZE)
335
+ .attr('font-weight', '600')
336
+ .attr('opacity', 0.5)
337
+ .text(parsed.rings[ri].name);
338
+ }
339
+ }
340
+
341
+ // ── Quadrant labels in corners ──
342
+ for (const quadrant of parsed.quadrants) {
343
+ const qColor = resolveQuadrantColor(
344
+ quadrant.position,
345
+ quadrant.color,
346
+ palette
347
+ );
348
+ const {
349
+ x: labelX,
350
+ y: labelY,
351
+ anchor,
352
+ } = getQuadrantLabelPosition(quadrant.position, cx, cy, maxRadius);
353
+ const labelGroup = radarGroup
354
+ .append('g')
355
+ .attr('data-line-number', quadrant.lineNumber)
356
+ .style('cursor', 'pointer');
357
+
358
+ renderQuadrantLabel(
359
+ labelGroup,
360
+ quadrant.name,
361
+ labelX,
362
+ labelY,
363
+ anchor,
364
+ qColor,
365
+ maxRadius * 0.9
366
+ );
367
+ }
368
+
369
+ // ── Interactive ring×quadrant hover hit areas (rendered before blips so blips sit on top) ──
370
+ if (!exportDims) {
371
+ renderRingHoverAreas(
372
+ radarGroup,
373
+ svg,
374
+ parsed,
375
+ palette,
376
+ cx,
377
+ cy,
378
+ ringBandWidth,
379
+ maxRadius
380
+ );
381
+ }
382
+
383
+ // ── Compute layout and render blips ──
384
+ const layoutPoints = computeRadarLayout(
385
+ parsed,
386
+ radarAreaWidth,
387
+ radarAreaHeight
388
+ );
389
+
390
+ // Rich popover for blip details
391
+ const popover = createBlipPopover(container, palette, isDark);
392
+ let pinnedLineNum: string | null = null;
393
+
394
+ function showBlipHighlight(
395
+ lineNum: string,
396
+ bx: number,
397
+ by: number,
398
+ blipGroup: d3Selection.Selection<SVGGElement, unknown, null, undefined>
399
+ ) {
400
+ blipGroup.attr(
401
+ 'transform',
402
+ `translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
403
+ );
404
+ svg
405
+ .selectAll<SVGElement, unknown>('[data-line-number]')
406
+ .style('opacity', function () {
407
+ return this.getAttribute('data-line-number') === lineNum
408
+ ? '1'
409
+ : String(DIM_OPACITY);
410
+ });
411
+ }
412
+
413
+ function clearBlipHighlight() {
414
+ svg
415
+ .selectAll<SVGElement, unknown>('[data-line-number]')
416
+ .style('opacity', '1')
417
+ .attr('transform', null);
418
+ }
419
+
420
+ for (const point of layoutPoints) {
421
+ const quadrant = parsed.quadrants.find((q) =>
422
+ q.blips.includes(point.blip)
423
+ )!;
424
+ const qColor = resolveQuadrantColor(
425
+ quadrant.position,
426
+ quadrant.color,
427
+ palette
428
+ );
429
+
430
+ const blipGroup = radarGroup
431
+ .append('g')
432
+ .attr('data-line-number', point.blip.lineNumber)
433
+ .attr('data-quadrant', quadrant.position)
434
+ .attr('data-ring', point.blip.ring)
435
+ .attr('data-trend', point.blip.trend ?? 'stable')
436
+ .style('cursor', 'pointer');
437
+
438
+ // Angle from blip toward radar center in SVG coords (Y-down)
439
+ const angleToCenter = Math.atan2(cy - point.y, cx - point.x);
440
+
441
+ // Trend indicator + circle
442
+ renderTrendIndicator(
443
+ blipGroup,
444
+ point.blip.trend,
445
+ qColor,
446
+ point.x,
447
+ point.y,
448
+ BLIP_RADIUS,
449
+ angleToCenter
450
+ );
451
+
452
+ // Blip number
453
+ blipGroup
454
+ .append('text')
455
+ .attr('x', point.x)
456
+ .attr('y', point.y + 3)
457
+ .attr('text-anchor', 'middle')
458
+ .attr('fill', isDark ? '#000' : '#fff')
459
+ .attr('font-family', FONT_FAMILY)
460
+ .attr('font-size', BLIP_FONT_SIZE)
461
+ .attr('font-weight', 'bold')
462
+ .text(point.blip.globalNumber);
463
+
464
+ // Hover: show rich popover + highlight
465
+ const lineNum = String(point.blip.lineNumber);
466
+ const bx = point.x;
467
+ const by = point.y;
468
+ blipGroup
469
+ .on('mouseenter', (event: MouseEvent) => {
470
+ if (pinnedLineNum) return; // don't interfere with pinned popover
471
+ showBlipPopover(popover, point.blip, qColor, palette, isDark, event);
472
+ showBlipHighlight(lineNum, bx, by, blipGroup);
473
+ })
474
+ .on('mousemove', (event: MouseEvent) => {
475
+ if (pinnedLineNum) return;
476
+ positionPopover(popover, event);
477
+ })
478
+ .on('mouseleave', () => {
479
+ if (pinnedLineNum) return;
480
+ hideBlipPopover(popover);
481
+ clearBlipHighlight();
482
+ });
483
+
484
+ // Click: pin/unpin the popover (don't stopPropagation so the
485
+ // interactivity hook's document listener can fire for editor navigation)
486
+ blipGroup.on('click', (event: MouseEvent) => {
487
+ (event as MouseEvent & { _blipClick?: boolean })._blipClick = true;
488
+ if (pinnedLineNum === lineNum) {
489
+ // Unpin
490
+ pinnedLineNum = null;
491
+ hideBlipPopover(popover);
492
+ clearBlipHighlight();
493
+ } else {
494
+ // Pin this blip — enable pointer events so links are clickable
495
+ pinnedLineNum = lineNum;
496
+ showBlipPopover(popover, point.blip, qColor, palette, isDark, event);
497
+ popover.style.pointerEvents = 'auto';
498
+ showBlipHighlight(lineNum, bx, by, blipGroup);
499
+ }
500
+ });
501
+ }
502
+
503
+ // Click on empty space clears pinned popover (ignore blip clicks)
504
+ svg.on('click', (event: MouseEvent) => {
505
+ if ((event as MouseEvent & { _blipClick?: boolean })._blipClick) return;
506
+ if (pinnedLineNum) {
507
+ pinnedLineNum = null;
508
+ hideBlipPopover(popover);
509
+ clearBlipHighlight();
510
+ }
511
+ });
512
+
513
+ // ── Active line from editor cursor → show popover for that blip ──
514
+ if (options?.activeLine && !pinnedLineNum) {
515
+ const activeLn = options.activeLine;
516
+ // Find the blip that matches this line (or whose description contains this line)
517
+ for (const point of layoutPoints) {
518
+ const blip = point.blip;
519
+ const isOnBlip = blip.lineNumber === activeLn;
520
+ const isOnDesc =
521
+ blip.description.length > 0 &&
522
+ activeLn > blip.lineNumber &&
523
+ activeLn <= blip.lineNumber + blip.description.length;
524
+
525
+ if (isOnBlip || isOnDesc) {
526
+ const quadrant = parsed.quadrants.find((q) => q.blips.includes(blip))!;
527
+ const qColor = resolveQuadrantColor(
528
+ quadrant.position,
529
+ quadrant.color,
530
+ palette
531
+ );
532
+ // Show popover at the blip's position
533
+ const svgRect = (svg.node() as SVGSVGElement)?.getBoundingClientRect();
534
+ if (svgRect) {
535
+ const fakeEvent = {
536
+ clientX: svgRect.left + point.x,
537
+ clientY: svgRect.top + offsetY + point.y,
538
+ } as MouseEvent;
539
+ showBlipPopover(popover, blip, qColor, palette, isDark, fakeEvent);
540
+ }
541
+ // Scale up and dim
542
+ const lineNum = String(blip.lineNumber);
543
+ const blipEl = svg.select(`[data-line-number="${lineNum}"]`);
544
+ if (!blipEl.empty()) {
545
+ blipEl.attr(
546
+ 'transform',
547
+ `translate(${point.x},${point.y}) scale(1.5) translate(${-point.x},${-point.y})`
548
+ );
549
+ }
550
+ svg
551
+ .selectAll<SVGElement, unknown>('[data-line-number]')
552
+ .style('opacity', function () {
553
+ return this.getAttribute('data-line-number') === lineNum
554
+ ? '1'
555
+ : String(DIM_OPACITY);
556
+ });
557
+ break;
558
+ }
559
+ }
560
+ }
561
+
562
+ // ── Four-column blip listing below radar ──
563
+ if (showListing) {
564
+ renderBlipListing(
565
+ svg,
566
+ parsed,
567
+ palette,
568
+ isDark,
569
+ textColor,
570
+ radarHeight + LISTING_TOP_MARGIN,
571
+ width,
572
+ onClickItem
573
+ );
574
+ }
575
+ }
576
+
577
+ // ============================================================
578
+ // Four-Column Listing
579
+ // ============================================================
580
+
581
+ const LISTING_BLIP_R = 11;
582
+
583
+ function renderBlipListing(
584
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
585
+ parsed: ParsedTechRadar,
586
+ palette: PaletteColors,
587
+ isDark: boolean,
588
+ textColor: string,
589
+ startY: number,
590
+ totalWidth: number,
591
+ onClickItem?: (lineNumber: number) => void
592
+ ): void {
593
+ const colCount = parsed.quadrants.length;
594
+ if (colCount === 0) return;
595
+
596
+ const colWidth = (totalWidth - LISTING_COL_GAP * (colCount + 1)) / colCount;
597
+
598
+ // Sort quadrants in POSITION_ORDER
599
+ const sortedQuadrants = [...parsed.quadrants].sort(
600
+ (a, b) =>
601
+ POSITION_ORDER.indexOf(a.position) - POSITION_ORDER.indexOf(b.position)
602
+ );
603
+
604
+ for (let ci = 0; ci < sortedQuadrants.length; ci++) {
605
+ const quadrant = sortedQuadrants[ci];
606
+ const qColor = resolveQuadrantColor(
607
+ quadrant.position,
608
+ quadrant.color,
609
+ palette
610
+ );
611
+ const colX = LISTING_COL_GAP + ci * (colWidth + LISTING_COL_GAP);
612
+ let y = startY;
613
+
614
+ // Column header — hover highlights entire quadrant on radar
615
+ const headerText = svg
616
+ .append('text')
617
+ .attr('x', colX)
618
+ .attr('y', y)
619
+ .attr('fill', qColor)
620
+ .attr('font-family', FONT_FAMILY)
621
+ .attr('font-size', LISTING_HEADER_FONT_SIZE)
622
+ .attr('font-weight', 'bold')
623
+ .style('cursor', 'pointer')
624
+ .text(quadrant.name);
625
+
626
+ const qPos = quadrant.position;
627
+ headerText
628
+ .on('mouseenter', () => {
629
+ // Dim everything except this quadrant's blips + ring segments
630
+ svg
631
+ .selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
632
+ .style('opacity', function () {
633
+ return this.getAttribute('data-quadrant') === qPos
634
+ ? '1'
635
+ : String(DIM_OPACITY);
636
+ });
637
+ })
638
+ .on('mouseleave', () => {
639
+ svg
640
+ .selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
641
+ .style('opacity', '1');
642
+ });
643
+
644
+ y += LISTING_LINE_HEIGHT + 6;
645
+
646
+ // Sort blips by globalNumber
647
+ const sortedBlips = [...quadrant.blips].sort(
648
+ (a, b) => a.globalNumber - b.globalNumber
649
+ );
650
+
651
+ for (const blip of sortedBlips) {
652
+ const itemGroup = svg
653
+ .append('g')
654
+ .attr('data-line-number', blip.lineNumber)
655
+ .attr('data-quadrant', quadrant.position)
656
+ .attr('data-ring', blip.ring)
657
+ .attr('data-trend', blip.trend ?? 'stable')
658
+ .style('cursor', onClickItem ? 'pointer' : 'default');
659
+
660
+ const blipCx = colX + LISTING_BLIP_R;
661
+ const blipCy = y - LISTING_BLIP_R + 2;
662
+
663
+ // Mini blip circle with trend indicator
664
+ // angleToCenter convention: "up" means center is above (angle = -π/2 in SVG)
665
+ // "down" means center is below (angle = π/2 in SVG)
666
+ // The shared renderer flips for "down", so we pass the "toward center" angle
667
+ const trendAngle = -Math.PI / 2; // "center" is always up for listing context
668
+ renderTrendIndicator(
669
+ itemGroup,
670
+ blip.trend,
671
+ qColor,
672
+ blipCx,
673
+ blipCy,
674
+ LISTING_BLIP_R,
675
+ trendAngle
676
+ );
677
+
678
+ // Number inside the circle
679
+ itemGroup
680
+ .append('text')
681
+ .attr('x', blipCx)
682
+ .attr('y', blipCy + 3)
683
+ .attr('text-anchor', 'middle')
684
+ .attr('fill', isDark ? '#000' : '#fff')
685
+ .attr('font-family', FONT_FAMILY)
686
+ .attr('font-size', 9)
687
+ .attr('font-weight', 'bold')
688
+ .text(blip.globalNumber);
689
+
690
+ // Blip name + ring — truncated to fit column width
691
+ const textX = colX + LISTING_BLIP_R * 2 + 6;
692
+ const availableWidth = colWidth - LISTING_BLIP_R * 2 - 8;
693
+ const fullLabel = `${blip.name} (${blip.ring})`;
694
+ const label = truncateLabel(fullLabel, availableWidth, LISTING_FONT_SIZE);
695
+
696
+ itemGroup
697
+ .append('text')
698
+ .attr('x', textX)
699
+ .attr('y', y)
700
+ .attr('fill', textColor)
701
+ .attr('font-family', FONT_FAMILY)
702
+ .attr('font-size', LISTING_FONT_SIZE)
703
+ .text(label);
704
+
705
+ // Cross-highlight: hover listing blip → highlight + scale up radar blip
706
+ const ln = String(blip.lineNumber);
707
+ itemGroup
708
+ .on('mouseenter', () => {
709
+ svg
710
+ .selectAll<SVGElement, unknown>('[data-line-number]')
711
+ .style('opacity', function () {
712
+ const isMatch = this.getAttribute('data-line-number') === ln;
713
+ // Scale up the matching radar blip (not listing items)
714
+ if (
715
+ isMatch &&
716
+ this.getAttribute('data-quadrant') &&
717
+ this.closest('g[transform]')
718
+ ) {
719
+ const bbox = (this as SVGGraphicsElement).getBBox?.();
720
+ if (bbox) {
721
+ const bx = bbox.x + bbox.width / 2;
722
+ const by = bbox.y + bbox.height / 2;
723
+ this.setAttribute(
724
+ 'transform',
725
+ `translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
726
+ );
727
+ }
728
+ }
729
+ return isMatch ? '1' : String(DIM_OPACITY);
730
+ });
731
+ })
732
+ .on('mouseleave', () => {
733
+ svg
734
+ .selectAll<SVGElement, unknown>('[data-line-number]')
735
+ .style('opacity', '1')
736
+ .attr('transform', null);
737
+ });
738
+
739
+ if (onClickItem) {
740
+ itemGroup.on('click', () => onClickItem(blip.lineNumber));
741
+ }
742
+
743
+ y += LISTING_LINE_HEIGHT;
744
+ }
745
+ }
746
+ }
747
+
748
+ /** Estimate max characters that fit in `availablePx` at the given font size. */
749
+ function truncateLabel(
750
+ text: string,
751
+ availablePx: number,
752
+ fontSize: number
753
+ ): string {
754
+ // Average character width ≈ 0.58 × fontSize for Helvetica/Inter
755
+ const avgCharWidth = fontSize * 0.58;
756
+ const maxChars = Math.floor(availablePx / avgCharWidth);
757
+ if (maxChars <= 0) return '';
758
+ if (text.length <= maxChars) return text;
759
+ return text.substring(0, maxChars - 1) + '\u2026';
760
+ }
761
+
762
+ // ============================================================
763
+ // Ring×Quadrant Hover Interactivity
764
+ // ============================================================
765
+
766
+ /**
767
+ * Render transparent arc hit areas for each ring×quadrant slice.
768
+ * On hover, dims all blips (radar + listing) except those in the hovered slice.
769
+ */
770
+ function renderRingHoverAreas(
771
+ radarGroup: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
772
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
773
+ parsed: ParsedTechRadar,
774
+ palette: PaletteColors,
775
+ cx: number,
776
+ cy: number,
777
+ ringBandWidth: number,
778
+ _maxRadius: number
779
+ ): void {
780
+ for (const quadrant of parsed.quadrants) {
781
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
782
+ const qColor = resolveQuadrantColor(
783
+ quadrant.position,
784
+ quadrant.color,
785
+ palette
786
+ );
787
+
788
+ for (let ri = 0; ri < parsed.rings.length; ri++) {
789
+ const innerR = ri * ringBandWidth;
790
+ const outerR = (ri + 1) * ringBandWidth;
791
+ const ringName = parsed.rings[ri].name;
792
+
793
+ const path = buildArcSlicePath(
794
+ cx,
795
+ cy,
796
+ innerR,
797
+ outerR,
798
+ startAngle,
799
+ endAngle
800
+ );
801
+
802
+ const hitArea = radarGroup
803
+ .append('path')
804
+ .attr('d', path)
805
+ .attr('fill', 'transparent')
806
+ .style('cursor', 'pointer');
807
+
808
+ hitArea
809
+ .on('mouseenter', () => {
810
+ // Tint the hovered slice via the overlay
811
+ hitArea.attr('fill', qColor).attr('opacity', 0.15);
812
+ // Dim all blips/listing except matching ring+quadrant
813
+ svg
814
+ .selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
815
+ .style('opacity', function () {
816
+ const q = this.getAttribute('data-quadrant');
817
+ const r = this.getAttribute('data-ring');
818
+ return q === quadrant.position && r === ringName
819
+ ? '1'
820
+ : String(DIM_OPACITY);
821
+ });
822
+ })
823
+ .on('mouseleave', () => {
824
+ // Remove overlay tint
825
+ hitArea.attr('fill', 'transparent').attr('opacity', 1);
826
+ // Restore all opacities
827
+ svg
828
+ .selectAll<SVGElement, unknown>('[data-quadrant][data-ring]')
829
+ .style('opacity', '1');
830
+ });
831
+ }
832
+ }
833
+ }
834
+
835
+ /** Build an SVG arc-slice path between inner and outer radius for a quadrant arc. */
836
+ function buildArcSlicePath(
837
+ cx: number,
838
+ cy: number,
839
+ innerR: number,
840
+ outerR: number,
841
+ startAngle: number,
842
+ endAngle: number
843
+ ): string {
844
+ // Convert math angles to SVG coordinates (negate sin for Y-down)
845
+ const ox1 = cx + outerR * Math.cos(startAngle);
846
+ const oy1 = cy - outerR * Math.sin(startAngle);
847
+ const ox2 = cx + outerR * Math.cos(endAngle);
848
+ const oy2 = cy - outerR * Math.sin(endAngle);
849
+ const ix1 = cx + innerR * Math.cos(endAngle);
850
+ const iy1 = cy - innerR * Math.sin(endAngle);
851
+ const ix2 = cx + innerR * Math.cos(startAngle);
852
+ const iy2 = cy - innerR * Math.sin(startAngle);
853
+
854
+ if (innerR === 0) {
855
+ // Pie wedge from center
856
+ return `M${cx},${cy} L${ox1},${oy1} A${outerR},${outerR} 0 0,0 ${ox2},${oy2} Z`;
857
+ }
858
+
859
+ return `M${ox1},${oy1} A${outerR},${outerR} 0 0,0 ${ox2},${oy2} L${ix1},${iy1} A${innerR},${innerR} 0 0,1 ${ix2},${iy2} Z`;
860
+ }
861
+
862
+ // ============================================================
863
+ // Trend Items (used by legend group entries)
864
+ // ============================================================
865
+
866
+ function estimateListingHeight(parsed: ParsedTechRadar): number {
867
+ const maxBlipsInQuadrant = Math.max(
868
+ 0,
869
+ ...parsed.quadrants.map((q) => q.blips.length)
870
+ );
871
+ return (
872
+ LISTING_LINE_HEIGHT * (maxBlipsInQuadrant + 1) +
873
+ LISTING_LINE_HEIGHT +
874
+ LISTING_TOP_MARGIN
875
+ );
876
+ }
877
+
878
+ // ============================================================
879
+ // Rich Blip Popover (B&L-style node card)
880
+ // ============================================================
881
+
882
+ import type { TechRadarBlip } from './types';
883
+
884
+ function createBlipPopover(
885
+ container: HTMLElement,
886
+ palette: PaletteColors,
887
+ isDark: boolean
888
+ ): HTMLDivElement {
889
+ container.style.position = 'relative';
890
+ const existing = container.querySelector<HTMLDivElement>(
891
+ '[data-blip-popover]'
892
+ );
893
+ if (existing) {
894
+ existing.style.display = 'none';
895
+ return existing;
896
+ }
897
+ const el = document.createElement('div');
898
+ el.setAttribute('data-blip-popover', '');
899
+ el.style.position = 'absolute';
900
+ el.style.display = 'none';
901
+ el.style.pointerEvents = 'none';
902
+ el.style.zIndex = '20';
903
+ el.style.maxWidth = '280px';
904
+ el.style.fontFamily = FONT_FAMILY;
905
+ el.style.fontSize = '12px';
906
+ el.style.lineHeight = '1.5';
907
+ el.style.borderRadius = '6px';
908
+ el.style.overflow = 'hidden';
909
+ el.style.boxShadow = isDark
910
+ ? '0 4px 12px rgba(0,0,0,0.4)'
911
+ : '0 4px 12px rgba(0,0,0,0.12)';
912
+ container.appendChild(el);
913
+ return el;
914
+ }
915
+
916
+ function showBlipPopover(
917
+ popover: HTMLDivElement,
918
+ blip: TechRadarBlip,
919
+ qColor: string,
920
+ palette: PaletteColors,
921
+ isDark: boolean,
922
+ event: MouseEvent
923
+ ): void {
924
+ const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
925
+ const hasDesc = blip.description.length > 0;
926
+
927
+ let html = `<div style="background:${fillColor}; border: 1.5px solid ${qColor}; border-radius: 6px; overflow: hidden;">`;
928
+ html += `<div style="padding: 8px 12px; font-weight: 600; color: ${palette.text};">${escapeHtml(blip.name)}</div>`;
929
+
930
+ if (hasDesc) {
931
+ html += `<div style="border-top: 1px solid ${qColor}; opacity: 0.3;"></div>`;
932
+ html += `<div style="padding: 6px 12px 8px; color: ${palette.textMuted}; font-size: 11px; line-height: 1.6;">`;
933
+ // Join consecutive prose lines into paragraphs; bullets stay separate
934
+ const paragraphs = joinDescriptionParagraphs(blip.description);
935
+ for (const para of paragraphs) {
936
+ html += renderDescriptionLine(para, palette);
937
+ }
938
+ html += `</div>`;
939
+ }
940
+
941
+ html += `</div>`;
942
+
943
+ popover.innerHTML = html;
944
+ popover.style.display = 'block';
945
+ positionPopover(popover, event);
946
+ }
947
+
948
+ function positionPopover(popover: HTMLDivElement, event: MouseEvent): void {
949
+ const container = popover.parentElement!;
950
+ const rect = container.getBoundingClientRect();
951
+ const tipW = popover.offsetWidth;
952
+ const tipH = popover.offsetHeight;
953
+ const cursorX = event.clientX - rect.left;
954
+ const cursorY = event.clientY - rect.top;
955
+ const centerX = rect.width / 2;
956
+ const centerY = rect.height / 2;
957
+
958
+ // Position toward the center of the diagram relative to the blip
959
+ let left = cursorX < centerX ? cursorX + 16 : cursorX - tipW - 16;
960
+ let top = cursorY < centerY ? cursorY + 16 : cursorY - tipH - 16;
961
+
962
+ // Clamp to container bounds
963
+ if (left + tipW > rect.width - 4) left = rect.width - tipW - 4;
964
+ if (left < 4) left = 4;
965
+ if (top + tipH > rect.height - 4) top = rect.height - tipH - 4;
966
+ if (top < 4) top = 4;
967
+
968
+ popover.style.left = `${left}px`;
969
+ popover.style.top = `${top}px`;
970
+ }
971
+
972
+ function hideBlipPopover(popover: HTMLDivElement): void {
973
+ popover.style.display = 'none';
974
+ popover.style.pointerEvents = 'none';
975
+ }
976
+
977
+ /**
978
+ * Join consecutive prose lines into single paragraphs.
979
+ * Bullets (lines starting with -, *, •) stay as separate entries.
980
+ * Blank lines create paragraph breaks.
981
+ */
982
+ function joinDescriptionParagraphs(lines: string[]): string[] {
983
+ const result: string[] = [];
984
+ let currentPara = '';
985
+
986
+ for (const line of lines) {
987
+ const trimmed = line.trim();
988
+ const isBullet = /^[-*•]\s+/.test(trimmed);
989
+
990
+ if (isBullet) {
991
+ // Flush any accumulated paragraph
992
+ if (currentPara) {
993
+ result.push(currentPara);
994
+ currentPara = '';
995
+ }
996
+ result.push(trimmed);
997
+ } else if (!trimmed) {
998
+ // Blank line — paragraph break
999
+ if (currentPara) {
1000
+ result.push(currentPara);
1001
+ currentPara = '';
1002
+ }
1003
+ } else {
1004
+ // Prose line — join with previous
1005
+ currentPara = currentPara ? `${currentPara} ${trimmed}` : trimmed;
1006
+ }
1007
+ }
1008
+
1009
+ if (currentPara) result.push(currentPara);
1010
+ return result;
1011
+ }
1012
+
1013
+ function renderDescriptionLine(line: string, palette: PaletteColors): string {
1014
+ const trimmed = line.trim();
1015
+ const isBullet = /^[-*•]\s+/.test(trimmed);
1016
+ const content = isBullet ? trimmed.replace(/^[-*•]\s+/, '') : trimmed;
1017
+
1018
+ const spans = parseInlineMarkdown(content);
1019
+ let spanHtml = '';
1020
+ for (const span of spans) {
1021
+ let text = escapeHtml(span.text);
1022
+ if (span.bold) text = `<strong>${text}</strong>`;
1023
+ if (span.italic) text = `<em>${text}</em>`;
1024
+ if (span.code)
1025
+ text = `<code style="background:${palette.surface}; padding: 1px 4px; border-radius: 3px; font-size: 10px;">${text}</code>`;
1026
+ if (span.href)
1027
+ text = `<a href="${escapeHtml(span.href)}" target="_blank" rel="noopener" style="color: ${palette.primary ?? palette.text}; text-decoration: underline;">${text}</a>`;
1028
+ spanHtml += text;
1029
+ }
1030
+
1031
+ if (isBullet) {
1032
+ return `<div style="padding-left: 12px; text-indent: -10px; margin: 1px 0;">• ${spanHtml}</div>`;
1033
+ }
1034
+ return `<div style="margin: 2px 0;">${spanHtml}</div>`;
1035
+ }
1036
+
1037
+ function escapeHtml(text: string): string {
1038
+ return text
1039
+ .replace(/&/g, '&amp;')
1040
+ .replace(/</g, '&lt;')
1041
+ .replace(/>/g, '&gt;');
1042
+ }
1043
+
1044
+ // ============================================================
1045
+ // Quadrant Label Positioning
1046
+ // ============================================================
1047
+
1048
+ /**
1049
+ * Render a quadrant label, wrapping to multiple lines if needed and
1050
+ * scaling font down if the text is too wide for the available space.
1051
+ */
1052
+ function renderQuadrantLabel(
1053
+ g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
1054
+ name: string,
1055
+ x: number,
1056
+ y: number,
1057
+ anchor: string,
1058
+ color: string,
1059
+ maxWidth: number
1060
+ ): void {
1061
+ const avgCharWidth = QUADRANT_LABEL_FONT_SIZE * 0.58;
1062
+ const maxCharsPerLine = Math.floor(maxWidth / avgCharWidth);
1063
+
1064
+ // Split into words and wrap
1065
+ const words = name.split(/\s+/);
1066
+ const lines: string[] = [];
1067
+ let currentLine = '';
1068
+
1069
+ for (const word of words) {
1070
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
1071
+ if (testLine.length > maxCharsPerLine && currentLine) {
1072
+ lines.push(currentLine);
1073
+ currentLine = word;
1074
+ } else {
1075
+ currentLine = testLine;
1076
+ }
1077
+ }
1078
+ if (currentLine) lines.push(currentLine);
1079
+
1080
+ // Scale font down if any line is still too wide
1081
+ const longestLine = Math.max(...lines.map((l) => l.length));
1082
+ const estimatedWidth = longestLine * avgCharWidth;
1083
+ const fontSize =
1084
+ estimatedWidth > maxWidth
1085
+ ? Math.max(12, QUADRANT_LABEL_FONT_SIZE * (maxWidth / estimatedWidth))
1086
+ : QUADRANT_LABEL_FONT_SIZE;
1087
+
1088
+ const lineHeight = fontSize * 1.2;
1089
+
1090
+ for (let i = 0; i < lines.length; i++) {
1091
+ g.append('text')
1092
+ .attr('x', x)
1093
+ .attr('y', y + i * lineHeight)
1094
+ .attr('text-anchor', anchor)
1095
+ .attr('fill', color)
1096
+ .attr('font-family', FONT_FAMILY)
1097
+ .attr('font-size', fontSize)
1098
+ .attr('font-weight', 'bold')
1099
+ .text(lines[i]);
1100
+ }
1101
+ }
1102
+
1103
+ function getQuadrantLabelPosition(
1104
+ position: QuadrantPosition,
1105
+ cx: number,
1106
+ cy: number,
1107
+ maxRadius: number
1108
+ ): { x: number; y: number; anchor: string } {
1109
+ const margin = 8;
1110
+ switch (position) {
1111
+ case 'top-left':
1112
+ return {
1113
+ x: cx - maxRadius + margin,
1114
+ y: cy - maxRadius + 16,
1115
+ anchor: 'start',
1116
+ };
1117
+ case 'top-right':
1118
+ return {
1119
+ x: cx + maxRadius - margin,
1120
+ y: cy - maxRadius + 16,
1121
+ anchor: 'end',
1122
+ };
1123
+ case 'bottom-left':
1124
+ return {
1125
+ x: cx - maxRadius + margin,
1126
+ y: cy + maxRadius - 8,
1127
+ anchor: 'start',
1128
+ };
1129
+ case 'bottom-right':
1130
+ return {
1131
+ x: cx + maxRadius - margin,
1132
+ y: cy + maxRadius - 8,
1133
+ anchor: 'end',
1134
+ };
1135
+ }
1136
+ }
1137
+
1138
+ // ============================================================
1139
+ // Export Renderer (for static SVG/PNG export)
1140
+ // ============================================================
1141
+
1142
+ export function renderTechRadarForExport(
1143
+ container: HTMLDivElement,
1144
+ parsed: ParsedTechRadar,
1145
+ palette: PaletteColors,
1146
+ isDark: boolean,
1147
+ exportDims?: D3ExportDimensions,
1148
+ viewState?: CompactViewState
1149
+ ): void {
1150
+ renderTechRadar(
1151
+ container,
1152
+ parsed,
1153
+ palette,
1154
+ isDark,
1155
+ undefined,
1156
+ exportDims,
1157
+ viewState
1158
+ );
1159
+ }