@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,1465 +0,0 @@
1
- // ============================================================
2
- // Boxes and Lines Diagram — D3 SVG Renderer
3
- // ============================================================
4
-
5
- import * as d3Selection from 'd3-selection';
6
- import {
7
- renderNoteBox,
8
- renderNoteConnector,
9
- renderNoteBadge,
10
- noteConnectorPoints,
11
- NOTE_BADGE_RADIUS,
12
- } from '../utils/note-box';
13
- import * as d3Shape from 'd3-shape';
14
- import { FONT_FAMILY } from '../fonts';
15
- import { renderIntegratedLegend } from '../utils/legend-integration';
16
- import { getMaxLegendReservedHeight } from '../utils/legend-layout';
17
- import type {
18
- LegendCallbacks,
19
- LegendGroupData,
20
- ControlsGroupToggle,
21
- } from '../utils/legend-types';
22
- import {
23
- TITLE_FONT_SIZE,
24
- TITLE_FONT_WEIGHT,
25
- TITLE_Y,
26
- } from '../utils/title-constants';
27
- import {
28
- contrastText,
29
- mix,
30
- relativeLuminance,
31
- shapeFill,
32
- valueRampColor,
33
- themeBaseBg,
34
- } from '../palettes/color-utils';
35
- import { resolveColor } from '../colors';
36
- import { resolveTagColor } from '../utils/tag-groups';
37
- import type { TagGroup } from '../utils/tag-groups';
38
- import type { PaletteColors } from '../palettes';
39
- import { renderInlineText } from '../utils/inline-markdown';
40
- import {
41
- wrapDescriptionLines,
42
- type WrappedDescLine,
43
- } from '../utils/wrapped-desc';
44
- import type { ParsedBoxesAndLines, BLNode } from './types';
45
- import type { BLLayoutResult, BLLayoutNode, BLLayoutEdge } from './layout';
46
- import { ScaleContext } from '../utils/scaling';
47
- import {
48
- CHAR_WIDTH_RATIO,
49
- measureText,
50
- truncateText,
51
- } from '../utils/text-measure';
52
-
53
- // ── Constants (aligned with infra pattern) ─────────────────
54
- const DIAGRAM_PADDING = 20;
55
- // Box labels run smaller than the 13px org/infra use — boxes-and-lines nodes are
56
- // narrower (~97px), so a smaller label fits more text per line before wrapping.
57
- const NODE_FONT_SIZE = 11;
58
- const MIN_NODE_FONT_SIZE = 9;
59
- const EDGE_LABEL_FONT_SIZE = 11;
60
- import {
61
- EDGE_STROKE_WIDTH,
62
- NODE_STROKE_WIDTH,
63
- } from '../utils/visual-conventions'; // shared (Story 111.1)
64
- const NODE_RX = 8;
65
- // Intentional deviation (conventions §3): boxes-and-lines uses a 4px collapse
66
- // bar (and 4px separator gap in layout.ts) — denser than the 6px default.
67
- const COLLAPSE_BAR_HEIGHT = 4;
68
- const ARROWHEAD_W = 5;
69
- const ARROWHEAD_H = 4;
70
- const DESC_FONT_SIZE = 10; // matches infra META_FONT_SIZE
71
- const DESC_LINE_HEIGHT = 1.4; // 14px row height at 10px font (matches infra META_LINE_HEIGHT)
72
- const MAX_DESC_LINES = 6;
73
- const NODE_TEXT_PADDING = 12;
74
- const GROUP_RX = 8;
75
- const GROUP_LABEL_FONT_SIZE = 14;
76
- const GROUP_LABEL_ZONE = 32;
77
- // % tint floor so the ramp minimum still reads as "low, present" (mirror map).
78
- const RAMP_FLOOR = 15;
79
- const VALUE_FONT_SIZE = 11;
80
-
81
- type D3Svg = d3Selection.Selection<SVGSVGElement, unknown, null, undefined>;
82
-
83
- // ── Edge path generators ───────────────────────────────────
84
- const lineGeneratorLR = d3Shape
85
- .line<{ x: number; y: number }>()
86
- .x((d) => d.x)
87
- .y((d) => d.y)
88
- .curve(d3Shape.curveBasis);
89
-
90
- const lineGeneratorTB = d3Shape
91
- .line<{ x: number; y: number }>()
92
- .x((d) => d.x)
93
- .y((d) => d.y)
94
- .curve(d3Shape.curveBasis);
95
-
96
- // Straight (linear) generator for pinned-layout connectors (Canvas Editor
97
- // spike). curveBasis collapses a 2-point polyline, so border-clipped straight
98
- // edges must draw linearly.
99
- const lineGeneratorStraight = d3Shape
100
- .line<{ x: number; y: number }>()
101
- .x((d) => d.x)
102
- .y((d) => d.y)
103
- .curve(d3Shape.curveLinear);
104
-
105
- // ── Text fitting ───────────────────────────────────────────
106
-
107
- function splitCamelCase(word: string): string[] {
108
- const parts: string[] = [];
109
- let start = 0;
110
- for (let i = 1; i < word.length; i++) {
111
- // In-bounds by loop guard (i >= 1 and i < word.length).
112
- const prev = word[i - 1]!;
113
- const curr = word[i]!;
114
- const next = i + 1 < word.length ? word[i + 1]! : '';
115
- const lowerToUpper =
116
- prev >= 'a' && prev <= 'z' && curr >= 'A' && curr <= 'Z';
117
- const upperRunEnd =
118
- prev >= 'A' &&
119
- prev <= 'Z' &&
120
- curr >= 'A' &&
121
- curr <= 'Z' &&
122
- next >= 'a' &&
123
- next <= 'z';
124
- if (lowerToUpper || upperRunEnd) {
125
- parts.push(word.slice(start, i));
126
- start = i;
127
- }
128
- }
129
- parts.push(word.slice(start));
130
- return parts.length > 1 ? parts : [word];
131
- }
132
-
133
- /**
134
- * Fit a label into a header zone for described nodes.
135
- * Strategy: split first (spaces, dashes, camelCase), wrap into lines,
136
- * shrink font if needed, truncate individual lines with "…" — never hard-break.
137
- */
138
- function fitLabelToHeader(
139
- label: string,
140
- nodeWidth: number,
141
- maxLines: number
142
- ): { lines: string[]; fontSize: number } {
143
- const maxTextWidth = nodeWidth - NODE_TEXT_PADDING * 2;
144
-
145
- // Split on spaces and dashes, then camelCase split each part
146
- const rawParts = label.split(/(\s+|-)/);
147
- const words: string[] = [];
148
- for (const part of rawParts) {
149
- if (!part || /^\s+$/.test(part) || part === '-') continue;
150
- words.push(...splitCamelCase(part));
151
- }
152
-
153
- for (
154
- let fontSize = NODE_FONT_SIZE;
155
- fontSize >= MIN_NODE_FONT_SIZE;
156
- fontSize--
157
- ) {
158
- if (maxTextWidth < measureText('MM', fontSize)) continue;
159
-
160
- // Wrap words into lines (greedy, by measured pixel width)
161
- const lines: string[] = [];
162
- let current = '';
163
- for (const word of words) {
164
- const test = current ? `${current} ${word}` : word;
165
- if (measureText(test, fontSize) <= maxTextWidth) {
166
- current = test;
167
- } else {
168
- if (current) lines.push(current);
169
- current = word;
170
- }
171
- }
172
- if (current) lines.push(current);
173
-
174
- const fits = (l: string): boolean =>
175
- measureText(l, fontSize) <= maxTextWidth;
176
-
177
- // All lines fit at this font? Done.
178
- if (lines.length <= maxLines && lines.every(fits)) {
179
- return { lines, fontSize };
180
- }
181
-
182
- // Lines fit in count but some are too wide? Truncate those lines.
183
- if (lines.length <= maxLines) {
184
- const result = lines.map((l) =>
185
- fits(l) ? l : truncateText(l, fontSize, maxTextWidth)
186
- );
187
- return { lines: result, fontSize };
188
- }
189
-
190
- // Too many lines — take first maxLines, truncate last + any oversized
191
- const result = lines
192
- .slice(0, maxLines)
193
- .map((l) => (fits(l) ? l : truncateText(l, fontSize, maxTextWidth)));
194
- // In-bounds: result has exactly maxLines entries (from .slice(0, maxLines)).
195
- const last = result[maxLines - 1]!;
196
- if (!last.endsWith('\u2026')) {
197
- result[maxLines - 1] = truncateText(
198
- last + '\u2026',
199
- fontSize,
200
- maxTextWidth
201
- );
202
- }
203
- return { lines: result, fontSize };
204
- }
205
-
206
- // Fallback at min font
207
- const truncated = truncateText(label, MIN_NODE_FONT_SIZE, maxTextWidth);
208
- return { lines: [truncated], fontSize: MIN_NODE_FONT_SIZE };
209
- }
210
-
211
- // ── Color helpers ──────────────────────────────────────────
212
-
213
- function nodeColors(
214
- node: BLNode,
215
- tagGroups: readonly TagGroup[],
216
- activeGroupName: string | null,
217
- palette: PaletteColors,
218
- isDark: boolean,
219
- value: {
220
- active: boolean;
221
- hue: string;
222
- /** Two explicit endpoint colours (`heat Risk green red`). When set, the
223
- * value's position on the ramp is carried by HUE, so the box follows the
224
- * STANDARD box convention (solid colour outline + 25% faded fill) rather than
225
- * the map's saturated choropleth fill. A single-colour ramp encodes value by
226
- * saturation/lightness and keeps the choropleth fill (no hue to spare). */
227
- twoColor: boolean;
228
- fillForValue: (v: number) => string;
229
- },
230
- solid?: boolean
231
- ): { fill: string; stroke: string; text: string } {
232
- // Untagged-neutral fill, reused by the value path for no-value boxes.
233
- const neutralFill = mix(palette.bg, palette.text, isDark ? 90 : 95);
234
- if (value.active) {
235
- if (node.value === undefined) {
236
- // No-value box: neutral fill, ramp-hue stroke (present so the app's
237
- // --bl-node-stroke hover-dim still works).
238
- const text = contrastText(
239
- neutralFill,
240
- palette.textOnFillLight,
241
- palette.textOnFillDark
242
- );
243
- return { fill: neutralFill, stroke: value.hue, text };
244
- }
245
- // Value box: render the ramp colour like any tagged box — a 25% faded
246
- // (muted) fill + a solid colour outline; `solid-fill` opts into the full
247
- // fill. The outline differs by ramp kind: a two-colour ramp carries value by
248
- // HUE, so each box's outline is its own ramp colour (red→green); a
249
- // single-colour ramp has one hue, so the outline is the constant ramp hue and
250
- // value reads from the muted fill depth.
251
- const rampColor = value.fillForValue(node.value);
252
- const fill = shapeFill(palette, rampColor, isDark, {
253
- ...(solid !== undefined && { solid }),
254
- });
255
- const stroke = value.twoColor ? rampColor : value.hue;
256
- const text = contrastText(
257
- fill,
258
- palette.textOnFillLight,
259
- palette.textOnFillDark
260
- );
261
- return { fill, stroke, text };
262
- }
263
- const tagColor = resolveTagColor(
264
- node.metadata,
265
- [...tagGroups],
266
- activeGroupName
267
- );
268
- if (tagColor) {
269
- const fill = shapeFill(palette, tagColor, isDark, {
270
- ...(solid !== undefined && { solid }),
271
- });
272
- const stroke = tagColor;
273
- const text = contrastText(
274
- fill,
275
- palette.textOnFillLight,
276
- palette.textOnFillDark
277
- );
278
- return { fill, stroke, text };
279
- }
280
- // Untagged fallback (subtle-neutral — out of scope per TD-2 / F2;
281
- // intentionally near-invisible so default nodes recede).
282
- const fill = mix(palette.bg, palette.text, isDark ? 90 : 95);
283
- const stroke = mix(palette.text, palette.bg, isDark ? 60 : 40);
284
- const text = palette.text;
285
- return { fill, stroke, text };
286
- }
287
-
288
- function edgeColor(
289
- edge: BLLayoutEdge,
290
- tagGroups: readonly TagGroup[],
291
- activeGroupName: string | null,
292
- palette: PaletteColors
293
- ): string {
294
- // Only color edges that have explicit tag metadata — otherwise neutral
295
- const hasTagMeta =
296
- Object.keys(edge.metadata).length > 0 && activeGroupName != null;
297
- if (hasTagMeta) {
298
- const tagColor = resolveTagColor(
299
- edge.metadata,
300
- [...tagGroups],
301
- activeGroupName
302
- );
303
- if (tagColor) return tagColor;
304
- }
305
- return palette.textMuted;
306
- }
307
-
308
- // ── Arrowhead markers ──────────────────────────────────────
309
-
310
- function ensureArrowMarkers(
311
- defs: d3Selection.Selection<SVGDefsElement, unknown, null, undefined>,
312
- colors: Set<string>
313
- ): void {
314
- for (const color of colors) {
315
- const id = `bl-arrow-${color.replace('#', '')}`;
316
- if (!defs.select(`#${id}`).empty()) continue;
317
- defs
318
- .append('marker')
319
- .attr('id', id)
320
- .attr('viewBox', `0 0 ${ARROWHEAD_W * 2} ${ARROWHEAD_H * 2}`)
321
- .attr('refX', ARROWHEAD_W * 2)
322
- .attr('refY', ARROWHEAD_H)
323
- .attr('markerWidth', ARROWHEAD_W)
324
- .attr('markerHeight', ARROWHEAD_H)
325
- .attr('orient', 'auto')
326
- .append('polygon')
327
- .attr(
328
- 'points',
329
- `0,0 ${ARROWHEAD_W * 2},${ARROWHEAD_H} 0,${ARROWHEAD_H * 2}`
330
- )
331
- .attr('fill', color);
332
-
333
- // Reverse marker for bidirectional
334
- const revId = `bl-arrow-rev-${color.replace('#', '')}`;
335
- if (!defs.select(`#${revId}`).empty()) continue;
336
- defs
337
- .append('marker')
338
- .attr('id', revId)
339
- .attr('viewBox', `0 0 ${ARROWHEAD_W * 2} ${ARROWHEAD_H * 2}`)
340
- .attr('refX', 0)
341
- .attr('refY', ARROWHEAD_H)
342
- .attr('markerWidth', ARROWHEAD_W)
343
- .attr('markerHeight', ARROWHEAD_H)
344
- .attr('orient', 'auto')
345
- .append('polygon')
346
- .attr(
347
- 'points',
348
- `${ARROWHEAD_W * 2},0 0,${ARROWHEAD_H} ${ARROWHEAD_W * 2},${ARROWHEAD_H * 2}`
349
- )
350
- .attr('fill', color);
351
- }
352
- }
353
-
354
- // ── Main render function ───────────────────────────────────
355
-
356
- interface BLRenderOptions {
357
- onClickItem?: (lineNumber: number) => void;
358
- exportDims?: { width?: number; height?: number };
359
- activeTagGroup?: string | null;
360
- hiddenTagValues?: Map<string, Set<string>>;
361
- hideDescriptions?: boolean;
362
- controlsExpanded?: boolean;
363
- onToggleDescriptions?: (active: boolean) => void;
364
- onToggleControlsExpand?: () => void;
365
- exportMode?: boolean;
366
- /** When 'app', the description toggle is hosted by the app overlay strip
367
- * (inline gear suppressed, controls row + anchor reserved). */
368
- controlsHost?: 'app' | 'inline';
369
- /** Explicit value-ramp domain override. When provided, the choropleth ramp
370
- * uses these endpoints instead of computing min/max from `parsed.nodes`.
371
- * Focus mode passes the GLOBAL (pre-filter) domain so neighbor colours stay
372
- * stable when only a subset is rendered (Decision 20 / FM1). */
373
- rampDomain?: { min: number; max: number };
374
- }
375
-
376
- export function renderBoxesAndLines(
377
- container: HTMLDivElement,
378
- parsed: ParsedBoxesAndLines,
379
- layout: BLLayoutResult,
380
- palette: PaletteColors,
381
- isDark: boolean,
382
- options?: BLRenderOptions
383
- ): void {
384
- const {
385
- onClickItem,
386
- exportDims,
387
- activeTagGroup,
388
- hiddenTagValues,
389
- hideDescriptions,
390
- controlsExpanded,
391
- onToggleDescriptions,
392
- onToggleControlsExpand,
393
- exportMode = false,
394
- controlsHost,
395
- rampDomain,
396
- } = options ?? {};
397
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
398
-
399
- const width = exportDims?.width ?? container.clientWidth;
400
- const height = exportDims?.height ?? container.clientHeight;
401
- if (width <= 0 || height <= 0) return;
402
-
403
- const sctx = ScaleContext.identity();
404
-
405
- const sDiagramPadding = sctx.aesthetic(DIAGRAM_PADDING);
406
- const sMinNodeFontSize = sctx.text(MIN_NODE_FONT_SIZE);
407
- const sEdgeLabelFontSize = sctx.text(EDGE_LABEL_FONT_SIZE);
408
- const sEdgeStrokeWidth = sctx.structural(EDGE_STROKE_WIDTH);
409
- const sNodeStrokeWidth = sctx.structural(NODE_STROKE_WIDTH);
410
- const sCollapseBarHeight = sctx.structural(COLLAPSE_BAR_HEIGHT);
411
- const sDescFontSize = sctx.text(DESC_FONT_SIZE);
412
- const sGroupLabelFontSize = sctx.text(GROUP_LABEL_FONT_SIZE);
413
- const sGroupLabelZone = sctx.structural(GROUP_LABEL_ZONE);
414
- const sTitleFontSize = sctx.text(TITLE_FONT_SIZE);
415
- const sTitleY = sctx.structural(TITLE_Y);
416
-
417
- // ── Value ramp + active-dimension resolution (mirror of map's value model) ──
418
- // The ramp is computed in the renderer (architectural divergence from the
419
- // map, which precomputes in layout) — node sizes are value-independent, and
420
- // this file already owns all colouring + the legend build. Hoisted ONCE
421
- // before the node loop so `fillForValue` is not recomputed per node.
422
- const nodeValues = parsed.nodes
423
- .filter((n) => n.value !== undefined)
424
- .map((n) => n.value!);
425
- const hasRamp = nodeValues.length > 0;
426
- // Anchor the low end at the lowest value (not 0) to maximise within-diagram
427
- // dynamic range; mirrors the map's region-heat ramp. Equal-value data
428
- // (rampMin === rampMax) falls back to t = 1 in fillForValue below.
429
- // A caller-supplied domain (focus mode) wins so colours don't shift when a
430
- // subset is rendered; otherwise derive from the nodes on screen.
431
- const rampMin = rampDomain?.min ?? (hasRamp ? Math.min(...nodeValues) : 0);
432
- const rampMax = rampDomain?.max ?? Math.max(...nodeValues);
433
- // Default hue = palette.primary (NOT red like the map — boxes have no water to
434
- // stand out against, and red reads as alarm on a neutral metric). A trailing
435
- // color on `heat` overrides.
436
- const rampHue =
437
- resolveColor(parsed.boxMetricColor ?? '', palette) ?? palette.primary;
438
- // Explicit LOW endpoint (`heat Risk green red`); absent ⇒ single-colour
439
- // (neutral low). Only recognized names peel, so resolveColor always succeeds.
440
- const rampLow = parsed.boxMetricLowColor
441
- ? (resolveColor(parsed.boxMetricLowColor, palette) ?? undefined)
442
- : undefined;
443
- // Lift the ramp anchor off the near-black surface on dark themes so the
444
- // lowest values read as a clear muted tint rather than sinking to the surface.
445
- const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
446
- const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
447
- const fillForValue = (v: number): string => {
448
- const t = rampMax > rampMin ? (v - rampMin) / (rampMax - rampMin) : 1;
449
- // Two-colour ramp: shared low→high interpolation (direct or via midpoint).
450
- if (rampLow !== undefined)
451
- return valueRampColor(rampLow, rampHue, t, { isDark });
452
- // Single/zero-colour: byte-identical to pre-change (same numeric pct, no
453
- // float round-trip).
454
- const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
455
- return mix(rampHue, rampBase, pct);
456
- };
457
- const VALUE_NAME = hasRamp ? parsed.boxMetric?.trim() || 'Value' : null;
458
-
459
- // Local active-dimension resolver — mirror map's inline matchColorGroup /
460
- // activeIsScore. Do NOT extend the shared resolveActiveTagGroup (it has a
461
- // fixed 3-arg signature consumed by 7 chart types). On a name collision
462
- // between a tag group and the metric label, the tag group wins (AC9).
463
- const matchColorGroup = (v: string): string | null => {
464
- const lv = v.trim().toLowerCase();
465
- if (lv === '' || lv === 'none') return null;
466
- const tg = parsed.tagGroups.find((g) => g.name.toLowerCase() === lv);
467
- if (tg) return tg.name;
468
- if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
469
- return v; // unknown name passes through → renders neutral
470
- };
471
- const override = activeTagGroup; // string | null | undefined
472
- let activeGroup: string | null;
473
- if (override !== undefined) {
474
- activeGroup = override === null ? null : matchColorGroup(override);
475
- } else if (parsed.options['active-tag'] !== undefined) {
476
- activeGroup = matchColorGroup(parsed.options['active-tag']);
477
- } else {
478
- // Default-active dimension: value ramp when any box has a value, else the
479
- // first declared tag group, else none.
480
- activeGroup =
481
- VALUE_NAME ??
482
- (parsed.tagGroups.length > 0 ? parsed.tagGroups[0]!.name : null);
483
- }
484
- const activeIsValue = VALUE_NAME !== null && activeGroup === VALUE_NAME;
485
-
486
- // Synthetic legend group for the value ramp (empty entries + gradient),
487
- // prepended to the tag groups handed to renderLegendD3 — exactly like the
488
- // map's VALUE_NAME group. The shared legend infra renders the gradient capsule
489
- // ONLY when it is the active group (legendState.activeGroup === its name).
490
- const valueGroup: LegendGroupData | null =
491
- VALUE_NAME !== null
492
- ? {
493
- name: VALUE_NAME,
494
- entries: [],
495
- gradient: {
496
- min: rampMin,
497
- max: rampMax,
498
- low: rampLow ?? rampLowFloor,
499
- high: rampHue,
500
- },
501
- }
502
- : null;
503
- const legendGroups: readonly LegendGroupData[] = [
504
- ...(valueGroup ? [valueGroup] : []),
505
- ...parsed.tagGroups,
506
- ];
507
-
508
- // Reserve legend height only when a legend will actually render. App-hosted
509
- // controls move the Descriptions toggle to the app overlay, so a
510
- // descriptions-only chart (no tag groups) reserves nothing.
511
- const reserveHasDescriptions = parsed.nodes.some(
512
- (n) => n.description && n.description.length > 0
513
- );
514
- const willRenderLegend =
515
- legendGroups.length > 0 ||
516
- (reserveHasDescriptions && controlsHost !== 'app');
517
- const sLegendHeight = willRenderLegend
518
- ? sctx.structural(
519
- getMaxLegendReservedHeight(
520
- {
521
- groups: legendGroups,
522
- position: { placement: 'top-center', titleRelation: 'below-title' },
523
- mode: exportMode ? 'export' : 'preview',
524
- },
525
- width
526
- )
527
- )
528
- : 0;
529
-
530
- // Build hidden set
531
- const hidden = hiddenTagValues ?? parsed.initialHiddenTagValues;
532
-
533
- // Build node lookup
534
- const nodeMap = new Map<string, BLNode>();
535
- for (const node of parsed.nodes) nodeMap.set(node.label, node);
536
-
537
- // Build layout node lookup
538
- const layoutNodeMap = new Map<string, BLLayoutNode>();
539
- for (const ln of layout.nodes) layoutNodeMap.set(ln.label, ln);
540
-
541
- // Compute diagram bounds for scaling
542
- const showTitle = !!parsed.title && parsed.options['no-title'] !== 'on';
543
- const titleOffset = showTitle ? 40 : 0;
544
- const hasAnyDescriptions = parsed.nodes.some(
545
- (n) => n.description && n.description.length > 0
546
- );
547
- const needsLegend =
548
- legendGroups.length > 0 || (hasAnyDescriptions && onToggleDescriptions);
549
- const legendH = needsLegend ? sLegendHeight + 8 : 0;
550
-
551
- const groupLabelsSet = new Set(layout.groups.map((g) => g.label));
552
- let labelZoneExtension = 0;
553
- for (const group of parsed.groups) {
554
- if (group.children.some((c) => groupLabelsSet.has(c))) {
555
- labelZoneExtension += sGroupLabelZone;
556
- }
557
- }
558
-
559
- const contentW = layout.width;
560
- const contentH = layout.height + titleOffset + legendH + labelZoneExtension;
561
-
562
- const scaleX = width / (contentW + sDiagramPadding * 2);
563
- const scaleY = height / (contentH + sDiagramPadding * 2);
564
- const scale = Math.min(scaleX, scaleY, 3);
565
-
566
- // Pinned (`layout`-block) coordinates can leave the content's real extent
567
- // sitting off-centre inside layout.width/height — e.g. nodes pinned far from
568
- // the origin bake a wide gap on one side. Re-centre the content within its
569
- // own box by equalizing opposite margins. Gated to pinned diagrams so the
570
- // auto-layout path (and its snapshots) stays byte-identical: that path
571
- // already produces symmetric margins.
572
- let centerShiftX = 0;
573
- let centerShiftY = 0;
574
- if (parsed.nodePositions && parsed.nodePositions.size > 0) {
575
- let bMinX = Infinity,
576
- bMinY = Infinity,
577
- bMaxX = -Infinity,
578
- bMaxY = -Infinity;
579
- const accB = (x: number, y: number) => {
580
- if (x < bMinX) bMinX = x;
581
- if (x > bMaxX) bMaxX = x;
582
- if (y < bMinY) bMinY = y;
583
- if (y > bMaxY) bMaxY = y;
584
- };
585
- for (const n of layout.nodes) {
586
- accB(n.x - n.width / 2, n.y - n.height / 2);
587
- accB(n.x + n.width / 2, n.y + n.height / 2);
588
- }
589
- for (const g of layout.groups) {
590
- accB(g.x - g.width / 2, g.y - g.height / 2);
591
- accB(g.x + g.width / 2, g.y + g.height / 2);
592
- }
593
- for (const e of layout.edges) for (const p of e.points) accB(p.x, p.y);
594
- if (Number.isFinite(bMinX)) {
595
- centerShiftX = (layout.width - bMaxX - bMinX) / 2;
596
- centerShiftY = (layout.height - bMaxY - bMinY) / 2;
597
- }
598
- }
599
-
600
- const offsetX = (width - contentW * scale) / 2 + centerShiftX * scale;
601
- const offsetY =
602
- sDiagramPadding + titleOffset + legendH + centerShiftY * scale;
603
-
604
- const svg: D3Svg = d3Selection
605
- .select(container)
606
- .append('svg')
607
- .attr('width', width)
608
- .attr('height', height)
609
- .attr('viewBox', `0 0 ${width} ${height}`)
610
- .attr('preserveAspectRatio', 'xMidYMin meet')
611
- .style('font-family', FONT_FAMILY)
612
- .style('background', palette.bg);
613
-
614
- if (sctx.isBelowFloor) {
615
- svg.attr('width', '100%');
616
- }
617
-
618
- const defs = svg.append('defs');
619
-
620
- if (showTitle) {
621
- svg
622
- .append('text')
623
- .attr('x', width / 2)
624
- .attr('y', sTitleY)
625
- .attr('text-anchor', 'middle')
626
- .attr('font-size', sTitleFontSize)
627
- .attr('font-weight', TITLE_FONT_WEIGHT)
628
- .attr('fill', palette.text)
629
- .text(parsed.title);
630
- }
631
-
632
- // Main diagram group with scaling
633
- const diagramG = svg
634
- .append('g')
635
- .attr('transform', `translate(${offsetX},${offsetY}) scale(${scale})`);
636
-
637
- // Collect all edge colors for arrowhead markers
638
- const arrowColors = new Set<string>();
639
- const edgeColorMap = new Map<number, string>();
640
- for (let i = 0; i < layout.edges.length; i++) {
641
- const c = edgeColor(
642
- // In-bounds by loop guard.
643
- layout.edges[i]!,
644
- parsed.tagGroups,
645
- activeGroup,
646
- palette
647
- );
648
- arrowColors.add(c);
649
- edgeColorMap.set(i, c);
650
- }
651
- ensureArrowMarkers(defs, arrowColors);
652
-
653
- // ── Render groups (bottom layer, largest first for nesting) ──
654
- const sortedGroups = [...layout.groups].sort(
655
- (a, b) => b.width * b.height - a.width * a.height
656
- );
657
- // Identify groups that contain sub-groups — only those need extra label space
658
- const groupLabels = new Set(layout.groups.map((g) => g.label));
659
- const hasSubGroups = new Set<string>();
660
- for (const group of parsed.groups) {
661
- for (const child of group.children) {
662
- if (groupLabels.has(child)) hasSubGroups.add(group.label);
663
- }
664
- }
665
-
666
- for (const group of sortedGroups) {
667
- const gx = group.x - group.width / 2;
668
- // Only extend top for groups that contain sub-groups (dagre under-pads these)
669
- const needsExtra = !group.collapsed && hasSubGroups.has(group.label);
670
- const gy = needsExtra
671
- ? group.y - group.height / 2 - sGroupLabelZone
672
- : group.y - group.height / 2;
673
- const groupHeight = needsExtra
674
- ? group.height + sGroupLabelZone
675
- : group.height;
676
-
677
- const groupG = diagramG
678
- .append('g')
679
- .attr(
680
- 'class',
681
- group.collapsed ? 'bl-group bl-group-collapsed' : 'bl-group'
682
- )
683
- .attr('data-line-number', String(group.lineNumber))
684
- .attr('data-node-id', group.label)
685
- .attr('data-group-toggle', group.label)
686
- .style('cursor', 'pointer');
687
-
688
- if (group.collapsed) {
689
- // Collapsed: solid rounded rect matching node style + 6px collapse bar
690
- const fillColor = themeBaseBg(palette, isDark);
691
- const strokeColor = palette.border;
692
-
693
- groupG
694
- .append('rect')
695
- .attr('x', gx)
696
- .attr('y', gy)
697
- .attr('width', group.width)
698
- .attr('height', group.height)
699
- .attr('rx', NODE_RX)
700
- .attr('ry', NODE_RX)
701
- .attr('fill', fillColor)
702
- .attr('stroke', strokeColor)
703
- .attr('stroke-width', sNodeStrokeWidth);
704
-
705
- const clipId = `bl-clip-${group.label.replace(/[[\]\s]/g, '')}`;
706
- groupG
707
- .append('clipPath')
708
- .attr('id', clipId)
709
- .append('rect')
710
- .attr('x', gx)
711
- .attr('y', gy)
712
- .attr('width', group.width)
713
- .attr('height', group.height)
714
- .attr('rx', NODE_RX);
715
- groupG
716
- .append('rect')
717
- .attr('x', gx)
718
- .attr('y', gy + group.height - sCollapseBarHeight)
719
- .attr('width', group.width)
720
- .attr('height', sCollapseBarHeight)
721
- .attr('fill', strokeColor)
722
- .attr('clip-path', `url(#${clipId})`)
723
- .attr('class', 'bl-collapse-bar');
724
-
725
- const maxLabelLines = Math.max(
726
- 2,
727
- Math.floor((group.height - 16) / (sMinNodeFontSize * 1.3))
728
- );
729
- const fitted = fitLabelToHeader(group.label, group.width, maxLabelLines);
730
- const lineH = fitted.fontSize * 1.3;
731
- const totalH = fitted.lines.length * lineH;
732
- for (let li = 0; li < fitted.lines.length; li++) {
733
- groupG
734
- .append('text')
735
- .attr('class', 'bl-group-label')
736
- .attr('x', group.x)
737
- .attr('y', group.y - totalH / 2 + lineH / 2 + li * lineH)
738
- .attr('text-anchor', 'middle')
739
- .attr('dominant-baseline', 'central')
740
- .attr('font-family', FONT_FAMILY)
741
- .attr('font-size', fitted.fontSize)
742
- .attr('font-weight', '600')
743
- .attr('fill', palette.text)
744
- // In-bounds by loop guard.
745
- .text(fitted.lines[li]!);
746
- }
747
- } else {
748
- // Expanded: background container with label
749
- groupG
750
- .append('rect')
751
- .attr('x', gx)
752
- .attr('y', gy)
753
- .attr('width', group.width)
754
- .attr('height', groupHeight)
755
- .attr('rx', GROUP_RX)
756
- .attr('ry', GROUP_RX)
757
- .attr('fill', mix(palette.surface, palette.bg, 40))
758
- .attr('stroke', palette.textMuted)
759
- .attr('stroke-width', 1)
760
- .attr('stroke-opacity', 0.35);
761
-
762
- groupG
763
- .append('text')
764
- .attr('class', 'bl-group-label')
765
- .attr('x', gx + group.width / 2)
766
- .attr('y', gy + 18)
767
- .attr('text-anchor', 'middle')
768
- .attr('font-family', FONT_FAMILY)
769
- .attr('font-size', sGroupLabelFontSize)
770
- .attr('font-weight', '600')
771
- .attr('fill', palette.text)
772
- .text(group.label);
773
- }
774
- }
775
-
776
- // ── Render edges ───────────────────────────────────────
777
- // Edge labels are placed in layout (label-placement.ts) and drawn AFTER the
778
- // node loop (see the bl-edge-labels layer) so boxes never clip them.
779
- for (let i = 0; i < layout.edges.length; i++) {
780
- // In-bounds by loop guard.
781
- const le = layout.edges[i]!;
782
- const color = edgeColorMap.get(i) ?? palette.textMuted;
783
-
784
- // Check if hidden
785
- if (hidden.size > 0) {
786
- let isHidden = false;
787
- for (const [groupKey, hiddenVals] of hidden) {
788
- const val = le.metadata[groupKey];
789
- if (val && hiddenVals.has(val.toLowerCase())) {
790
- isHidden = true;
791
- break;
792
- }
793
- }
794
- if (isHidden) continue;
795
- }
796
-
797
- // Self-loop: render as a smooth circular arc below the node
798
- if (le.source === le.target) {
799
- const nodeLayout = layoutNodeMap.get(le.source);
800
- if (nodeLayout) {
801
- const edgeG = diagramG
802
- .append('g')
803
- .attr('class', 'bl-edge-group')
804
- .attr('data-line-number', String(le.lineNumber))
805
- // Endpoint node labels for baked-CSS connection-highlight.
806
- .attr('data-from', le.source)
807
- .attr('data-to', le.target);
808
-
809
- const markerId = `bl-arrow-${color.replace('#', '')}`;
810
- const cx = nodeLayout.x;
811
- const cy = nodeLayout.y;
812
- const hw = nodeLayout.width / 2;
813
- const hh = nodeLayout.height / 2;
814
- const pad = 20; // clearance from node edge
815
-
816
- // Arc exits from bottom of right side, swings wide, returns to right of bottom side
817
- const startX = cx + hw;
818
- const startY = cy + hh * 0.4;
819
- const endX = cx + hw * 0.4;
820
- const endY = cy + hh;
821
-
822
- // Control points swing far out to create a smooth circular arc
823
- const cp1x = startX + hw + pad;
824
- const cp1y = startY;
825
- const cp2x = endX;
826
- const cp2y = endY + hh + pad;
827
-
828
- edgeG
829
- .append('path')
830
- .attr('class', 'bl-edge')
831
- .attr(
832
- 'd',
833
- `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
834
- )
835
- .attr('fill', 'none')
836
- .attr('stroke', color)
837
- .attr('stroke-width', sEdgeStrokeWidth)
838
- .attr('marker-end', `url(#${markerId})`);
839
- }
840
- continue;
841
- }
842
-
843
- // Parallel edge fan: construct explicit 5-point geometry so lines
844
- // bundle at ports and visibly spread apart in the middle.
845
- let points: { x: number; y: number }[];
846
- if (le.yOffset !== 0 && le.parallelCount > 1) {
847
- const srcLayout = layoutNodeMap.get(le.source);
848
- const tgtLayout = layoutNodeMap.get(le.target);
849
- const srcY = srcLayout?.y ?? le.points[0]?.y ?? 0;
850
- const tgtY = tgtLayout?.y ?? le.points[le.points.length - 1]?.y ?? 0;
851
- const srcX = le.points[0]?.x ?? 0;
852
- const tgtX = le.points[le.points.length - 1]?.x ?? 0;
853
- const midX = (srcX + tgtX) / 2;
854
- const midY = (srcY + tgtY) / 2;
855
-
856
- points = [
857
- { x: srcX, y: srcY }, // port (bundled)
858
- { x: srcX + (midX - srcX) * 0.3, y: srcY + le.yOffset * 0.5 }, // separate
859
- { x: midX, y: midY + le.yOffset }, // full spread
860
- { x: tgtX - (tgtX - midX) * 0.3, y: tgtY + le.yOffset * 0.5 }, // converge
861
- { x: tgtX, y: tgtY }, // port (bundled)
862
- ];
863
- } else {
864
- points = le.points.map((p) => ({ x: p.x, y: p.y }));
865
- }
866
- if (points.length < 2) continue;
867
-
868
- const edgeG = diagramG
869
- .append('g')
870
- .attr('class', 'bl-edge-group')
871
- .attr('data-line-number', String(le.lineNumber))
872
- // Endpoint node labels for baked-CSS connection-highlight.
873
- .attr('data-from', le.source)
874
- .attr('data-to', le.target);
875
-
876
- const markerId = `bl-arrow-${color.replace('#', '')}`;
877
- const gen = le.straight
878
- ? lineGeneratorStraight
879
- : parsed.direction === 'TB'
880
- ? lineGeneratorTB
881
- : lineGeneratorLR;
882
- const path = edgeG
883
- .append('path')
884
- .attr('class', 'bl-edge')
885
- .attr('d', gen(points) ?? '')
886
- .attr('fill', 'none')
887
- .attr('stroke', color)
888
- .attr('stroke-width', sEdgeStrokeWidth)
889
- .attr('marker-end', `url(#${markerId})`);
890
-
891
- if (le.bidirectional) {
892
- const revId = `bl-arrow-rev-${color.replace('#', '')}`;
893
- path.attr('marker-start', `url(#${revId})`);
894
- }
895
- }
896
-
897
- // ── Render nodes ───────────────────────────────────────
898
- for (const ln of layout.nodes) {
899
- const node = nodeMap.get(ln.label);
900
- if (!node) continue;
901
-
902
- // Check if hidden
903
- if (hidden.size > 0) {
904
- let isHidden = false;
905
- for (const [groupKey, hiddenVals] of hidden) {
906
- const val = node.metadata[groupKey];
907
- if (val && hiddenVals.has(val.toLowerCase())) {
908
- isHidden = true;
909
- break;
910
- }
911
- }
912
- if (isHidden) continue;
913
- }
914
-
915
- const solid = parsed.options['solid-fill'] === 'on';
916
- const colors = nodeColors(
917
- node,
918
- parsed.tagGroups,
919
- activeGroup,
920
- palette,
921
- isDark,
922
- {
923
- active: activeIsValue,
924
- hue: rampHue,
925
- twoColor: rampLow !== undefined,
926
- fillForValue,
927
- },
928
- solid
929
- );
930
- // Divider matches the org-card convention: the box stroke normally, but the
931
- // contrast text colour in solid mode (where stroke == fill and would vanish).
932
- const dividerStroke = solid ? colors.text : colors.stroke;
933
-
934
- const nodeG = diagramG
935
- .append('g')
936
- .attr('class', 'bl-node')
937
- .attr('transform', `translate(${ln.x},${ln.y})`)
938
- .attr('data-line-number', node.lineNumber)
939
- .attr('data-node-id', node.label)
940
- .style('cursor', onClickItem ? 'pointer' : 'default')
941
- .style('--bl-node-stroke', colors.stroke);
942
-
943
- // Add tag metadata as data attributes for legend hover dimming
944
- for (const [key, val] of Object.entries(node.metadata)) {
945
- nodeG.attr(`data-tag-${key.toLowerCase()}`, val.toLowerCase());
946
- }
947
-
948
- // Numeric value drives the gradient scrub; guard on !== undefined so a
949
- // legitimate `heat: 0` still emits data-value="0" (0 is falsy).
950
- if (node.value !== undefined) {
951
- nodeG.attr('data-value', node.value);
952
- }
953
-
954
- if (onClickItem) {
955
- nodeG.on('click', (event: Event) => {
956
- // Don't intercept clicks on links in description text
957
- const target = event.target as Element | null;
958
- if (target?.closest('a')) return;
959
- onClickItem(node.lineNumber);
960
- });
961
- }
962
-
963
- // Rectangle card
964
- const x = -ln.width / 2;
965
- const y = -ln.height / 2;
966
-
967
- // Background rect
968
- nodeG
969
- .append('rect')
970
- .attr('x', x)
971
- .attr('y', y)
972
- .attr('width', ln.width)
973
- .attr('height', ln.height)
974
- .attr('rx', NODE_RX)
975
- .attr('ry', NODE_RX)
976
- .attr('fill', colors.fill)
977
- .attr('stroke', colors.stroke)
978
- .attr('stroke-width', sNodeStrokeWidth);
979
-
980
- // All text centered vertically using dominant-baseline: central
981
- const desc = node.description;
982
- if (desc && desc.length > 0 && !hideDescriptions) {
983
- const MAX_LABEL_LINES = 3;
984
- const fitted = fitLabelToHeader(node.label, ln.width, MAX_LABEL_LINES);
985
- const labelLines = fitted.lines;
986
- const labelLineH = fitted.fontSize * 1.3;
987
- const labelTotalH = labelLines.length * labelLineH;
988
- const headerH = labelTotalH + 12;
989
- const headerCenterY = -ln.height / 2 + headerH / 2;
990
- for (let li = 0; li < labelLines.length; li++) {
991
- nodeG
992
- .append('text')
993
- .attr('x', 0)
994
- .attr(
995
- 'y',
996
- headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
997
- )
998
- .attr('text-anchor', 'middle')
999
- .attr('dominant-baseline', 'central')
1000
- .attr('font-size', fitted.fontSize)
1001
- .attr('font-weight', 'bold')
1002
- .attr('fill', colors.text)
1003
- // In-bounds by loop guard.
1004
- .text(labelLines[li]!);
1005
- }
1006
-
1007
- // Single divider under the title (org-card convention) — everything else
1008
- // renders below it as one body section (no second divider / footer band).
1009
- const sepY = -ln.height / 2 + headerH;
1010
- nodeG
1011
- .append('line')
1012
- .attr('x1', -ln.width / 2)
1013
- .attr('y1', sepY)
1014
- .attr('x2', ln.width / 2)
1015
- .attr('y2', sepY)
1016
- .attr('stroke', dividerStroke)
1017
- .attr('stroke-opacity', 0.3)
1018
- .attr('stroke-width', 1);
1019
-
1020
- const descStartY = sepY + 4 + sDescFontSize;
1021
- const maxTextWidth = ln.width - NODE_TEXT_PADDING * 2;
1022
- // Char budget for the shared (char-based) bullet-aware wrapper. Derived
1023
- // from the shared average glyph ratio so it stays in step with the
1024
- // pixel measurer used everywhere else here.
1025
- const charsPerLine = Math.floor(
1026
- maxTextWidth / (sDescFontSize * CHAR_WIDTH_RATIO)
1027
- );
1028
- const descLineH = sDescFontSize * DESC_LINE_HEIGHT;
1029
-
1030
- // Estimate display length — strip markdown syntax for measurement
1031
- const displayLen = (text: string): number =>
1032
- text
1033
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
1034
- .replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
1035
- .replace(/\*(.+?)\*/g, '$1') // *italic* → italic
1036
- .replace(/`(.+?)`/g, '$1') // `code` → code
1037
- .replace(/https?:\/\/\S+/g, (u) => u.slice(0, 20)).length; // bare URLs shortened
1038
-
1039
- // Build wrapped lines from description. Convert "- " to bullet glyph
1040
- // and let the shared helper split bullet lines into first/cont rows
1041
- // so continuation text aligns under the bullet body, not the glyph.
1042
- const normalizedLines: string[] = [];
1043
- for (const descLine of desc) {
1044
- let normalized = descLine.startsWith('- ')
1045
- ? '\u2022 ' + descLine.slice(2)
1046
- : descLine;
1047
- // Normalize bare URLs: `http example.com` → `http://example.com`
1048
- normalized = normalized.replace(
1049
- /\bhttps?\s+([\w][\w.-]+\.[a-z]{2,}(?:\/\S*)?)/gi,
1050
- (_, domain) => `https://${domain}`
1051
- );
1052
- normalizedLines.push(normalized);
1053
- }
1054
-
1055
- const wrappedLinesShared: WrappedDescLine[] = wrapDescriptionLines(
1056
- normalizedLines,
1057
- charsPerLine,
1058
- displayLen
1059
- );
1060
-
1061
- const truncated = wrappedLinesShared.length > MAX_DESC_LINES;
1062
- const visibleLines = truncated
1063
- ? wrappedLinesShared.slice(0, MAX_DESC_LINES)
1064
- : wrappedLinesShared;
1065
-
1066
- // Bullet glyph at the description's left edge; body text indented so
1067
- // continuation lines align under the first word past the bullet.
1068
- const BULLET_GLYPH_X = -ln.width / 2 + 6;
1069
- const BULLET_BODY_X = BULLET_GLYPH_X + 10;
1070
-
1071
- // Description must stay legible on ANY fill. On the default light/tinted
1072
- // fills keep the subtle muted grey; on a dark/saturated fill (e.g.
1073
- // solid-fill) the fixed grey sinks in — switch to a muted tint of the
1074
- // box's contrast-correct text colour so it reads while staying
1075
- // subordinate to the title.
1076
- const descColor =
1077
- relativeLuminance(colors.fill) > 0.5
1078
- ? palette.textMuted
1079
- : mix(colors.text, colors.fill, 75);
1080
-
1081
- for (let li = 0; li < visibleLines.length; li++) {
1082
- // In-bounds by loop guard.
1083
- const line = visibleLines[li]!;
1084
- let lineText = line.text;
1085
- // Truncate last line if there are more lines beyond the cap
1086
- if (truncated && li === visibleLines.length - 1) {
1087
- lineText =
1088
- measureText(lineText, sDescFontSize) >= maxTextWidth
1089
- ? truncateText(lineText, sDescFontSize, maxTextWidth)
1090
- : lineText + '\u2026';
1091
- }
1092
- const y = descStartY + li * descLineH;
1093
- if (line.kind === 'bullet-first') {
1094
- nodeG
1095
- .append('text')
1096
- .attr('x', BULLET_GLYPH_X)
1097
- .attr('y', y)
1098
- .attr('text-anchor', 'start')
1099
- .attr('dominant-baseline', 'central')
1100
- .attr('font-size', sDescFontSize)
1101
- .attr('fill', descColor)
1102
- .text('\u2022');
1103
- }
1104
- const isBullet =
1105
- line.kind === 'bullet-first' || line.kind === 'bullet-cont';
1106
- const textEl = nodeG
1107
- .append('text')
1108
- .attr('x', isBullet ? BULLET_BODY_X : 0)
1109
- .attr('y', y)
1110
- .attr('text-anchor', isBullet ? 'start' : 'middle')
1111
- .attr('dominant-baseline', 'central')
1112
- .attr('font-size', DESC_FONT_SIZE)
1113
- .attr('fill', descColor);
1114
- renderInlineText(textEl, lineText, palette, sDescFontSize);
1115
- }
1116
-
1117
- // Tooltip when truncated
1118
- if (truncated) {
1119
- const fullText = desc.join(' ');
1120
- const tooltipText =
1121
- fullText.length > 200 ? fullText.slice(0, 199) + '\u2026' : fullText;
1122
- nodeG.append('title').text(tooltipText);
1123
- }
1124
-
1125
- // Value sits in the SAME body section, directly after the description \u2014
1126
- // no second divider / footer band (org-card: title, one line, body).
1127
- if (parsed.showValues && node.value !== undefined) {
1128
- const valueLabel = parsed.boxMetric
1129
- ? `${parsed.boxMetric}: ${node.value}`
1130
- : String(node.value);
1131
- nodeG
1132
- .append('text')
1133
- .attr('class', 'bl-node-value')
1134
- .attr('x', 0)
1135
- .attr('y', descStartY + visibleLines.length * descLineH)
1136
- .attr('text-anchor', 'middle')
1137
- .attr('dominant-baseline', 'central')
1138
- .attr('font-size', VALUE_FONT_SIZE)
1139
- .attr('font-weight', '600')
1140
- .attr('fill', colors.text)
1141
- .text(valueLabel);
1142
- }
1143
- } else if (parsed.showValues && node.value !== undefined) {
1144
- // Plain node with show-values: label header + thin divider + a
1145
- // "Metric: value" line below (org/infra card style), instead of a
1146
- // vertically-centered label with a floating number.
1147
- const valueLabel = parsed.boxMetric
1148
- ? `${parsed.boxMetric}: ${node.value}`
1149
- : String(node.value);
1150
- // Fixed header zone (not label-height-driven) so the divider sits at a
1151
- // UNIFORM Y across every box, regardless of label line count (infra/org
1152
- // both anchor the separator to a constant header height).
1153
- const headerH = ln.height / 2;
1154
- const sepY = -ln.height / 2 + headerH;
1155
- const fitted = fitLabelToHeader(node.label, ln.width, 2);
1156
- const labelLineH = fitted.fontSize * 1.3;
1157
- const labelTotalH = fitted.lines.length * labelLineH;
1158
- const headerCenterY = -ln.height / 2 + headerH / 2;
1159
- for (let li = 0; li < fitted.lines.length; li++) {
1160
- nodeG
1161
- .append('text')
1162
- .attr('x', 0)
1163
- .attr(
1164
- 'y',
1165
- headerCenterY - labelTotalH / 2 + labelLineH / 2 + li * labelLineH
1166
- )
1167
- .attr('text-anchor', 'middle')
1168
- .attr('dominant-baseline', 'central')
1169
- .attr('font-size', fitted.fontSize)
1170
- .attr('font-weight', 'bold')
1171
- .attr('fill', colors.text)
1172
- // In-bounds by loop guard.
1173
- .text(fitted.lines[li]!);
1174
- }
1175
- // Single divider under the title (org-card convention; solid-aware so it
1176
- // stays visible when stroke == fill).
1177
- nodeG
1178
- .append('line')
1179
- .attr('x1', -ln.width / 2)
1180
- .attr('y1', sepY)
1181
- .attr('x2', ln.width / 2)
1182
- .attr('y2', sepY)
1183
- .attr('stroke', dividerStroke)
1184
- .attr('stroke-opacity', 0.3)
1185
- .attr('stroke-width', 1);
1186
- // "Metric: value" centered in the space below the divider.
1187
- nodeG
1188
- .append('text')
1189
- .attr('class', 'bl-node-value')
1190
- .attr('x', 0)
1191
- .attr('y', (sepY + ln.height / 2) / 2)
1192
- .attr('text-anchor', 'middle')
1193
- .attr('dominant-baseline', 'central')
1194
- .attr('font-size', VALUE_FONT_SIZE)
1195
- .attr('fill', colors.text)
1196
- .attr('opacity', 0.85)
1197
- .text(valueLabel);
1198
- } else {
1199
- const maxLabelLines = Math.max(
1200
- 2,
1201
- Math.floor((ln.height - 16) / (sMinNodeFontSize * 1.3))
1202
- );
1203
- const fitted = fitLabelToHeader(node.label, ln.width, maxLabelLines);
1204
- const lineH = fitted.fontSize * 1.3;
1205
- const totalH = fitted.lines.length * lineH;
1206
- for (let li = 0; li < fitted.lines.length; li++) {
1207
- nodeG
1208
- .append('text')
1209
- .attr('x', 0)
1210
- .attr('y', -totalH / 2 + lineH / 2 + li * lineH)
1211
- .attr('text-anchor', 'middle')
1212
- .attr('dominant-baseline', 'central')
1213
- .attr('font-size', fitted.fontSize)
1214
- .attr('font-weight', '600')
1215
- .attr('fill', colors.text)
1216
- // In-bounds by loop guard.
1217
- .text(fitted.lines[li]!);
1218
- }
1219
- }
1220
-
1221
- // ── Note (floated beside the box, or a collapsed corner badge) ──
1222
- // The box keeps its layout position; the note floats in adjacent space.
1223
- // Coords are node-center-local (the node `<g>` is at the box center).
1224
- if (ln.note) {
1225
- if (ln.note.collapsed) {
1226
- renderNoteBadge(
1227
- nodeG,
1228
- {
1229
- x: ln.width / 2 - NOTE_BADGE_RADIUS - 3,
1230
- y: -ln.height / 2 + NOTE_BADGE_RADIUS + 3,
1231
- },
1232
- palette,
1233
- {
1234
- isDark,
1235
- ...(ln.note.color && { color: ln.note.color }),
1236
- lineNumber: ln.note.lineNumber,
1237
- endLineNumber: ln.note.endLineNumber,
1238
- }
1239
- );
1240
- } else {
1241
- const [cx1, cy1, cx2, cy2] = noteConnectorPoints(
1242
- { width: ln.width, height: ln.height },
1243
- ln.note
1244
- );
1245
- renderNoteConnector(nodeG, cx1, cy1, cx2, cy2, palette);
1246
- renderNoteBox(
1247
- nodeG,
1248
- {
1249
- x: ln.note.x,
1250
- y: ln.note.y,
1251
- width: ln.note.width,
1252
- height: ln.note.height,
1253
- },
1254
- ln.note.lines,
1255
- palette,
1256
- {
1257
- isDark,
1258
- ...(ln.note.color && { color: ln.note.color }),
1259
- lineNumber: ln.note.lineNumber,
1260
- endLineNumber: ln.note.endLineNumber,
1261
- interactive: true,
1262
- }
1263
- );
1264
- }
1265
- }
1266
- }
1267
-
1268
- // ── Render edge labels ─────────────────────────────────
1269
- // Drawn AFTER nodes (and groups) so the halo + text always paint on top and
1270
- // are never clipped by a box. Positions/wrapping come from label-placement
1271
- // (layout); this is a pure consumer. Labels stay in their own layer rather
1272
- // than inside each edge group, but carry data-line-number so line-hover/click
1273
- // still highlights the matching label.
1274
- const labelLayer = diagramG.append('g').attr('class', 'bl-edge-labels');
1275
- const labelLineHeight = sEdgeLabelFontSize * 1.3;
1276
- for (const le of layout.edges) {
1277
- if (!le.labelLines || le.labelLines.length === 0) continue;
1278
- if (le.labelX === undefined || le.labelY === undefined) continue;
1279
- const lx = le.labelX;
1280
- const ly = le.labelY;
1281
-
1282
- // Honour the same tag-value hiding as the edges themselves.
1283
- if (hidden.size > 0) {
1284
- let isHidden = false;
1285
- for (const [groupKey, hiddenVals] of hidden) {
1286
- const val = le.metadata[groupKey];
1287
- if (val && hiddenVals.has(val.toLowerCase())) {
1288
- isHidden = true;
1289
- break;
1290
- }
1291
- }
1292
- if (isHidden) continue;
1293
- }
1294
-
1295
- const lw = le.labelWidth ?? 0;
1296
- const lh = le.labelHeight ?? sEdgeLabelFontSize + 6;
1297
- const lines = le.labelLines;
1298
- const labelG = labelLayer
1299
- .append('g')
1300
- .attr('class', 'bl-edge-label')
1301
- .attr('data-line-number', String(le.lineNumber));
1302
-
1303
- labelG
1304
- .append('rect')
1305
- .attr('x', lx - lw / 2)
1306
- .attr('y', ly - lh / 2)
1307
- .attr('width', lw)
1308
- .attr('height', lh)
1309
- .attr('rx', 3)
1310
- .attr('fill', palette.bg)
1311
- // No border; semi-transparent so the line stays faintly visible behind the
1312
- // label while the (full-opacity) text on top stays crisp.
1313
- .attr('opacity', 0.72);
1314
-
1315
- const text = labelG
1316
- .append('text')
1317
- .attr('x', lx)
1318
- .attr('text-anchor', 'middle')
1319
- .attr('font-family', FONT_FAMILY)
1320
- .attr('font-size', sEdgeLabelFontSize)
1321
- .attr('fill', palette.textMuted);
1322
- const firstY =
1323
- ly - ((lines.length - 1) / 2) * labelLineHeight + sEdgeLabelFontSize / 3;
1324
- lines.forEach((line, k) => {
1325
- text
1326
- .append('tspan')
1327
- .attr('x', lx)
1328
- .attr('y', firstY + k * labelLineHeight)
1329
- .text(line);
1330
- });
1331
- }
1332
-
1333
- // ── Render legend ──────────────────────────────────────
1334
- const hasDescriptions = parsed.nodes.some(
1335
- (n) => n.description && n.description.length > 0
1336
- );
1337
- // App-hosted: the Descriptions control moves to the app overlay, so a
1338
- // descriptions-only legend (no tag groups) has nothing left to render. The
1339
- // value ramp (a synthetic group in legendGroups) also forces a legend.
1340
- const hasLegend =
1341
- legendGroups.length > 0 || (hasDescriptions && controlsHost !== 'app');
1342
-
1343
- if (hasLegend) {
1344
- // Build controls group for description toggle. App-hosted controls own the
1345
- // toggling, so the group is built (to gate + size the row) even without the
1346
- // inline-gear callback.
1347
- let controlsGroup: { toggles: ControlsGroupToggle[] } | undefined;
1348
- if (hasDescriptions && (onToggleDescriptions || controlsHost === 'app')) {
1349
- controlsGroup = {
1350
- toggles: [
1351
- {
1352
- id: 'descriptions',
1353
- type: 'toggle',
1354
- label: 'Descriptions',
1355
- active: !hideDescriptions,
1356
- onToggle: () => {},
1357
- },
1358
- ],
1359
- };
1360
- }
1361
-
1362
- const legendCallbacks: LegendCallbacks = {
1363
- ...(onToggleControlsExpand !== undefined && {
1364
- onControlsExpand: onToggleControlsExpand,
1365
- }),
1366
- onControlsToggle: (toggleId, active) => {
1367
- if (toggleId === 'descriptions' && onToggleDescriptions) {
1368
- onToggleDescriptions(active);
1369
- }
1370
- },
1371
- };
1372
- const legendG = svg
1373
- .append('g')
1374
- .attr('transform', `translate(0,${titleOffset + 4})`);
1375
- renderIntegratedLegend(legendG, {
1376
- groups: legendGroups,
1377
- activeGroup,
1378
- mode: exportMode ? 'export' : 'preview',
1379
- // Keep inactive sibling tag groups visible as collapsed pills so the user
1380
- // can click one to flip the active colouring dimension (preview only).
1381
- showInactivePills: true,
1382
- ...(controlsGroup !== undefined && { controlsGroup }),
1383
- ...(controlsHost !== undefined && { controlsHost }),
1384
- ...(controlsExpanded !== undefined && { controlsExpanded }),
1385
- callbacks: legendCallbacks,
1386
- palette,
1387
- isDark,
1388
- width,
1389
- });
1390
- legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
1391
- }
1392
-
1393
- // ── Focus mode: one reusable hover-reveal icon (interactive only) ──
1394
- // A single hidden icon the app repositions over the hovered box/group and
1395
- // stamps `data-focus-id`/`data-focus-kind` on (Decision 22 / ADR-4) — NOT one
1396
- // per node (~4k elements on a large graph). Appended to the SVG root so the
1397
- // app positions it in root (screen-mapped) coordinates, counter-scaled to a
1398
- // constant size regardless of fit. Excluded from export like org's icon.
1399
- if (!exportDims && !exportMode) {
1400
- const iconSize = 14;
1401
- const focusG = svg
1402
- .append('g')
1403
- .attr('class', 'bl-focus-icon')
1404
- .attr('data-export-ignore', 'true')
1405
- .style('display', 'none')
1406
- .style('pointer-events', 'auto')
1407
- .style('cursor', 'pointer');
1408
- // Hit area
1409
- focusG
1410
- .append('rect')
1411
- .attr('x', -3)
1412
- .attr('y', -3)
1413
- .attr('width', iconSize + 6)
1414
- .attr('height', iconSize + 6)
1415
- .attr('fill', 'transparent');
1416
- // Scope/target icon: outer circle + inner dot (mirrors org-focus-icon)
1417
- const cx = iconSize / 2;
1418
- const cy = iconSize / 2;
1419
- focusG
1420
- .append('circle')
1421
- .attr('cx', cx)
1422
- .attr('cy', cy)
1423
- .attr('r', iconSize / 2 - 1)
1424
- .attr('fill', palette.bg)
1425
- .attr('stroke', palette.textMuted)
1426
- .attr('stroke-width', 1.5);
1427
- focusG
1428
- .append('circle')
1429
- .attr('cx', cx)
1430
- .attr('cy', cy)
1431
- .attr('r', 2)
1432
- .attr('fill', palette.textMuted);
1433
- }
1434
- }
1435
-
1436
- // ── Export helper ──────────────────────────────────────────
1437
-
1438
- export function renderBoxesAndLinesForExport(
1439
- container: HTMLDivElement,
1440
- parsed: ParsedBoxesAndLines,
1441
- layout: BLLayoutResult,
1442
- palette: PaletteColors,
1443
- isDark: boolean,
1444
- options?: {
1445
- exportDims?: { width: number; height: number };
1446
- activeTagGroup?: string | null;
1447
- hiddenTagValues?: Map<string, Set<string>>;
1448
- exportMode?: boolean;
1449
- }
1450
- ): void {
1451
- renderBoxesAndLines(container, parsed, layout, palette, isDark, {
1452
- ...(options?.exportDims !== undefined && {
1453
- exportDims: options.exportDims,
1454
- }),
1455
- ...(options?.activeTagGroup !== undefined && {
1456
- activeTagGroup: options.activeTagGroup,
1457
- }),
1458
- ...(options?.hiddenTagValues !== undefined && {
1459
- hiddenTagValues: options.hiddenTagValues,
1460
- }),
1461
- ...(options?.exportMode !== undefined && {
1462
- exportMode: options.exportMode,
1463
- }),
1464
- });
1465
- }