@diagrammo/dgmo 0.47.0 → 0.48.1

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