@diagrammo/dgmo 0.47.0 → 0.48.0

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 (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. 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
- }