@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,1058 @@
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 {
7
+ ParsedTechRadar,
8
+ QuadrantPosition,
9
+ TechRadarRenderOptions,
10
+ } from './types';
11
+ import { getQuadrantArc } from './layout';
12
+ import {
13
+ resolveQuadrantColor,
14
+ renderTrendIndicator,
15
+ createTooltip,
16
+ DIM_OPACITY,
17
+ } from './shared';
18
+ import { parseInlineMarkdown } from '../utils/inline-markdown';
19
+
20
+ // ============================================================
21
+ // Constants
22
+ // ============================================================
23
+
24
+ const BLIP_RADIUS = 13;
25
+ const BLIP_FONT_SIZE = 10;
26
+ const TITLE_FONT_SIZE = 16;
27
+ const NARROW_BREAKPOINT = 600;
28
+
29
+ // ============================================================
30
+ // Quadrant Focus Renderer
31
+ // ============================================================
32
+
33
+ export function renderQuadrantFocus(
34
+ container: HTMLDivElement,
35
+ parsed: ParsedTechRadar,
36
+ quadrantPosition: QuadrantPosition,
37
+ palette: PaletteColors,
38
+ isDark: boolean,
39
+ onClickItem?: (lineNumber: number) => void,
40
+ exportDims?: D3ExportDimensions,
41
+ _options?: TechRadarRenderOptions
42
+ ): void {
43
+ const quadrant = parsed.quadrants.find(
44
+ (q) => q.position === quadrantPosition
45
+ );
46
+ if (!quadrant) return;
47
+
48
+ // Clear container
49
+ container.innerHTML = '';
50
+
51
+ const width = exportDims?.width ?? container.clientWidth;
52
+ const height = exportDims?.height ?? container.clientHeight;
53
+ if (width <= 0 || height <= 0) return;
54
+
55
+ const isNarrow = width < NARROW_BREAKPOINT;
56
+ const qColor = resolveQuadrantColor(
57
+ quadrant.position,
58
+ quadrant.color,
59
+ palette
60
+ );
61
+
62
+ // ── Breadcrumb title ──
63
+ const titleBar = document.createElement('div');
64
+ titleBar.style.cssText = `
65
+ display: flex; align-items: baseline; gap: 8px;
66
+ padding: 8px 12px; font-family: ${FONT_FAMILY};
67
+ font-size: ${TITLE_FONT_SIZE}px; background: ${palette.bg};
68
+ `;
69
+
70
+ const titleLink = document.createElement('span');
71
+ titleLink.textContent = parsed.title || 'Tech Radar';
72
+ titleLink.style.cssText = `font-weight: bold; color: ${palette.text}; cursor: pointer;`;
73
+ titleLink.setAttribute('data-line-number', String(parsed.titleLineNumber));
74
+
75
+ const sep = document.createElement('span');
76
+ sep.textContent = '›';
77
+ sep.style.color = palette.border;
78
+
79
+ const quadrantLabel = document.createElement('span');
80
+ quadrantLabel.textContent = quadrant.name;
81
+ quadrantLabel.style.cssText = `font-weight: bold; color: ${qColor};`;
82
+
83
+ titleBar.appendChild(titleLink);
84
+ titleBar.appendChild(sep);
85
+ titleBar.appendChild(quadrantLabel);
86
+ container.appendChild(titleBar);
87
+
88
+ // ── Main layout: SVG radar (left) + HTML panel (right) ──
89
+ const mainLayout = document.createElement('div');
90
+ mainLayout.style.cssText = `
91
+ display: flex; flex-direction: ${isNarrow ? 'column' : 'row'};
92
+ flex: 1; min-height: 0; height: calc(100% - 40px); background: ${palette.bg};
93
+ `;
94
+ container.appendChild(mainLayout);
95
+
96
+ // SVG container for quarter-circle
97
+ const svgContainer = document.createElement('div');
98
+ svgContainer.style.cssText = `
99
+ ${isNarrow ? 'height: 40%;' : 'width: 50%; min-width: 200px;'}
100
+ flex-shrink: 0;
101
+ `;
102
+ mainLayout.appendChild(svgContainer);
103
+
104
+ // HTML panel for blip listing
105
+ const panel = document.createElement('div');
106
+ panel.style.cssText = `
107
+ flex: 1; overflow-y: auto; padding: 8px 12px;
108
+ font-family: ${FONT_FAMILY}; background: ${palette.bg};
109
+ `;
110
+ mainLayout.appendChild(panel);
111
+
112
+ // ── Render HTML side panel first (returns toggle callback for radar clicks) ──
113
+ const toggleBlip = renderHtmlPanel(
114
+ panel,
115
+ parsed,
116
+ quadrant,
117
+ qColor,
118
+ palette,
119
+ isDark,
120
+ container,
121
+ onClickItem
122
+ );
123
+
124
+ // ── Render quarter-circle SVG ──
125
+ const svgWidth = svgContainer.clientWidth || (isNarrow ? width : width * 0.5);
126
+ const svgHeight =
127
+ svgContainer.clientHeight || (isNarrow ? height * 0.4 : height - 40);
128
+
129
+ const svg = d3Selection
130
+ .select(svgContainer)
131
+ .append('svg')
132
+ .attr('width', '100%')
133
+ .attr('height', '100%')
134
+ .attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`)
135
+ .style('background', palette.bg);
136
+
137
+ const tooltip = createTooltip(container, palette, isDark);
138
+
139
+ renderQuarterCircle(
140
+ svg,
141
+ parsed,
142
+ quadrant,
143
+ qColor,
144
+ palette,
145
+ isDark,
146
+ svgWidth,
147
+ svgHeight,
148
+ palette.border,
149
+ tooltip,
150
+ container,
151
+ toggleBlip
152
+ );
153
+
154
+ // ── Click handlers for title (back navigation) ──
155
+ titleLink.addEventListener('click', () => {
156
+ if (onClickItem) onClickItem(parsed.titleLineNumber);
157
+ });
158
+
159
+ // ── Active line from editor cursor → expand that blip in the panel ──
160
+ if (_options?.activeLine) {
161
+ const activeLn = _options.activeLine;
162
+ for (const blip of quadrant.blips) {
163
+ const isOnBlip = blip.lineNumber === activeLn;
164
+ const isOnDesc =
165
+ blip.description.length > 0 &&
166
+ activeLn > blip.lineNumber &&
167
+ activeLn <= blip.lineNumber + blip.description.length;
168
+ if (isOnBlip || isOnDesc) {
169
+ toggleBlip(blip.lineNumber);
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // ============================================================
177
+ // Quarter-Circle SVG Rendering
178
+ // ============================================================
179
+
180
+ function renderQuarterCircle(
181
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
182
+ parsed: ParsedTechRadar,
183
+ quadrant: ParsedTechRadar['quadrants'][number],
184
+ qColor: string,
185
+ palette: PaletteColors,
186
+ isDark: boolean,
187
+ width: number,
188
+ height: number,
189
+ mutedColor: string,
190
+ tooltip: HTMLDivElement,
191
+ rootContainer: HTMLElement,
192
+ onClickItem?: (lineNumber: number) => void
193
+ ): void {
194
+ const padding = 8;
195
+ const size = Math.min(width - padding, height - padding);
196
+ const maxRadius = size * 0.95;
197
+ const ringCount = parsed.rings.length;
198
+ const ringBandWidth = maxRadius / ringCount;
199
+
200
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
201
+ let cx: number, cy: number;
202
+
203
+ switch (quadrant.position) {
204
+ case 'top-right':
205
+ cx = padding;
206
+ cy = size + padding;
207
+ break;
208
+ case 'top-left':
209
+ cx = width - padding;
210
+ cy = size + padding;
211
+ break;
212
+ case 'bottom-left':
213
+ cx = width - padding;
214
+ cy = padding;
215
+ break;
216
+ case 'bottom-right':
217
+ cx = padding;
218
+ cy = padding;
219
+ break;
220
+ }
221
+
222
+ // Ring arcs with zebra shading
223
+ const arcGen = (innerR: number, outerR: number) =>
224
+ `M${cx + outerR * Math.cos(startAngle)},${cy - outerR * Math.sin(startAngle)} A${outerR},${outerR} 0 0,0 ${cx + outerR * Math.cos(endAngle)},${cy - outerR * Math.sin(endAngle)} L${cx + innerR * Math.cos(endAngle)},${cy - innerR * Math.sin(endAngle)} A${innerR},${innerR} 0 0,1 ${cx + innerR * Math.cos(startAngle)},${cy - innerR * Math.sin(startAngle)} Z`;
225
+
226
+ for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
227
+ const innerR = ri * ringBandWidth;
228
+ const outerR = (ri + 1) * ringBandWidth;
229
+ const fillColor =
230
+ ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
231
+
232
+ svg
233
+ .append('path')
234
+ .attr('d', arcGen(innerR, outerR))
235
+ .attr('fill', fillColor)
236
+ .attr('stroke', mutedColor)
237
+ .attr('stroke-width', 0.5);
238
+ }
239
+
240
+ // Ring labels removed — the side panel ring headers serve this purpose
241
+
242
+ // Blip dots
243
+ const ringOrder = parsed.rings.map((r) => r.name);
244
+ const angularPadding = 0.08;
245
+ const radialPadding = ringBandWidth * 0.12;
246
+ const usableArcStart = startAngle + angularPadding;
247
+ const usableArcEnd = endAngle - angularPadding;
248
+ const arcSpan = usableArcEnd - usableArcStart;
249
+
250
+ const blipsByRing = new Map<string, typeof quadrant.blips>();
251
+ for (const blip of quadrant.blips) {
252
+ const list = blipsByRing.get(blip.ring) ?? [];
253
+ list.push(blip);
254
+ blipsByRing.set(blip.ring, list);
255
+ }
256
+
257
+ for (const [ringName, blips] of blipsByRing) {
258
+ const ringIndex = ringOrder.indexOf(ringName);
259
+ if (ringIndex < 0) continue;
260
+ const rInner = ringIndex * ringBandWidth + radialPadding;
261
+ const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
262
+ const rMid = (rInner + rOuter) / 2;
263
+
264
+ for (let bi = 0; bi < blips.length; bi++) {
265
+ const blip = blips[bi];
266
+ const angle =
267
+ blips.length === 1
268
+ ? (usableArcStart + usableArcEnd) / 2
269
+ : usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
270
+
271
+ const radius =
272
+ blips.length <= 3
273
+ ? rMid
274
+ : rInner +
275
+ BLIP_RADIUS +
276
+ ((bi % 3) / 2) * (rOuter - rInner - BLIP_RADIUS * 2);
277
+
278
+ const bx = cx + radius * Math.cos(angle);
279
+ const by = cy - radius * Math.sin(angle);
280
+
281
+ const blipGroup = svg
282
+ .append('g')
283
+ .attr('data-line-number', blip.lineNumber)
284
+ .attr('data-ring', blip.ring)
285
+ .attr('data-trend', blip.trend ?? 'stable')
286
+ .style('cursor', 'pointer');
287
+
288
+ const angleToCenter = Math.atan2(cy - by, cx - bx);
289
+ renderTrendIndicator(
290
+ blipGroup,
291
+ blip.trend,
292
+ qColor,
293
+ bx,
294
+ by,
295
+ BLIP_RADIUS,
296
+ angleToCenter
297
+ );
298
+
299
+ blipGroup
300
+ .append('text')
301
+ .attr('x', bx)
302
+ .attr('y', by + 3)
303
+ .attr('text-anchor', 'middle')
304
+ .attr('fill', isDark ? '#000' : '#fff')
305
+ .attr('font-family', FONT_FAMILY)
306
+ .attr('font-size', BLIP_FONT_SIZE)
307
+ .attr('font-weight', 'bold')
308
+ .text(blip.globalNumber);
309
+
310
+ // Hover: scale up + dim others (preview only, no expansion)
311
+ const lineNum = String(blip.lineNumber);
312
+ blipGroup
313
+ .on('mouseenter', () => {
314
+ blipGroup.attr(
315
+ 'transform',
316
+ `translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
317
+ );
318
+ dimExcept(rootContainer, lineNum);
319
+ })
320
+ .on('mouseleave', () => {
321
+ blipGroup.attr('transform', null);
322
+ clearDim(rootContainer);
323
+ });
324
+
325
+ // Click: expand description in panel (persistent)
326
+ blipGroup.on('click', () => {
327
+ if (onClickItem) {
328
+ onClickItem(blip.lineNumber);
329
+ requestAnimationFrame(() => dimExcept(rootContainer, lineNum));
330
+ }
331
+ });
332
+ }
333
+ }
334
+ }
335
+
336
+ // ============================================================
337
+ // Cross-highlight helpers (work across SVG + HTML)
338
+ // ============================================================
339
+
340
+ function dimExcept(root: HTMLElement, lineNum: string): void {
341
+ root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
342
+ el.style.opacity =
343
+ el.getAttribute('data-line-number') === lineNum
344
+ ? '1'
345
+ : String(DIM_OPACITY);
346
+ });
347
+ }
348
+
349
+ function clearDim(root: HTMLElement): void {
350
+ root.querySelectorAll<HTMLElement>('[data-line-number]').forEach((el) => {
351
+ el.style.opacity = '1';
352
+ });
353
+ }
354
+
355
+ // ============================================================
356
+ // HTML Side Panel
357
+ // ============================================================
358
+
359
+ /**
360
+ * Render the HTML side panel. Returns a toggle callback that the radar
361
+ * can call to expand/scroll a blip by line number.
362
+ */
363
+ function renderHtmlPanel(
364
+ panel: HTMLElement,
365
+ parsed: ParsedTechRadar,
366
+ quadrant: ParsedTechRadar['quadrants'][number],
367
+ qColor: string,
368
+ palette: PaletteColors,
369
+ isDark: boolean,
370
+ rootContainer: HTMLElement,
371
+ onClickItem?: (lineNumber: number) => void
372
+ ): (lineNumber: number) => void {
373
+ const ringOrder = parsed.rings.map((r) => r.name);
374
+ const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
375
+ let expandedLineNum: string | null = null;
376
+
377
+ function render() {
378
+ panel.innerHTML = '';
379
+
380
+ for (const ringName of ringOrder) {
381
+ const blips = quadrant.blips.filter((b) => b.ring === ringName);
382
+ if (blips.length === 0) continue;
383
+
384
+ // Ring group container
385
+ const ringGroup = document.createElement('div');
386
+ ringGroup.style.cssText = `
387
+ background: ${palette.surface};
388
+ border-radius: 8px;
389
+ padding: 10px;
390
+ margin-bottom: 12px;
391
+ `;
392
+
393
+ // Ring header inside the group
394
+ const header = document.createElement('div');
395
+ header.style.cssText = `
396
+ font-size: 13px; font-weight: 700; color: ${palette.textMuted};
397
+ margin-bottom: 8px;
398
+ `;
399
+ header.textContent = ringName;
400
+ ringGroup.appendChild(header);
401
+
402
+ panel.appendChild(ringGroup);
403
+
404
+ // Blip nodes (appended to ringGroup, not panel)
405
+ for (const blip of blips) {
406
+ const ln = String(blip.lineNumber);
407
+ const isExpanded = expandedLineNum === ln;
408
+ const hasDesc = blip.description.length > 0;
409
+
410
+ const node = document.createElement('div');
411
+ node.setAttribute('data-line-number', ln);
412
+ node.setAttribute('data-ring', blip.ring);
413
+ node.setAttribute('data-trend', blip.trend ?? 'stable');
414
+ node.style.cssText = `
415
+ background: ${fillColor}; border: 1.5px solid ${qColor};
416
+ border-radius: 6px; margin-bottom: 6px; cursor: pointer;
417
+ transition: border-width 0.1s;
418
+ ${isExpanded ? 'border-width: 2px;' : ''}
419
+ `;
420
+
421
+ // Title row
422
+ const titleRow = document.createElement('div');
423
+ titleRow.style.cssText = `
424
+ display: flex; align-items: center; gap: 8px;
425
+ padding: 6px 10px; min-height: 28px;
426
+ `;
427
+
428
+ // Mini SVG blip indicator
429
+ const indicatorSvg = document.createElementNS(
430
+ 'http://www.w3.org/2000/svg',
431
+ 'svg'
432
+ );
433
+ indicatorSvg.setAttribute('width', '26');
434
+ indicatorSvg.setAttribute('height', '26');
435
+ indicatorSvg.style.flexShrink = '0';
436
+ const indicatorG = d3Selection
437
+ .select(indicatorSvg)
438
+ .append('g') as d3Selection.Selection<
439
+ SVGGElement,
440
+ unknown,
441
+ null,
442
+ undefined
443
+ >;
444
+ renderTrendIndicator(
445
+ indicatorG,
446
+ blip.trend,
447
+ qColor,
448
+ 13,
449
+ 13,
450
+ 10,
451
+ -Math.PI / 2
452
+ );
453
+ d3Selection
454
+ .select(indicatorSvg)
455
+ .append('text')
456
+ .attr('x', 13)
457
+ .attr('y', 16)
458
+ .attr('text-anchor', 'middle')
459
+ .attr('fill', isDark ? '#000' : '#fff')
460
+ .attr('font-family', FONT_FAMILY)
461
+ .attr('font-size', 9)
462
+ .attr('font-weight', 'bold')
463
+ .text(blip.globalNumber);
464
+ titleRow.appendChild(indicatorSvg);
465
+
466
+ // Name
467
+ const name = document.createElement('span');
468
+ name.textContent = blip.name;
469
+ name.style.cssText = `
470
+ flex: 1; font-size: 12px; font-weight: 600;
471
+ color: ${palette.text}; white-space: nowrap;
472
+ overflow: hidden; text-overflow: ellipsis;
473
+ `;
474
+ titleRow.appendChild(name);
475
+
476
+ node.appendChild(titleRow);
477
+
478
+ // Description (expanded only)
479
+ if (isExpanded && hasDesc) {
480
+ const sep = document.createElement('div');
481
+ sep.style.cssText = `
482
+ border-top: 1px solid ${qColor}; opacity: 0.3;
483
+ `;
484
+ node.appendChild(sep);
485
+
486
+ const descDiv = document.createElement('div');
487
+ descDiv.style.cssText = `
488
+ padding: 6px 10px 8px; font-size: 11px; line-height: 1.6;
489
+ color: ${palette.textMuted};
490
+ `;
491
+ descDiv.innerHTML = renderDescriptionHtml(blip.description, palette);
492
+ node.appendChild(descDiv);
493
+ }
494
+
495
+ // Hover: dim all other blips (radar + panel), scale up matching radar dot
496
+ node.addEventListener('mouseenter', () => {
497
+ dimExcept(rootContainer, ln);
498
+ // Scale up matching radar dot
499
+ rootContainer
500
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
501
+ .forEach((el) => {
502
+ if (el.getAttribute('data-line-number') === ln) {
503
+ const bbox = (el as SVGGraphicsElement).getBBox?.();
504
+ if (bbox) {
505
+ const bx = bbox.x + bbox.width / 2;
506
+ const by = bbox.y + bbox.height / 2;
507
+ el.setAttribute(
508
+ 'transform',
509
+ `translate(${bx},${by}) scale(1.5) translate(${-bx},${-by})`
510
+ );
511
+ }
512
+ }
513
+ });
514
+ });
515
+ node.addEventListener('mouseleave', () => {
516
+ clearDim(rootContainer);
517
+ rootContainer
518
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
519
+ .forEach((el) => {
520
+ el.removeAttribute('transform');
521
+ });
522
+ });
523
+
524
+ // Click: accordion toggle + persistent dim
525
+ node.addEventListener('click', (event) => {
526
+ event.stopPropagation();
527
+ if (hasDesc) {
528
+ const wasExpanded = expandedLineNum === ln;
529
+ expandedLineNum = wasExpanded ? null : ln;
530
+ render();
531
+ if (!wasExpanded) {
532
+ // Dim others after re-render
533
+ requestAnimationFrame(() => {
534
+ dimExcept(rootContainer, ln);
535
+ // Scale up matching radar dot
536
+ rootContainer
537
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
538
+ .forEach((el) => {
539
+ if (el.getAttribute('data-line-number') === ln) {
540
+ const bbox = (el as SVGGraphicsElement).getBBox?.();
541
+ if (bbox) {
542
+ const cbx = bbox.x + bbox.width / 2;
543
+ const cby = bbox.y + bbox.height / 2;
544
+ el.setAttribute(
545
+ 'transform',
546
+ `translate(${cbx},${cby}) scale(1.5) translate(${-cbx},${-cby})`
547
+ );
548
+ }
549
+ }
550
+ });
551
+ });
552
+ } else {
553
+ // Collapsed — clear all
554
+ clearDim(rootContainer);
555
+ rootContainer
556
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
557
+ .forEach((el) => el.removeAttribute('transform'));
558
+ }
559
+ } else if (onClickItem) {
560
+ onClickItem(blip.lineNumber);
561
+ }
562
+ });
563
+
564
+ ringGroup.appendChild(node);
565
+ }
566
+ }
567
+ }
568
+
569
+ render();
570
+
571
+ // Click on empty panel space → collapse and clear
572
+ panel.addEventListener('click', () => {
573
+ if (expandedLineNum) {
574
+ expandedLineNum = null;
575
+ render();
576
+ clearDim(rootContainer);
577
+ rootContainer
578
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
579
+ .forEach((el) => el.removeAttribute('transform'));
580
+ }
581
+ });
582
+
583
+ // Return a toggle function for external callers (radar blip clicks)
584
+ return (lineNumber: number) => {
585
+ const ln = String(lineNumber);
586
+ const blip = quadrant.blips.find((b) => String(b.lineNumber) === ln);
587
+ if (blip && blip.description.length > 0) {
588
+ const wasExpanded = expandedLineNum === ln;
589
+ expandedLineNum = wasExpanded ? null : ln;
590
+ render();
591
+ const node = panel.querySelector(`[data-line-number="${ln}"]`);
592
+ if (node) node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
593
+ if (!wasExpanded) {
594
+ requestAnimationFrame(() => dimExcept(rootContainer, ln));
595
+ } else {
596
+ clearDim(rootContainer);
597
+ rootContainer
598
+ .querySelectorAll<SVGElement>('svg [data-line-number]')
599
+ .forEach((el) => el.removeAttribute('transform'));
600
+ }
601
+ } else if (onClickItem) {
602
+ onClickItem(lineNumber);
603
+ }
604
+ };
605
+ }
606
+
607
+ // ============================================================
608
+ // Description HTML Rendering (with markdown)
609
+ // ============================================================
610
+
611
+ function renderDescriptionHtml(
612
+ lines: string[],
613
+ palette: PaletteColors
614
+ ): string {
615
+ // Join prose lines into paragraphs, keep bullets separate
616
+ const paragraphs = joinParagraphs(lines);
617
+ let html = '';
618
+
619
+ for (const para of paragraphs) {
620
+ const trimmed = para.trim();
621
+ const isBullet = /^[-*•]\s+/.test(trimmed);
622
+ const content = isBullet ? trimmed.replace(/^[-*•]\s+/, '') : trimmed;
623
+ const rendered = renderInlineMarkdownHtml(content, palette);
624
+
625
+ if (isBullet) {
626
+ html += `<div style="padding-left: 14px; text-indent: -10px; margin: 2px 0;">• ${rendered}</div>`;
627
+ } else {
628
+ html += `<div style="margin: 4px 0;">${rendered}</div>`;
629
+ }
630
+ }
631
+
632
+ return html;
633
+ }
634
+
635
+ function joinParagraphs(lines: string[]): string[] {
636
+ const result: string[] = [];
637
+ let currentPara = '';
638
+
639
+ for (const line of lines) {
640
+ const trimmed = line.trim();
641
+ const isBullet = /^[-*•]\s+/.test(trimmed);
642
+
643
+ if (isBullet) {
644
+ if (currentPara) {
645
+ result.push(currentPara);
646
+ currentPara = '';
647
+ }
648
+ result.push(trimmed);
649
+ } else if (!trimmed) {
650
+ if (currentPara) {
651
+ result.push(currentPara);
652
+ currentPara = '';
653
+ }
654
+ } else {
655
+ currentPara = currentPara ? `${currentPara} ${trimmed}` : trimmed;
656
+ }
657
+ }
658
+
659
+ if (currentPara) result.push(currentPara);
660
+ return result;
661
+ }
662
+
663
+ function renderInlineMarkdownHtml(
664
+ text: string,
665
+ palette: PaletteColors
666
+ ): string {
667
+ const spans = parseInlineMarkdown(text);
668
+ let html = '';
669
+ for (const span of spans) {
670
+ let t = escapeHtml(span.text);
671
+ if (span.bold) t = `<strong>${t}</strong>`;
672
+ if (span.italic) t = `<em>${t}</em>`;
673
+ if (span.code)
674
+ t = `<code style="background:${palette.surface}; padding: 1px 4px; border-radius: 3px; font-size: 10px;">${t}</code>`;
675
+ if (span.href)
676
+ t = `<a href="${escapeHtml(span.href)}" target="_blank" rel="noopener" style="color: ${palette.primary ?? palette.text}; text-decoration: underline;">${t}</a>`;
677
+ html += t;
678
+ }
679
+ return html;
680
+ }
681
+
682
+ function escapeHtml(text: string): string {
683
+ return text
684
+ .replace(/&/g, '&amp;')
685
+ .replace(/</g, '&lt;')
686
+ .replace(/>/g, '&gt;');
687
+ }
688
+
689
+ // ============================================================
690
+ // Static Quadrant Export Renderer (all descriptions expanded, no interactivity)
691
+ // ============================================================
692
+
693
+ export function renderQuadrantFocusForExport(
694
+ container: HTMLDivElement,
695
+ parsed: ParsedTechRadar,
696
+ quadrantPosition: QuadrantPosition,
697
+ palette: PaletteColors,
698
+ isDark: boolean,
699
+ exportDims: { width: number; height: number }
700
+ ): void {
701
+ const quadrant = parsed.quadrants.find(
702
+ (q) => q.position === quadrantPosition
703
+ );
704
+ if (!quadrant) return;
705
+
706
+ container.innerHTML = '';
707
+
708
+ const width = exportDims.width;
709
+ const height = exportDims.height;
710
+ const qColor = resolveQuadrantColor(
711
+ quadrant.position,
712
+ quadrant.color,
713
+ palette
714
+ );
715
+
716
+ // ── Title bar ──
717
+ const titleBar = document.createElement('div');
718
+ titleBar.style.cssText = `
719
+ display: flex; align-items: baseline; gap: 8px;
720
+ padding: 12px 16px; font-family: ${FONT_FAMILY};
721
+ font-size: ${TITLE_FONT_SIZE + 2}px; background: ${palette.bg};
722
+ `;
723
+
724
+ const titleText = document.createElement('span');
725
+ titleText.textContent = parsed.title || 'Tech Radar';
726
+ titleText.style.cssText = `font-weight: bold; color: ${palette.text};`;
727
+
728
+ const sep = document.createElement('span');
729
+ sep.textContent = '›';
730
+ sep.style.color = palette.border;
731
+
732
+ const quadrantLabel = document.createElement('span');
733
+ quadrantLabel.textContent = quadrant.name;
734
+ quadrantLabel.style.cssText = `font-weight: bold; color: ${qColor};`;
735
+
736
+ titleBar.appendChild(titleText);
737
+ titleBar.appendChild(sep);
738
+ titleBar.appendChild(quadrantLabel);
739
+ container.appendChild(titleBar);
740
+
741
+ // ── Main layout: SVG radar (left) + HTML panel (right) ──
742
+ const mainLayout = document.createElement('div');
743
+ mainLayout.style.cssText = `
744
+ display: flex; flex-direction: row;
745
+ height: ${height - 48}px; background: ${palette.bg};
746
+ `;
747
+ container.appendChild(mainLayout);
748
+
749
+ // SVG container for quarter-circle (left 45%)
750
+ const svgContainer = document.createElement('div');
751
+ svgContainer.style.cssText = `width: 45%; min-width: 200px; flex-shrink: 0;`;
752
+ mainLayout.appendChild(svgContainer);
753
+
754
+ // HTML panel for blip listing (right 55%)
755
+ const panel = document.createElement('div');
756
+ panel.style.cssText = `
757
+ flex: 1; padding: 8px 16px;
758
+ font-family: ${FONT_FAMILY}; background: ${palette.bg};
759
+ `;
760
+ mainLayout.appendChild(panel);
761
+
762
+ // ── Render static HTML panel (all descriptions expanded) ──
763
+ renderStaticHtmlPanel(panel, parsed, quadrant, qColor, palette, isDark);
764
+
765
+ // ── Render quarter-circle SVG ──
766
+ const svgWidth = width * 0.45;
767
+ const svgHeight = height - 48;
768
+
769
+ const svg = d3Selection
770
+ .select(svgContainer)
771
+ .append('svg')
772
+ .attr('width', svgWidth)
773
+ .attr('height', svgHeight)
774
+ .attr('viewBox', `0 0 ${svgWidth} ${svgHeight}`)
775
+ .style('background', palette.bg);
776
+
777
+ renderQuarterCircleStatic(
778
+ svg,
779
+ parsed,
780
+ quadrant,
781
+ qColor,
782
+ palette,
783
+ isDark,
784
+ svgWidth,
785
+ svgHeight,
786
+ palette.border
787
+ );
788
+ }
789
+
790
+ /**
791
+ * Render the quarter-circle SVG without any interactivity.
792
+ */
793
+ function renderQuarterCircleStatic(
794
+ svg: d3Selection.Selection<SVGSVGElement, unknown, null, undefined>,
795
+ parsed: ParsedTechRadar,
796
+ quadrant: ParsedTechRadar['quadrants'][number],
797
+ qColor: string,
798
+ palette: PaletteColors,
799
+ isDark: boolean,
800
+ width: number,
801
+ height: number,
802
+ mutedColor: string
803
+ ): void {
804
+ const padding = 8;
805
+ const size = Math.min(width - padding, height - padding);
806
+ const maxRadius = size * 0.95;
807
+ const ringCount = parsed.rings.length;
808
+ const ringBandWidth = maxRadius / ringCount;
809
+
810
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
811
+ let cx: number, cy: number;
812
+
813
+ switch (quadrant.position) {
814
+ case 'top-right':
815
+ cx = padding;
816
+ cy = size + padding;
817
+ break;
818
+ case 'top-left':
819
+ cx = width - padding;
820
+ cy = size + padding;
821
+ break;
822
+ case 'bottom-left':
823
+ cx = width - padding;
824
+ cy = padding;
825
+ break;
826
+ case 'bottom-right':
827
+ cx = padding;
828
+ cy = padding;
829
+ break;
830
+ }
831
+
832
+ // Ring arcs with zebra shading
833
+ const arcGen = (innerR: number, outerR: number) =>
834
+ `M${cx + outerR * Math.cos(startAngle)},${cy - outerR * Math.sin(startAngle)} A${outerR},${outerR} 0 0,0 ${cx + outerR * Math.cos(endAngle)},${cy - outerR * Math.sin(endAngle)} L${cx + innerR * Math.cos(endAngle)},${cy - innerR * Math.sin(endAngle)} A${innerR},${innerR} 0 0,1 ${cx + innerR * Math.cos(startAngle)},${cy - innerR * Math.sin(startAngle)} Z`;
835
+
836
+ for (let ri = parsed.rings.length - 1; ri >= 0; ri--) {
837
+ const innerR = ri * ringBandWidth;
838
+ const outerR = (ri + 1) * ringBandWidth;
839
+ const fillColor =
840
+ ri % 2 === 0 ? palette.bg : mix(palette.bg, palette.border, 0.15);
841
+
842
+ svg
843
+ .append('path')
844
+ .attr('d', arcGen(innerR, outerR))
845
+ .attr('fill', fillColor)
846
+ .attr('stroke', mutedColor)
847
+ .attr('stroke-width', 0.5);
848
+ }
849
+
850
+ // Ring labels along the arc edge
851
+ for (let ri = 0; ri < parsed.rings.length; ri++) {
852
+ const rCenter = (ri + 0.5) * ringBandWidth;
853
+ const midAngle = (startAngle + endAngle) / 2;
854
+ const labelX = cx + rCenter * Math.cos(midAngle);
855
+ const labelY = cy - rCenter * Math.sin(midAngle);
856
+
857
+ svg
858
+ .append('text')
859
+ .attr('x', labelX)
860
+ .attr('y', labelY)
861
+ .attr('text-anchor', 'middle')
862
+ .attr('dominant-baseline', 'central')
863
+ .attr('fill', palette.textMuted)
864
+ .attr('font-family', FONT_FAMILY)
865
+ .attr('font-size', 11)
866
+ .attr('font-weight', '600')
867
+ .attr('opacity', 0.5)
868
+ .text(parsed.rings[ri].name);
869
+ }
870
+
871
+ // Blip dots
872
+ const ringOrder = parsed.rings.map((r) => r.name);
873
+ const angularPadding = 0.08;
874
+ const radialPadding = ringBandWidth * 0.12;
875
+ const usableArcStart = startAngle + angularPadding;
876
+ const usableArcEnd = endAngle - angularPadding;
877
+ const arcSpan = usableArcEnd - usableArcStart;
878
+
879
+ const blipsByRing = new Map<string, typeof quadrant.blips>();
880
+ for (const blip of quadrant.blips) {
881
+ const list = blipsByRing.get(blip.ring) ?? [];
882
+ list.push(blip);
883
+ blipsByRing.set(blip.ring, list);
884
+ }
885
+
886
+ for (const [ringName, blips] of blipsByRing) {
887
+ const ringIndex = ringOrder.indexOf(ringName);
888
+ if (ringIndex < 0) continue;
889
+ const rInner = ringIndex * ringBandWidth + radialPadding;
890
+ const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
891
+ const rMid = (rInner + rOuter) / 2;
892
+
893
+ for (let bi = 0; bi < blips.length; bi++) {
894
+ const blip = blips[bi];
895
+ const angle =
896
+ blips.length === 1
897
+ ? (usableArcStart + usableArcEnd) / 2
898
+ : usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
899
+
900
+ const radius =
901
+ blips.length <= 3
902
+ ? rMid
903
+ : rInner +
904
+ BLIP_RADIUS +
905
+ ((bi % 3) / 2) * (rOuter - rInner - BLIP_RADIUS * 2);
906
+
907
+ const bx = cx + radius * Math.cos(angle);
908
+ const by = cy - radius * Math.sin(angle);
909
+
910
+ const blipGroup = svg.append('g');
911
+
912
+ const angleToCenter = Math.atan2(cy - by, cx - bx);
913
+ renderTrendIndicator(
914
+ blipGroup,
915
+ blip.trend,
916
+ qColor,
917
+ bx,
918
+ by,
919
+ BLIP_RADIUS,
920
+ angleToCenter
921
+ );
922
+
923
+ blipGroup
924
+ .append('text')
925
+ .attr('x', bx)
926
+ .attr('y', by + 3)
927
+ .attr('text-anchor', 'middle')
928
+ .attr('fill', isDark ? '#000' : '#fff')
929
+ .attr('font-family', FONT_FAMILY)
930
+ .attr('font-size', BLIP_FONT_SIZE)
931
+ .attr('font-weight', 'bold')
932
+ .text(blip.globalNumber);
933
+ }
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Render static HTML panel with all descriptions expanded (for export).
939
+ */
940
+ function renderStaticHtmlPanel(
941
+ panel: HTMLElement,
942
+ parsed: ParsedTechRadar,
943
+ quadrant: ParsedTechRadar['quadrants'][number],
944
+ qColor: string,
945
+ palette: PaletteColors,
946
+ isDark: boolean
947
+ ): void {
948
+ const ringOrder = parsed.rings.map((r) => r.name);
949
+ const fillColor = mix(qColor, isDark ? palette.surface : palette.bg, 30);
950
+
951
+ for (const ringName of ringOrder) {
952
+ const blips = quadrant.blips.filter((b) => b.ring === ringName);
953
+ if (blips.length === 0) continue;
954
+
955
+ // Ring group container
956
+ const ringGroup = document.createElement('div');
957
+ ringGroup.style.cssText = `
958
+ background: ${palette.surface};
959
+ border-radius: 8px;
960
+ padding: 10px;
961
+ margin-bottom: 12px;
962
+ `;
963
+
964
+ // Ring header
965
+ const header = document.createElement('div');
966
+ header.style.cssText = `
967
+ font-size: 13px; font-weight: 700; color: ${palette.textMuted};
968
+ margin-bottom: 8px;
969
+ `;
970
+ header.textContent = ringName;
971
+ ringGroup.appendChild(header);
972
+
973
+ panel.appendChild(ringGroup);
974
+
975
+ for (const blip of blips) {
976
+ const hasDesc = blip.description.length > 0;
977
+
978
+ const node = document.createElement('div');
979
+ node.style.cssText = `
980
+ background: ${fillColor}; border: 1.5px solid ${qColor};
981
+ border-radius: 6px; margin-bottom: 6px;
982
+ `;
983
+
984
+ // Title row
985
+ const titleRow = document.createElement('div');
986
+ titleRow.style.cssText = `
987
+ display: flex; align-items: center; gap: 8px;
988
+ padding: 6px 10px; min-height: 28px;
989
+ `;
990
+
991
+ // Mini SVG blip indicator
992
+ const indicatorSvg = document.createElementNS(
993
+ 'http://www.w3.org/2000/svg',
994
+ 'svg'
995
+ );
996
+ indicatorSvg.setAttribute('width', '26');
997
+ indicatorSvg.setAttribute('height', '26');
998
+ indicatorSvg.style.flexShrink = '0';
999
+ const indicatorG = d3Selection
1000
+ .select(indicatorSvg)
1001
+ .append('g') as d3Selection.Selection<
1002
+ SVGGElement,
1003
+ unknown,
1004
+ null,
1005
+ undefined
1006
+ >;
1007
+ renderTrendIndicator(
1008
+ indicatorG,
1009
+ blip.trend,
1010
+ qColor,
1011
+ 13,
1012
+ 13,
1013
+ 10,
1014
+ -Math.PI / 2
1015
+ );
1016
+ d3Selection
1017
+ .select(indicatorSvg)
1018
+ .append('text')
1019
+ .attr('x', 13)
1020
+ .attr('y', 16)
1021
+ .attr('text-anchor', 'middle')
1022
+ .attr('fill', isDark ? '#000' : '#fff')
1023
+ .attr('font-family', FONT_FAMILY)
1024
+ .attr('font-size', 9)
1025
+ .attr('font-weight', 'bold')
1026
+ .text(blip.globalNumber);
1027
+ titleRow.appendChild(indicatorSvg);
1028
+
1029
+ // Name
1030
+ const name = document.createElement('span');
1031
+ name.textContent = blip.name;
1032
+ name.style.cssText = `
1033
+ flex: 1; font-size: 12px; font-weight: 600;
1034
+ color: ${palette.text};
1035
+ `;
1036
+ titleRow.appendChild(name);
1037
+
1038
+ node.appendChild(titleRow);
1039
+
1040
+ // Description (always expanded for export)
1041
+ if (hasDesc) {
1042
+ const sepDiv = document.createElement('div');
1043
+ sepDiv.style.cssText = `border-top: 1px solid ${qColor}; opacity: 0.3;`;
1044
+ node.appendChild(sepDiv);
1045
+
1046
+ const descDiv = document.createElement('div');
1047
+ descDiv.style.cssText = `
1048
+ padding: 6px 10px 8px; font-size: 11px; line-height: 1.6;
1049
+ color: ${palette.textMuted};
1050
+ `;
1051
+ descDiv.innerHTML = renderDescriptionHtml(blip.description, palette);
1052
+ node.appendChild(descDiv);
1053
+ }
1054
+
1055
+ ringGroup.appendChild(node);
1056
+ }
1057
+ }
1058
+ }