@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,1445 +0,0 @@
1
- // Renderer (step 4, part 2): MapLayout → SVG, via d3-selection. THIN — all
2
- // geometry/color/collision decisions are layout.ts; this only emits attributes.
3
- // Layering regions → legs(edges/routes) → POIs → labels → legend (§24B.11).
4
- // Mirrors every structured renderer's contract (container in, SVG appended,
5
- // returns void). Legend uses renderLegendD3 — the d3-selection legend path —
6
- // NOT the string renderLegendSvg (AR1).
7
- import * as d3Selection from 'd3-selection';
8
- import { FONT_FAMILY } from '../fonts';
9
- import {
10
- TITLE_FONT_SIZE,
11
- TITLE_FONT_WEIGHT,
12
- TITLE_Y,
13
- } from '../utils/title-constants';
14
- import { mix } from '../palettes/color-utils';
15
- import { measureText } from '../utils/text-measure';
16
- import { renderIntegratedLegend } from '../utils/legend-integration';
17
- import type { LegendConfig } from '../utils/legend-types';
18
- import { mapLegendConfig, mapLegendGroups } from './legend-band';
19
- import type { PaletteColors } from '../palettes/types';
20
- import type { D3ExportDimensions } from '../utils/d3-types';
21
- import type { MapData, ResolvedMap } from './resolved-types';
22
- import {
23
- layoutMap,
24
- parsePathRings,
25
- type MapLayoutRegion,
26
- type MapLayoutCoastlineStyle,
27
- type PlacedLabel,
28
- } from './layout';
29
-
30
- const LABEL_FONT = 11;
31
-
32
- // ── Coastline water-lines helpers (opt-in `coastline`, §24B.2) ──
33
- // Geometry is derived from the already-drawn region paths: each outer ring is
34
- // buffered as a symmetric SVG stroke band then eroded (flat-water overdraw) to a
35
- // thin offshore ring; a luminance <mask> reveals only the water side. See the
36
- // render block + ADR-1/6 in the tech-spec.
37
-
38
- /** Even-odd point-in-ring test (screen space). */
39
- function pointInRing(
40
- px: number,
41
- py: number,
42
- ring: ReadonlyArray<[number, number]>
43
- ): boolean {
44
- let inside = false;
45
- for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
46
- const [xi, yi] = ring[i]!;
47
- const [xj, yj] = ring[j]!;
48
- if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi)
49
- inside = !inside;
50
- }
51
- return inside;
52
- }
53
-
54
- /** Build an SVG subpath `d` (`M…L…Z`) from a ring's points. */
55
- function ringToPath(ring: ReadonlyArray<[number, number]>): string {
56
- let d = '';
57
- for (let i = 0; i < ring.length; i++)
58
- d += (i ? 'L' : 'M') + ring[i]![0] + ',' + ring[i]![1];
59
- return d + 'Z';
60
- }
61
-
62
- /** Open SVG polyline (`M…L…`, no `Z`) from a run of points. */
63
- function polylineToPath(pts: ReadonlyArray<[number, number]>): string {
64
- let d = '';
65
- for (let i = 0; i < pts.length; i++)
66
- d += (i ? 'L' : 'M') + pts[i]![0] + ',' + pts[i]![1];
67
- return d;
68
- }
69
-
70
- /** Coast subpaths for one ring, dropping any edge that runs ALONG a canvas edge.
71
- * A region clipped to the viewport (the antimeridian on a world map, or a
72
- * regional clipExtent cut) gains a synthetic straight edge collinear with the
73
- * frame — that edge is NOT a real coast and must not be buffered into a coast
74
- * band (which would ring the cut with water-lines short of the edge). Without a
75
- * `frame` the ring is returned closed (`M…Z`) as before. With one, the ring is
76
- * split at every frame-collinear edge into open coast arcs (`M…L…`), so the land
77
- * runs cleanly to the edge and only true coastline gets a water-line. */
78
- function ringToCoastPaths(
79
- ring: ReadonlyArray<[number, number]>,
80
- frame?: { w: number; h: number }
81
- ): string[] {
82
- if (!frame) return [ringToPath(ring)];
83
- const n = ring.length;
84
- const eps = 0.75;
85
- const onL = (x: number): boolean => Math.abs(x) <= eps;
86
- const onR = (x: number): boolean => Math.abs(x - frame.w) <= eps;
87
- const onT = (y: number): boolean => Math.abs(y) <= eps;
88
- const onB = (y: number): boolean => Math.abs(y - frame.h) <= eps;
89
- const isFrameEdge = (
90
- a: readonly [number, number],
91
- b: readonly [number, number]
92
- ): boolean =>
93
- (onL(a[0]) && onL(b[0])) ||
94
- (onR(a[0]) && onR(b[0])) ||
95
- (onT(a[1]) && onT(b[1])) ||
96
- (onB(a[1]) && onB(b[1]));
97
- // No frame-collinear edge anywhere → ordinary interior coastline (closed).
98
- let firstBreak = -1;
99
- for (let i = 0; i < n; i++)
100
- if (isFrameEdge(ring[i]!, ring[(i + 1) % n]!)) {
101
- firstBreak = i;
102
- break;
103
- }
104
- if (firstBreak === -1) return [ringToPath(ring)];
105
- // Walk the loop from just after the first cut, accumulating runs of real-coast
106
- // edges into open polylines and breaking at each frame-collinear edge.
107
- const paths: string[] = [];
108
- let cur: Array<[number, number]> = [];
109
- const start = (firstBreak + 1) % n;
110
- for (let k = 0; k < n; k++) {
111
- const i = (start + k) % n;
112
- const a = ring[i]!;
113
- const b = ring[(i + 1) % n]!;
114
- if (isFrameEdge(a, b)) {
115
- if (cur.length >= 2) paths.push(polylineToPath(cur));
116
- cur = [];
117
- continue;
118
- }
119
- if (cur.length === 0) cur.push(a);
120
- cur.push(b);
121
- }
122
- if (cur.length >= 2) paths.push(polylineToPath(cur));
123
- return paths;
124
- }
125
-
126
- /** Coast outlines to buffer: every region's OUTER rings whose bbox extent clears
127
- * `minExtent`. Holes/enclaves are skipped via containment depth (even depth =
128
- * outer landmass boundary, odd = a hole) so an enclave (Lesotho) or a lake-hole
129
- * is never ringed as a fake coast on land (R11). `minExtent` is a bare
130
- * degenerate-ring floor now — every island, however small, grows coast rings. */
131
- function coastlineOuterRings(
132
- regions: readonly MapLayoutRegion[],
133
- minExtent: number,
134
- frame?: { w: number; h: number }
135
- ): string[] {
136
- const paths: string[] = [];
137
- for (const r of regions) {
138
- // Reuse the rings parsed once in layoutMap; fall back for older layouts.
139
- const rings =
140
- (r.rings as Array<Array<[number, number]>>) ?? parsePathRings(r.d);
141
- for (let i = 0; i < rings.length; i++) {
142
- const ring = rings[i]!;
143
- if (ring.length < 3) continue;
144
- let minX = Infinity;
145
- let minY = Infinity;
146
- let maxX = -Infinity;
147
- let maxY = -Infinity;
148
- for (const [x, y] of ring) {
149
- if (x < minX) minX = x;
150
- if (x > maxX) maxX = x;
151
- if (y < minY) minY = y;
152
- if (y > maxY) maxY = y;
153
- }
154
- if (Math.max(maxX - minX, maxY - minY) < minExtent) continue;
155
- const [fx, fy] = ring[0]!;
156
- let depth = 0;
157
- for (let j = 0; j < rings.length; j++)
158
- if (j !== i && pointInRing(fx, fy, rings[j]!)) depth++;
159
- if (depth % 2 === 1) continue; // hole/enclave — skip
160
- paths.push(...ringToCoastPaths(ring, frame));
161
- }
162
- }
163
- return paths;
164
- }
165
-
166
- /** Stroke the coast-parallel water-lines into a masked group. Per line, outer→
167
- * inner so the inner ring draws on top: a colour pass (the symmetric buffer
168
- * band) then a flat-water overdraw that erodes it to a thin offshore ring. The
169
- * group's `<mask>` keeps only the water-side half of each band.
170
- *
171
- * The outer→inner ordering protects a single ring (the inner band never reaches
172
- * the outer ring because `d1+thickness < d2`, the layout invariant). It does NOT
173
- * protect across regions: where two coasts sit closer than ~2·d1 (a tripoint, a
174
- * narrow strait, an inset box edge), one region's flat-water overdraw can paint
175
- * over a neighbour's inner ring — the same accepted "tripoint stub / narrow
176
- * inlet fills solid" artifact the tech-spec calls out, bounded by small d.
177
- *
178
- * Perf: every outer ring in a given (level, pass) shares identical stroke
179
- * attributes, so they collapse into ONE multi-subpath `<path>` (each ring's
180
- * `d` already starts with `M`, so joining is a valid compound path). A world
181
- * map drops from ~6k coastline paths to 2 per ring-level (~10 total), which is
182
- * ~87% of the whole map's path count — the dominant cost in any repaint. The
183
- * only visible consequence: the colour-band pass strokes at `stroke-opacity`,
184
- * and a single path's stroke rasterises as ONE coverage region, so adjacent
185
- * bands that overlap (straits/tripoints) no longer double-darken — they read
186
- * uniform, which is if anything cleaner. Draw order (all colour bands, then all
187
- * flat-water, outer level first) is unchanged, so the single-ring invariant and
188
- * the accepted cross-region overdraw artifact behave exactly as before. */
189
- function appendWaterLines(
190
- g: Sel,
191
- defs: Sel,
192
- coastId: string,
193
- outerRings: readonly string[],
194
- style: MapLayoutCoastlineStyle,
195
- flatWater: string
196
- ): void {
197
- const d = outerRings.join(' ');
198
- if (!d) return;
199
- // The coast geometry is byte-for-byte identical across every line-level and
200
- // both passes (only stroke colour/width differ). On a world map that compound
201
- // path is ~200KB, so emitting it ~12× inline ballooned the SVG to multi-MB and
202
- // overflowed the SSG HTML reparse. Define it once and `<use>` it: same pixels,
203
- // a fraction of the bytes.
204
- defs.append('path').attr('id', coastId).attr('d', d).attr('fill', 'none');
205
- const ref = `#${coastId}`;
206
- const linesOuterFirst = [...style.lines].sort((a, b) => b.d - a.d);
207
- for (const line of linesOuterFirst) {
208
- g.append('use')
209
- .attr('href', ref)
210
- .attr('stroke', style.color)
211
- .attr('stroke-width', 2 * (line.d + line.thickness))
212
- .attr('stroke-opacity', line.opacity)
213
- .attr('stroke-linejoin', 'round')
214
- .attr('stroke-linecap', 'round');
215
- g.append('use')
216
- .attr('href', ref)
217
- .attr('stroke', flatWater)
218
- .attr('stroke-width', 2 * line.d)
219
- .attr('stroke-linejoin', 'round')
220
- .attr('stroke-linecap', 'round');
221
- }
222
- }
223
-
224
- // Per-render namespace for SVG def ids (clipPaths, masks, filters, markers, the
225
- // `<use>`-shared coast path). SVG `url(#id)` / `href="#id"` resolve document-
226
- // globally to the FIRST matching id, so when several maps are inlined on one
227
- // page (the docs gallery, an MDX page) shared constant ids made every later map
228
- // reference the first map's defs — its coast `<use>` ghosted through. A
229
- // monotonic per-render suffix makes every render's ids unique. NOT reset between
230
- // renders, so re-renders (legend flips) and same-page siblings never collide.
231
- let mapInstanceCounter = 0;
232
-
233
- /** Render a resolved map into `container` (d3-selection appends an `<svg>`). */
234
- export function renderMap(
235
- container: HTMLDivElement,
236
- resolved: ResolvedMap,
237
- data: MapData,
238
- palette: PaletteColors,
239
- isDark: boolean,
240
- onClickItem?: (lineNumber: number) => void,
241
- exportDims?: D3ExportDimensions,
242
- /** Live override of the active colouring group (interactive legend flip). */
243
- activeGroupOverride?: string | null
244
- ): void {
245
- d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
246
- const width = exportDims?.width ?? container.clientWidth;
247
- const height = exportDims?.height ?? container.clientHeight;
248
- if (width <= 0 || height <= 0) return;
249
-
250
- const layout = layoutMap(
251
- resolved,
252
- data,
253
- { width, height },
254
- {
255
- palette,
256
- isDark,
257
- // Export-only: forward the contain-fit request from mapExportDimensions so a
258
- // clamped/floored (off-aspect) export canvas letterboxes instead of
259
- // stretch-distorting. The in-app preview pane passes no exportDims → unset →
260
- // keeps the global stretch-fill.
261
- preferContain: exportDims?.preferContain ?? false,
262
- // Reserve the legend band for the mode actually drawn below (export shows
263
- // only the active group; preview keeps the inactive pills).
264
- legendMode: exportDims ? 'export' : 'preview',
265
- ...(activeGroupOverride !== undefined && {
266
- activeGroup: activeGroupOverride,
267
- }),
268
- }
269
- );
270
-
271
- const svg = d3Selection
272
- .select(container)
273
- .append('svg')
274
- .attr('width', width)
275
- .attr('height', height)
276
- .attr('viewBox', `0 0 ${width} ${height}`)
277
- .attr('preserveAspectRatio', 'xMidYMin meet')
278
- .attr('xmlns', 'http://www.w3.org/2000/svg')
279
- .style('font-family', FONT_FAMILY)
280
- // Match the SVG element background to the water rect so any letterboxing
281
- // (when the host container's aspect differs from the viewBox) shows water,
282
- // not the gray palette bg that finalizeSvgExport would otherwise apply —
283
- // i.e. no stray band above/below the map.
284
- .style('background', layout.background);
285
-
286
- svg
287
- .append('rect')
288
- .attr('width', width)
289
- .attr('height', height)
290
- .attr('fill', layout.background);
291
-
292
- // Arrowhead markers for directed legs. Sized in user-space (NOT the SVG
293
- // default of stroke-width units) so a heavy weighted line doesn't blow the
294
- // arrowhead up to a giant wedge. The size grows gently with the line width —
295
- // enough to stay distinct from the stroke — but is firmly capped.
296
- const defs = svg.append('defs');
297
- // Namespace every def id minted below so multiple maps on one page don't share
298
- // `url(#…)` targets (see mapInstanceCounter).
299
- const uid = mapInstanceCounter++;
300
- const nid = (base: string): string => `${base}__m${uid}`;
301
- // Dampened: ~8px at the thinnest leg, easing toward a 15px cap as legs widen.
302
- const arrowSize = (w: number): number => Math.min(15, 7 + w * 0.95);
303
-
304
- // Neutral bg (not the water-tinted backdrop) so label halos read over both
305
- // land and ocean.
306
- const haloColor = palette.bg;
307
-
308
- // Title / subtitle / caption are rendered LAST (see end of function) so they
309
- // sit in the foreground above the basemap, POIs, and labels.
310
-
311
- // ── Regions ──
312
- const gRegions = svg.append('g').attr('class', 'dgmo-map-regions');
313
- const drawRegion = (
314
- g: Sel,
315
- r: MapLayoutRegion,
316
- strokeWidth: number
317
- ): void => {
318
- const p = g
319
- .append('path')
320
- .attr('d', r.d)
321
- .attr('fill', r.fill)
322
- .attr('stroke', r.stroke)
323
- .attr('stroke-width', strokeWidth);
324
- // Display name on EVERY region (authored + base/context) so the app can
325
- // surface it on hover — decorative metadata, no visible text drawn here.
326
- if (r.label) p.attr('data-region-name', r.label);
327
- // ISO id on EVERY political region (authored + base/context + inset states),
328
- // so the Inspect tool can resolve a reverse-geocoded `{iso}` to its drawn path
329
- // and outline it. Distinct from `data-region` (data-layer-only, legend hover):
330
- // this lands on base/context land too. Lakes carry no iso (not a place).
331
- if (r.id && r.id !== 'lake') p.attr('data-iso', r.id);
332
- // Area-weighted centroid (px) the app anchors the hover label to — robust to
333
- // antimeridian crossers where a bounding-box centre lands in open ocean.
334
- if (r.labelX !== undefined && r.labelY !== undefined) {
335
- p.attr('data-label-x', r.labelX).attr('data-label-y', r.labelY);
336
- }
337
- // Data layer? Tag it so the app can highlight on legend hover / gradient
338
- // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
339
- // (both lowercased to match the lowercased legend-entry attributes).
340
- if (r.layer !== 'base') {
341
- p.classed('dgmo-map-region', true).attr('data-region', r.id);
342
- if (r.value !== undefined) p.attr('data-value', r.value);
343
- if (r.tags) {
344
- for (const [group, value] of Object.entries(r.tags)) {
345
- p.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
346
- }
347
- }
348
- }
349
- if (r.lineNumber >= 0) {
350
- p.attr('data-line-number', r.lineNumber);
351
- if (onClickItem) {
352
- p.style('cursor', 'pointer').on('click', () =>
353
- onClickItem(r.lineNumber)
354
- );
355
- }
356
- }
357
- };
358
- for (const r of layout.regions) drawRegion(gRegions, r, 0.5);
359
-
360
- // ── Relief (mountain-range hachure over ALL land, under rivers/POIs/labels) ──
361
- // Rule horizontal lines across the whole canvas, clipped to the INTERSECTION
362
- // of (a) the union of range polygons and (b) the land — nested clipPaths, so
363
- // the hachure never bleeds onto water (coarse range polygons overrun the
364
- // coast, and horizontal lines on the sea read as the water convention). The
365
- // land clip is every drawn region except lakes — INCLUDING value-/tag-coloured
366
- // regions, so the relief texture sits ATOP the choropleth/tag fills (a range
367
- // crossing a valued state still reads as mountains there). It stays below
368
- // rivers, POIs, and labels. Explicit <line>s in a <clipPath> (not a tiled
369
- // <pattern>) dodge WKWebView/resvg pattern quirks. A non-scaling stroke keeps
370
- // the width constant in device px at any zoom/DPR (uniform, no moire); kept
371
- // sub-pixel + low-contrast so the texture stays faint. Decorative — no data attrs.
372
- if (layout.relief.length && layout.reliefHatch) {
373
- const h = layout.reliefHatch;
374
- const rangeClipId = nid('dgmo-relief-clip');
375
- const landClipId = nid('dgmo-relief-land');
376
- const rangeClip = defs.append('clipPath').attr('id', rangeClipId);
377
- for (const s of layout.relief) rangeClip.append('path').attr('d', s.d);
378
- const landClip = defs.append('clipPath').attr('id', landClipId);
379
- for (const r of layout.regions)
380
- if (r.id !== 'lake') landClip.append('path').attr('d', r.d);
381
- const gRelief = svg
382
- .append('g')
383
- .attr('clip-path', `url(#${landClipId})`) // outer: land only
384
- // Decorative texture — never a pointer target, so region hover (the app's
385
- // name-on-hover) always reaches the region path beneath. WebKit hit-tests
386
- // masked/clipped overlays unlike Chromium, so this must be explicit.
387
- .style('pointer-events', 'none')
388
- .append('g')
389
- .attr('class', 'dgmo-map-relief')
390
- .attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
391
- .attr('stroke', h.color)
392
- .attr('stroke-width', h.width)
393
- // Non-scaling stroke = constant device width at any zoom/DPR (uniform,
394
- // no moire). NOT crispEdges — that snaps to a solid ~1px in WebKit and
395
- // reads far too heavy; plain AA keeps the sub-pixel lines whisper-thin.
396
- .attr('vector-effect', 'non-scaling-stroke');
397
- for (let y = h.spacing; y < height; y += h.spacing) {
398
- gRelief
399
- .append('line')
400
- .attr('x1', 0)
401
- .attr('y1', y)
402
- .attr('x2', width)
403
- .attr('y2', y);
404
- }
405
- }
406
-
407
- // ── Coastline water-lines (faint nautical-chart lines on the WATER side) ──
408
- // 2 discrete coast-parallel lines hugging the ocean shore + lake shores, fading
409
- // seaward. Each region's outer ring is buffered as a symmetric SVG stroke band
410
- // then eroded with a flat-water overdraw to a thin offshore ring; a luminance
411
- // <mask> (white canvas − black land + white lakes) reveals only the water side,
412
- // so land/land borders self-remove (their band falls on
413
- // the neighbour's land, which the mask hides — no topojson.mesh needed). NOT a
414
- // clipPath: sibling clip paths UNION (can't subtract land). Decorative — no data
415
- // attrs, plain strokes. Below rivers/POIs/legs/labels, above region/relief fills
416
- // (so with `relief` on, water-lines sit on water and relief on land — disjoint).
417
- // §24B.2, ADR-1/3/6.
418
- if (layout.coastlineStyle) {
419
- const cs = layout.coastlineStyle;
420
- const maskId = nid('dgmo-map-water-mask');
421
- const mask = defs
422
- .append('mask')
423
- .attr('id', maskId)
424
- // userSpaceOnUse: the default objectBoundingBox clamps the mask region to
425
- // the group's own bbox and drops the canvas-edge reveal (round-2 #2).
426
- .attr('maskUnits', 'userSpaceOnUse')
427
- .attr('x', 0)
428
- .attr('y', 0)
429
- .attr('width', width)
430
- .attr('height', height);
431
- mask
432
- .append('rect')
433
- .attr('x', 0)
434
- .attr('y', 0)
435
- .attr('width', width)
436
- .attr('height', height)
437
- .attr('fill', 'white');
438
- // All land fills are opaque black and all lake fills opaque white, so each
439
- // group collapses into ONE compound path (land first, lakes over it to
440
- // re-reveal their interiors). Identical pixels, ~hundreds of paths → 2.
441
- const landD = layout.regions
442
- .filter((r) => r.id !== 'lake')
443
- .map((r) => r.d)
444
- .join(' ');
445
- const lakeD = layout.regions
446
- .filter((r) => r.id === 'lake')
447
- .map((r) => r.d)
448
- .join(' ');
449
- if (landD) mask.append('path').attr('d', landD).attr('fill', 'black');
450
- if (lakeD) mask.append('path').attr('d', lakeD).attr('fill', 'white');
451
- // Moat around each AK/HI inset box: the opaque box is drawn in the FOREGROUND
452
- // over open ocean, so the main-map offshore rings get chopped by its border
453
- // and butt into it — sloppy. Paint each box quad black (interior, under the
454
- // box) with a black stroke whose half-width = the band's outer reach, so the
455
- // main rings also clear a margin OUTSIDE the border. The ring-ends then fall
456
- // in open water away from the frame instead of crashing into it.
457
- if (layout.insets.length) {
458
- const reach = Math.max(0, ...cs.lines.map((l) => l.d + l.thickness));
459
- for (const box of layout.insets) {
460
- const d =
461
- box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
462
- 'Z';
463
- mask
464
- .append('path')
465
- .attr('d', d)
466
- .attr('fill', 'black')
467
- .attr('stroke', 'black')
468
- .attr('stroke-width', 2 * reach)
469
- .attr('stroke-linejoin', 'round');
470
- }
471
- }
472
- // NO frame band: a synthetic frame-cut edge (clipExtent/cullFeatureToView
473
- // trims region `d` to the view rect) has the region INTERIOR — land — on the
474
- // canvas-interior side, which the mask already paints black, so its band is
475
- // hidden anyway. A border band here only suppressed REAL coastal rings near
476
- // the canvas edge, leaving an empty top/bottom strip — so the water-lines now
477
- // carry through to every edge of the visible map.
478
- const gWater = svg
479
- .append('g')
480
- .attr('class', 'dgmo-map-water-lines')
481
- .attr('fill', 'none')
482
- // Decorative nautical lines — never a pointer target. Without this the wide
483
- // pre-mask coastal ring bands swallow region hover over coastal countries in
484
- // WebKit (which hit-tests masked content unlike Chromium); e.g. Portugal.
485
- .style('pointer-events', 'none')
486
- .attr('mask', `url(#${maskId})`);
487
- appendWaterLines(
488
- gWater,
489
- defs,
490
- nid('dgmo-map-coast'),
491
- // Pass the canvas frame so edges collinear with it (the antimeridian on a
492
- // world map, regional clipExtent cuts) don't get ringed as fake coast —
493
- // land runs cleanly to the render-area edge.
494
- coastlineOuterRings(layout.regions, cs.minExtent, {
495
- w: width,
496
- h: height,
497
- }),
498
- cs,
499
- layout.background
500
- );
501
- // Restore the seaward half of the coast stroke: the rings' flat-water erosion
502
- // overdraws repaint the water out to d_max, which paints over the water-side
503
- // half of each region's coast outline and makes coastlines read faded. Re-
504
- // stroke every region inside the SAME masked group (so it only repaints the
505
- // water side — the land side was never touched, and interior land/land borders
506
- // stay hidden), on top of the rings. The strokes sit at the coast (offset 0),
507
- // well inside d0, so they never cover the offshore rings.
508
- // Group the restroke by stroke colour (base borders share one colour; the
509
- // occasional data region may differ) so same-coloured coasts collapse into a
510
- // single compound path instead of one path per region.
511
- const byStroke = new Map<string, string[]>();
512
- for (const r of layout.regions) {
513
- const arr = byStroke.get(r.stroke);
514
- if (arr) arr.push(r.d);
515
- else byStroke.set(r.stroke, [r.d]);
516
- }
517
- for (const [stroke, ds] of byStroke)
518
- gWater
519
- .append('path')
520
- .attr('d', ds.join(' '))
521
- .attr('stroke', stroke)
522
- .attr('stroke-width', 0.5)
523
- .attr('stroke-linejoin', 'round');
524
- }
525
-
526
- // ── Rivers (thin water centerlines over the land, under POIs/edges) ──
527
- if (layout.rivers.length) {
528
- const gRivers = svg
529
- .append('g')
530
- .attr('class', 'dgmo-map-rivers')
531
- .attr('fill', 'none')
532
- .style('pointer-events', 'none'); // decorative — pass hover to regions below
533
- for (const r of layout.rivers) {
534
- gRivers
535
- .append('path')
536
- .attr('d', r.d)
537
- .attr('stroke', r.color)
538
- .attr('stroke-width', r.width)
539
- .attr('stroke-linecap', 'round')
540
- .attr('stroke-linejoin', 'round');
541
- }
542
- }
543
-
544
- // ── City dots — a faint gazetteer scatter for geographic orientation, over
545
- // the basemap but under connectors/POIs/labels. Decorative (no pointer events,
546
- // no data attributes); muted ink at low opacity so it reads as texture, not
547
- // data. Suppressed entirely by the `no-cities` directive (empty array). ──
548
- if (layout.cityDots.length) {
549
- const gCities = svg
550
- .append('g')
551
- .attr('class', 'dgmo-map-cities')
552
- .attr('fill', palette.textMuted)
553
- .attr('fill-opacity', 0.28)
554
- .style('pointer-events', 'none');
555
- for (const c of layout.cityDots) {
556
- gCities.append('circle').attr('cx', c.cx).attr('cy', c.cy).attr('r', c.r);
557
- }
558
- }
559
-
560
- // ── Label clarity patch — repaint the clean region FILL under each POI label
561
- // so the basemap line work beneath it (region borders, relief hachure, coast +
562
- // river lines) dissolves where it would clutter the text. Crucially this is NOT
563
- // a halo: the patch IS the same fill that already surrounds the label, so over
564
- // plain land it is invisible and ONLY the crossing lines disappear — no white
565
- // pad over flat colour. A blurred-blob mask feathers each patch into the map
566
- // (soft edges, no hard rectangle). Sits above all basemap line work, below the
567
- // dots / legs / labels. POI labels only; context/water labels keep their lines
568
- // (they read as cartographic texture, not clutter).
569
- //
570
- // Collapsed cluster members are HIDDEN at rest, so their labels aren't on screen
571
- // — patching under them would fade the basemap beneath an invisible label. So
572
- // each cluster's member patches go in a per-cluster group tagged
573
- // `data-cluster-deco`, which the app's spiderfy controller toggles in lockstep
574
- // with the legs / member dots: the fade only appears once the fan is expanded.
575
- // Non-clustered POI labels (and ALL labels in export / `no-cluster-pois`, where
576
- // the fan is permanently open) keep the always-on patch so the static image
577
- // matches what's on screen.
578
- const clusterUi = !exportDims && !resolved.directives.noClusterPois;
579
- const poiLabels = layout.labels.filter(
580
- (l) => l.poiId !== undefined && !l.hidden
581
- );
582
- if (poiLabels.length) {
583
- const patchBlurId = nid('dgmo-map-label-patch-blur');
584
- // Soft falloff so the patch dissolves into the surrounding basemap instead of
585
- // ending on a hard edge. Tuned at the 11px label font. One shared filter for
586
- // every patch group.
587
- defs
588
- .append('filter')
589
- .attr('id', patchBlurId)
590
- .attr('x', '-50%')
591
- .attr('y', '-50%')
592
- .attr('width', '200%')
593
- .attr('height', '200%')
594
- .append('feGaussianBlur')
595
- .attr('in', 'SourceGraphic')
596
- .attr('stdDeviation', 2.5);
597
- const PAD = 8; // ≳ blur reach so culled regions can't lose a fringe
598
-
599
- // Build one patch (mask of blurred blobs + the region fills they cross) for a
600
- // set of labels. `decoCluster` tags the group so the app toggles it with the
601
- // rest of the cluster's spiderfy decoration.
602
- const buildPatch = (
603
- labels: typeof poiLabels,
604
- maskId: string,
605
- decoCluster?: string
606
- ): void => {
607
- if (!labels.length) return;
608
- const patchMask = defs
609
- .append('mask')
610
- .attr('id', maskId)
611
- // userSpaceOnUse: the patch group's bbox is the whole basemap, but keep
612
- // the mask region pinned to the canvas (parity with the water masks).
613
- .attr('maskUnits', 'userSpaceOnUse')
614
- .attr('x', 0)
615
- .attr('y', 0)
616
- .attr('width', width)
617
- .attr('height', height);
618
- // White blobs = where the patch shows; the surrounding black hides it. Each
619
- // blob is a SOLID rounded rect sized to the text bbox so the core fully
620
- // hides the line work behind the glyphs; the blur only feathers the outer
621
- // edge. Solid core → if an engine drops the in-mask blur (some WebKit
622
- // builds), it degrades to a clean stadium patch, never a halo.
623
- const blobs = patchMask
624
- .append('g')
625
- .attr('filter', `url(#${patchBlurId})`);
626
- // Padded rects (incl. the blur reach) drive both the mask AND the region
627
- // cull below, so the two never disagree.
628
- const blobRects: Array<{
629
- x0: number;
630
- y0: number;
631
- x1: number;
632
- y1: number;
633
- }> = [];
634
- for (const l of labels) {
635
- const multi = l.lines && l.lines.length > 1;
636
- const tw = multi
637
- ? Math.max(...l.lines!.map((s) => measureText(s, LABEL_FONT)))
638
- : measureText(l.text, LABEL_FONT);
639
- const th = multi ? l.lines!.length * (LABEL_FONT + 2) : LABEL_FONT;
640
- // Anchor → blob centre x; cy nudged up from the text baseline to the
641
- // glyph visual centre.
642
- const cx =
643
- l.anchor === 'start'
644
- ? l.x + tw / 2
645
- : l.anchor === 'end'
646
- ? l.x - tw / 2
647
- : l.x;
648
- const cy = l.y - LABEL_FONT * 0.3;
649
- const w = tw + 8;
650
- const h = th + 6;
651
- blobs
652
- .append('rect')
653
- .attr('x', cx - w / 2)
654
- .attr('y', cy - h / 2)
655
- .attr('width', w)
656
- .attr('height', h)
657
- .attr('rx', h / 2)
658
- .attr('fill', 'white');
659
- blobRects.push({
660
- x0: cx - w / 2 - PAD,
661
- y0: cy - h / 2 - PAD,
662
- x1: cx + w / 2 + PAD,
663
- y1: cy + h / 2 + PAD,
664
- });
665
- }
666
- const gPatch = svg
667
- .append('g')
668
- .attr('class', 'dgmo-map-label-patch')
669
- .attr('mask', `url(#${maskId})`)
670
- // Decorative cover — never a pointer target so region hover / name-on-
671
- // hover reaches the real region paths beneath (WebKit hit-tests masked
672
- // content).
673
- .style('pointer-events', 'none');
674
- // Per-cluster patch: tag as cluster decoration so the spiderfy controller
675
- // shows/hides it in step with the fan. (The app collapses all `[data-
676
- // cluster-deco]` to opacity 0 at rest, pre-paint, so there's no flash.)
677
- if (decoCluster !== undefined)
678
- gPatch.attr('data-cluster-deco', decoCluster);
679
- // Redraw fills only (no stroke), in document order, but ONLY for regions
680
- // whose bbox overlaps a label blob — the mask already hides everything
681
- // else, so emitting the rest is pure SVG weight (matters on dense world
682
- // maps). The kept regions reproduce the exact composite colour under each
683
- // label, so the patch is seamless and the only visible change is the
684
- // vanished line work.
685
- for (const r of layout.regions) {
686
- // bbox is precomputed once in layoutMap (roadmap #2); fall back to
687
- // parsing only for layouts predating that field.
688
- let minX: number, minY: number, maxX: number, maxY: number;
689
- if (r.bbox) {
690
- [minX, minY, maxX, maxY] = r.bbox;
691
- } else {
692
- minX = Infinity;
693
- minY = Infinity;
694
- maxX = -Infinity;
695
- maxY = -Infinity;
696
- for (const ring of parsePathRings(r.d))
697
- for (const [px, py] of ring) {
698
- if (px < minX) minX = px;
699
- if (px > maxX) maxX = px;
700
- if (py < minY) minY = py;
701
- if (py > maxY) maxY = py;
702
- }
703
- }
704
- const hit = blobRects.some(
705
- (b) => minX <= b.x1 && maxX >= b.x0 && minY <= b.y1 && maxY >= b.y0
706
- );
707
- if (hit) gPatch.append('path').attr('d', r.d).attr('fill', r.fill);
708
- }
709
- };
710
-
711
- if (clusterUi) {
712
- // Always-on patch for labels that are visible at rest (non-cluster members).
713
- buildPatch(
714
- poiLabels.filter((l) => l.clusterMember === undefined),
715
- nid('dgmo-map-label-patch')
716
- );
717
- // Per-cluster patches, hidden until the fan expands.
718
- const byCluster = new Map<string, typeof poiLabels>();
719
- for (const l of poiLabels) {
720
- if (l.clusterMember === undefined) continue;
721
- const arr = byCluster.get(l.clusterMember);
722
- if (arr) arr.push(l);
723
- else byCluster.set(l.clusterMember, [l]);
724
- }
725
- // Safe mask id from the iteration index — cluster ids can be coord-based
726
- // (`@lat,lon`) and aren't `url(#…)`-safe; the raw id rides on the
727
- // data-cluster-deco attribute instead.
728
- let ci = 0;
729
- for (const [cid, labs] of byCluster)
730
- buildPatch(labs, nid(`dgmo-map-label-patch-c${ci++}`), cid);
731
- } else {
732
- // Export / `no-cluster-pois`: fan is permanently open → one always-on patch
733
- // over every POI label.
734
- buildPatch(poiLabels, nid('dgmo-map-label-patch'));
735
- }
736
- }
737
-
738
- // ── AK / HI insets (albers-usa) — drawn in the FOREGROUND so the opaque ocean
739
- // box hides the main-map neighbour land (Mexico's Baja) behind it; the state
740
- // then draws on top, framed by the box border. ──
741
- if (layout.insets.length) {
742
- const insetG = svg.append('g').attr('class', 'dgmo-map-insets');
743
- layout.insets.forEach((box, bi) => {
744
- // Angled-top quad frame — rides under the conus coast so it never covers
745
- // neighbouring states. Closed path from the four corners.
746
- const d =
747
- box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
748
- 'Z';
749
- insetG
750
- .append('path')
751
- .attr('d', d)
752
- .attr('fill', layout.background)
753
- .attr('stroke', mix(palette.text, palette.bg, 55))
754
- .attr('stroke-width', 1)
755
- .attr('stroke-linejoin', 'round');
756
- // Neighbour land (Canada beside Alaska) clipped to this box, behind the
757
- // state — so a land border reads as land rather than sprouting coast rings.
758
- if (box.contextLand) {
759
- const clipId = nid(`dgmo-map-inset-clip-${bi}`);
760
- defs.append('clipPath').attr('id', clipId).append('path').attr('d', d);
761
- insetG
762
- .append('path')
763
- .attr('d', box.contextLand.d)
764
- .attr('fill', box.contextLand.fill)
765
- .attr('clip-path', `url(#${clipId})`);
766
- }
767
- });
768
- for (const r of layout.insetRegions) drawRegion(insetG, r, 0.5);
769
-
770
- // Inset coastline water-lines (AK/HI box interiors) for visual parity with
771
- // the main map. Mask = the inset box quads (white reveal) − inset regions
772
- // (black land / white lake); buffer+erode the inset region outer rings the
773
- // same way. Inside the inset group so it composites over the box fills.
774
- if (layout.coastlineStyle) {
775
- const cs = layout.coastlineStyle;
776
- const maskId = nid('dgmo-map-inset-water-mask');
777
- const mask = defs
778
- .append('mask')
779
- .attr('id', maskId)
780
- .attr('maskUnits', 'userSpaceOnUse')
781
- .attr('x', 0)
782
- .attr('y', 0)
783
- .attr('width', width)
784
- .attr('height', height);
785
- // White reveal = the box interior, but eroded inward by `reach` (a black
786
- // border stroke) so the seaward rings fade out before the frame instead of
787
- // being hard-clipped at it — clipped ring fragments otherwise read as
788
- // artifacts bumping into the crisp 1px border. Mirrors the main-map moat,
789
- // applied inward.
790
- const reach = Math.max(0, ...cs.lines.map((l) => l.d + l.thickness));
791
- for (const box of layout.insets) {
792
- const d =
793
- box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
794
- 'Z';
795
- mask
796
- .append('path')
797
- .attr('d', d)
798
- .attr('fill', 'white')
799
- .attr('stroke', 'black')
800
- .attr('stroke-width', 2 * reach)
801
- .attr('stroke-linejoin', 'round');
802
- }
803
- // Neighbour land masks as land too — clipped to its box so it can't darken
804
- // an adjacent inset — keeping the AK/Canada land border free of rings.
805
- layout.insets.forEach((box, bi) => {
806
- if (box.contextLand)
807
- mask
808
- .append('path')
809
- .attr('d', box.contextLand.d)
810
- .attr('fill', 'black')
811
- .attr('clip-path', `url(#${nid(`dgmo-map-inset-clip-${bi}`)})`);
812
- });
813
- for (const r of layout.insetRegions)
814
- if (r.id !== 'lake')
815
- mask.append('path').attr('d', r.d).attr('fill', 'black');
816
- for (const r of layout.insetRegions)
817
- if (r.id === 'lake')
818
- mask.append('path').attr('d', r.d).attr('fill', 'white');
819
- // Clip the water-line strokes to the inset box quads — the mask controls
820
- // which side reads as water, but SVG strokes still extend stroke-width/2
821
- // past their path, so without this the seaward rings bleed over the box
822
- // border. Union of all inset quads = one clipPath shared by the group.
823
- const clipId = nid('dgmo-map-inset-water-clip');
824
- const clip = defs.append('clipPath').attr('id', clipId);
825
- for (const box of layout.insets) {
826
- const d =
827
- box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
828
- 'Z';
829
- clip.append('path').attr('d', d);
830
- }
831
- // Nest clip (outer) + mask (inner) rather than both on one element —
832
- // WebKit (Tauri) renders the combined attributes inconsistently; the
833
- // nested form is honored by every engine.
834
- const gInsetWater = insetG
835
- .append('g')
836
- .attr('clip-path', `url(#${clipId})`)
837
- .append('g')
838
- .attr('class', 'dgmo-map-inset-water-lines')
839
- .attr('fill', 'none')
840
- .style('pointer-events', 'none') // decorative — pass hover to inset regions
841
- .attr('mask', `url(#${maskId})`);
842
- appendWaterLines(
843
- gInsetWater,
844
- defs,
845
- nid('dgmo-map-inset-coast'),
846
- coastlineOuterRings(layout.insetRegions, cs.minExtent),
847
- cs,
848
- layout.background
849
- );
850
- // Restore the seaward half of the inset coast strokes (see main pass).
851
- for (const r of layout.insetRegions)
852
- gInsetWater
853
- .append('path')
854
- .attr('d', r.d)
855
- .attr('stroke', r.stroke)
856
- .attr('stroke-width', 0.5)
857
- .attr('stroke-linejoin', 'round');
858
- }
859
-
860
- // Re-stroke each inset frame ON TOP (stroke-only, no fill) as the final
861
- // inset layer — the early border path sits under the contextLand fill,
862
- // region fills, and water-lines, so its inner half gets covered near the
863
- // top/right edges and the frame reads as thin/fuzzy. This overlay keeps a
864
- // crisp full-width border on every side.
865
- for (const box of layout.insets) {
866
- const d =
867
- box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
868
- 'Z';
869
- insetG
870
- .append('path')
871
- .attr('d', d)
872
- .attr('fill', 'none')
873
- .attr('stroke', mix(palette.text, palette.bg, 55))
874
- .attr('stroke-width', 1)
875
- .attr('stroke-linejoin', 'round')
876
- .style('pointer-events', 'none');
877
- }
878
- }
879
-
880
- // Code↔diagram sync: tag a synced element with its 1-based source line and,
881
- // in preview, make it jump the editor cursor on click. Source-derived elements
882
- // (regions, POIs, legs, labels) all carry `lineNumber`; generated context
883
- // labels use 0 as a "no source line" sentinel, so guard on `>= 1`.
884
- const wireSync = <E extends Element>(
885
- sel: d3Selection.Selection<E, unknown, null, undefined>,
886
- lineNumber: number
887
- ): void => {
888
- if (lineNumber < 1) return;
889
- sel.attr('data-line-number', lineNumber);
890
- if (onClickItem)
891
- sel.style('cursor', 'pointer').on('click', () => onClickItem(lineNumber));
892
- };
893
-
894
- // ── Legs (edges + route legs) ──
895
- const gLegs = svg
896
- .append('g')
897
- .attr('class', 'dgmo-map-legs')
898
- .attr('fill', 'none');
899
- // Parse a single-segment leg path (`M…L…` or `M…Q…`) into its end point and the
900
- // unit tangent arriving there. Returns null for anything unexpected (then the
901
- // leg renders without the trimmed-arrow treatment).
902
- const legEnd = (
903
- d: string
904
- ): { ex: number; ey: number; tx: number; ty: number } | null => {
905
- const m =
906
- /^M(-?[\d.]+),(-?[\d.]+)(?:L(-?[\d.]+),(-?[\d.]+)|Q(-?[\d.]+),(-?[\d.]+) (-?[\d.]+),(-?[\d.]+))$/.exec(
907
- d
908
- );
909
- if (!m) return null;
910
- const x0 = +m[1]!;
911
- const y0 = +m[2]!;
912
- let ex: number;
913
- let ey: number;
914
- let fromX: number;
915
- let fromY: number;
916
- if (m[3] !== undefined) {
917
- ex = +m[3]!;
918
- ey = +m[4]!;
919
- fromX = x0;
920
- fromY = y0;
921
- } else {
922
- ex = +m[7]!;
923
- ey = +m[8]!;
924
- fromX = +m[5]!; // quadratic control → tangent at the end
925
- fromY = +m[6]!;
926
- }
927
- const dx = ex - fromX;
928
- const dy = ey - fromY;
929
- const len = Math.hypot(dx, dy);
930
- if (!(len > 0)) return null;
931
- return { ex, ey, tx: dx / len, ty: dy / len };
932
- };
933
- layout.legs.forEach((leg) => {
934
- // A directed leg's stroke is TRIMMED back to the arrowhead base so a heavy
935
- // weighted line doesn't run through (and bulge past) the narrowing arrowhead.
936
- // The arrowhead itself is drawn as a filled triangle at the true endpoint,
937
- // sized to always be a touch wider than the stroke so the stroke end is fully
938
- // covered. Undirected (or unparseable) legs keep the full path.
939
- const end = leg.arrow ? legEnd(leg.d) : null;
940
- let drawD = leg.d;
941
- let arrowTri: string | null = null;
942
- if (end) {
943
- const aLen = arrowSize(leg.width); // arrow depth along the line
944
- const aHalf = Math.max(aLen * 0.5, leg.width * 0.6 + 1); // half base ≥ stroke
945
- const bx = end.ex - end.tx * aLen; // base centre
946
- const by = end.ey - end.ty * aLen;
947
- const px = -end.ty; // perpendicular
948
- const py = end.tx;
949
- arrowTri = `M${bx + px * aHalf},${by + py * aHalf}L${end.ex},${end.ey}L${bx - px * aHalf},${by - py * aHalf}z`;
950
- // Re-point the stroke's end at the arrow base (small along-tangent shift; for
951
- // an arc leg the negligible end-curvature change is hidden by the arrowhead).
952
- drawD = leg.d.replace(
953
- /(-?[\d.]+),(-?[\d.]+)$/,
954
- `${bx.toFixed(2)},${by.toFixed(2)}`
955
- );
956
- }
957
- const p = gLegs
958
- .append('path')
959
- .attr('d', drawD)
960
- .attr('stroke', leg.color)
961
- .attr('stroke-width', leg.width)
962
- .attr('stroke-linecap', 'round')
963
- // Endpoint POI ids so a focused leg can co-highlight its two POIs (§17 sync).
964
- .attr('data-from-id', leg.fromId)
965
- .attr('data-to-id', leg.toId);
966
- // Tag the line per tag value (lowercased, matching the legend-entry attrs) so
967
- // a legend hover spotlights only the matching lines (§24B.6) — mirrors POIs.
968
- if (leg.tags) {
969
- for (const [group, value] of Object.entries(leg.tags)) {
970
- p.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
971
- }
972
- }
973
- // A 0-width invisible leg path is hard to hit; pointer-events on the visible
974
- // stroke is enough for the line widths legs use.
975
- wireSync(p, leg.lineNumber);
976
- // Surface the leg's weight on hover. A native <title> gives a tooltip on the
977
- // visible stroke in every surface (app, exported SVG, Obsidian, web) with no
978
- // viewer code; `data-value` is also emitted so an app overlay can style it
979
- // later. The label (when any) prefixes the number for context ("fiber: 28").
980
- if (leg.value !== undefined) {
981
- p.attr('data-value', leg.value);
982
- p.append('title').text(
983
- leg.label !== undefined ? `${leg.label}: ${leg.value}` : `${leg.value}`
984
- );
985
- }
986
- if (arrowTri) {
987
- // Filled arrowhead at the true endpoint — a flat triangle whose base sits
988
- // where the trimmed stroke ends, so the line meets it cleanly with no
989
- // show-through. Round join keeps the tip from looking spiky on thin legs.
990
- const a = gLegs
991
- .append('path')
992
- .attr('d', arrowTri)
993
- .attr('fill', leg.color)
994
- .attr('stroke', leg.color)
995
- .attr('stroke-width', 1)
996
- .attr('stroke-linejoin', 'round')
997
- .attr('data-from-id', leg.fromId)
998
- .attr('data-to-id', leg.toId);
999
- // Mirror the leg's tag attrs onto the arrowhead so a legend hover dims/keeps
1000
- // the arrow in lockstep with its line (§24B.6) — else the arrow stays dimmed
1001
- // while its matching line stays bright.
1002
- if (leg.tags) {
1003
- for (const [group, value] of Object.entries(leg.tags)) {
1004
- a.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
1005
- }
1006
- }
1007
- }
1008
- if (leg.label !== undefined && leg.labelX !== undefined) {
1009
- // Text shade is contrast-picked in layout against the fill under the label
1010
- // (dark scored country ⇒ light text, pale land ⇒ dark), with the ghost halo
1011
- // only when that contrast is marginal. Fall back to the muted default for
1012
- // legs that predate the computed style.
1013
- const lt = emitText(
1014
- gLegs,
1015
- leg.labelX,
1016
- leg.labelY ?? 0,
1017
- leg.label,
1018
- 'middle',
1019
- leg.labelColor ?? palette.textMuted,
1020
- leg.labelHaloColor ?? haloColor,
1021
- leg.labelHalo ?? true,
1022
- LABEL_FONT - 1
1023
- );
1024
- wireSync(lt, leg.lineNumber);
1025
- }
1026
- });
1027
-
1028
- // ── Coincident-stack spider legs + hub (under the dots) ──
1029
- // Legs + hub are DECORATIVE (`data-cluster-deco`, pointer-events none) — the app
1030
- // toggles only their opacity. Drawn VISIBLE so export + the no-JS default show
1031
- // the expanded fan. An invisible hit-area circle (interactive only) owns all
1032
- // pointer interaction so hover/click drives the spiderfy controller robustly.
1033
- // `no-cluster-pois` (or any export) keeps the fan permanently expanded: draw
1034
- // the legs/hub/members visible but emit NO hit-area + NO badge, so there is
1035
- // nothing for the app's spiderfy controller to collapse — the map reads the
1036
- // same on screen as on paper.
1037
- const gSpider = svg.append('g').attr('class', 'dgmo-map-spider');
1038
- for (const cl of layout.clusters) {
1039
- // Pointer hit-area — bottom of the stack so member dots still take their own
1040
- // clicks (line-jump); clicks on the empty centre fall through to here.
1041
- if (clusterUi) {
1042
- gSpider
1043
- .append('circle')
1044
- .attr('cx', cl.cx)
1045
- .attr('cy', cl.cy)
1046
- .attr('r', cl.hitR)
1047
- .attr('fill', 'transparent')
1048
- .attr('data-cluster-hit', cl.id)
1049
- .style('cursor', 'pointer');
1050
- }
1051
- for (const leg of cl.legs) {
1052
- gSpider
1053
- .append('line')
1054
- .attr('x1', cl.cx)
1055
- .attr('y1', cl.cy)
1056
- .attr('x2', leg.x2)
1057
- .attr('y2', leg.y2)
1058
- .attr('stroke', leg.color)
1059
- .attr('stroke-width', 1)
1060
- .attr('data-cluster-deco', cl.id)
1061
- .style('pointer-events', 'none');
1062
- }
1063
- // Faint neutral hub anchoring the legs (RQ3).
1064
- gSpider
1065
- .append('circle')
1066
- .attr('cx', cl.cx)
1067
- .attr('cy', cl.cy)
1068
- .attr('r', 2)
1069
- .attr('fill', mix(palette.textMuted, palette.bg, 40))
1070
- .attr('data-cluster-deco', cl.id)
1071
- .style('pointer-events', 'none');
1072
- }
1073
-
1074
- // ── POIs ──
1075
- const gPois = svg.append('g').attr('class', 'dgmo-map-pois');
1076
- for (const poi of layout.pois) {
1077
- if (poi.isOrigin) {
1078
- gPois
1079
- .append('circle')
1080
- .attr('cx', poi.cx)
1081
- .attr('cy', poi.cy)
1082
- .attr('r', poi.r + 3)
1083
- .attr('fill', 'none')
1084
- .attr('stroke', poi.stroke)
1085
- .attr('stroke-width', 1.5);
1086
- }
1087
- const c = gPois
1088
- .append('circle')
1089
- .attr('cx', poi.cx)
1090
- .attr('cy', poi.cy)
1091
- .attr('r', poi.r)
1092
- .attr('fill', poi.fill)
1093
- .attr('fill-opacity', poi.fillOpacity)
1094
- .attr('stroke', poi.stroke)
1095
- .attr('stroke-width', 1)
1096
- .attr('data-line-number', poi.lineNumber)
1097
- .attr('data-poi', poi.id);
1098
- // Coincident-stack member: tag so the app hides it when collapsing the stack.
1099
- if (poi.clusterId !== undefined)
1100
- c.attr('data-cluster-member', poi.clusterId);
1101
- // Tag the marker per tag value (lowercased, matching the lowercased
1102
- // legend-entry attributes) so the app can spotlight it on legend hover.
1103
- if (poi.tags) {
1104
- for (const [group, value] of Object.entries(poi.tags)) {
1105
- c.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
1106
- }
1107
- }
1108
- if (onClickItem) {
1109
- c.style('cursor', 'pointer').on('click', () =>
1110
- onClickItem(poi.lineNumber)
1111
- );
1112
- }
1113
- if (poi.routeNumber !== undefined) {
1114
- emitText(
1115
- gPois,
1116
- poi.cx,
1117
- poi.cy + 3,
1118
- String(poi.routeNumber),
1119
- 'middle',
1120
- palette.bg,
1121
- poi.fill,
1122
- false,
1123
- LABEL_FONT - 2
1124
- );
1125
- }
1126
- }
1127
-
1128
- // ── Labels (leaders + halo text) ──
1129
- const gLabels = svg.append('g').attr('class', 'dgmo-map-labels');
1130
- for (const lab of layout.labels) {
1131
- // Hover-only labels: OMIT entirely from static export (export = the
1132
- // hover-less default view); in preview emit invisible + flagged so the app
1133
- // can reveal them on hover. They carry no leader, so the leader block below
1134
- // is skipped naturally.
1135
- if (lab.hidden) {
1136
- if (exportDims) continue;
1137
- emitText(
1138
- gLabels,
1139
- lab.x,
1140
- lab.y,
1141
- lab.text,
1142
- lab.anchor,
1143
- lab.color,
1144
- lab.haloColor,
1145
- lab.halo,
1146
- lab.fontSize ?? LABEL_FONT,
1147
- lab.italic,
1148
- lab.letterSpacing
1149
- )
1150
- .attr('data-poi', lab.poiId ?? null)
1151
- .attr('data-poi-hidden', '')
1152
- .style('opacity', 0)
1153
- .style('pointer-events', 'none');
1154
- continue;
1155
- }
1156
- if (lab.leader) {
1157
- const line = gLabels
1158
- .append('line')
1159
- .attr('x1', lab.leader.x1)
1160
- .attr('y1', lab.leader.y1)
1161
- .attr('x2', lab.leader.x2)
1162
- .attr('y2', lab.leader.y2)
1163
- // Tie the leader to its dot by colour; neutral grey when it has none.
1164
- .attr(
1165
- 'stroke',
1166
- lab.leaderColor ?? mix(palette.textMuted, palette.bg, 60)
1167
- )
1168
- .attr('stroke-width', lab.leaderColor ? 1 : 0.75);
1169
- if (lab.poiId !== undefined) line.attr('data-poi', lab.poiId);
1170
- // Spiderfy member leader: toggle it with the badge (same as its text).
1171
- if (lab.clusterMember !== undefined)
1172
- line.attr('data-cluster-member', lab.clusterMember);
1173
- wireSync(line, lab.lineNumber);
1174
- }
1175
- // Region callout anchor dot: a small disc at the (too-small) region's true
1176
- // centroid, in the region's fill, so the leader visibly originates on the map.
1177
- if (lab.calloutDot) {
1178
- gLabels
1179
- .append('circle')
1180
- .attr('cx', lab.calloutDot.x)
1181
- .attr('cy', lab.calloutDot.y)
1182
- .attr('r', 2.5)
1183
- .attr('fill', lab.calloutDot.color)
1184
- .attr('stroke', mix(palette.text, palette.bg, 55))
1185
- .attr('stroke-width', 0.75);
1186
- }
1187
- const t = emitText(
1188
- gLabels,
1189
- lab.x,
1190
- lab.y,
1191
- lab.text,
1192
- lab.anchor,
1193
- lab.color,
1194
- lab.haloColor,
1195
- lab.halo,
1196
- lab.fontSize ?? LABEL_FONT,
1197
- lab.italic,
1198
- lab.letterSpacing,
1199
- lab.lines,
1200
- lab.valueLine
1201
- );
1202
- // POI labels are spotlightable: tag with the POI id and make the text the
1203
- // hover target (the app dims the other dots/labels on enter).
1204
- if (lab.poiId !== undefined) {
1205
- t.attr('data-poi', lab.poiId).style('cursor', 'default');
1206
- }
1207
- // Coincident-stack member label: hidden by the app when its stack collapses.
1208
- if (lab.clusterMember !== undefined) {
1209
- t.attr('data-cluster-member', lab.clusterMember);
1210
- }
1211
- // Click a region/POI label to jump to its source line (sets cursor:pointer,
1212
- // overriding the default above for POI labels).
1213
- wireSync(t, lab.lineNumber);
1214
- }
1215
-
1216
- // ── Coincident-stack badges (collapsed view). Interactive ONLY — a static
1217
- // export keeps the expanded fan (every label visible), so no badge there. A
1218
- // neutral dot ringed with the bare member count, emitted hidden; the app shows
1219
- // it at rest and hides it (revealing the spider) on click. ──
1220
- if (clusterUi && layout.clusters.length) {
1221
- const gBadge = svg.append('g').attr('class', 'dgmo-map-cluster-badges');
1222
- for (const cl of layout.clusters) {
1223
- // Decorative: the hit-area (drawn under the dots) owns hover + click; the
1224
- // badge just shows the count, so it never intercepts pointer events.
1225
- const g = gBadge
1226
- .append('g')
1227
- .attr('data-cluster', cl.id)
1228
- .style('opacity', 0)
1229
- .style('pointer-events', 'none');
1230
- const R = 9;
1231
- // Inner neutral disc + outer ring → reads as "more than one here" (RQ2).
1232
- g.append('circle')
1233
- .attr('cx', cl.cx)
1234
- .attr('cy', cl.cy)
1235
- .attr('r', R)
1236
- .attr('fill', mix(palette.textMuted, palette.bg, 35))
1237
- .attr('stroke', palette.textMuted)
1238
- .attr('stroke-width', 1);
1239
- g.append('circle')
1240
- .attr('cx', cl.cx)
1241
- .attr('cy', cl.cy)
1242
- .attr('r', R + 2.5)
1243
- .attr('fill', 'none')
1244
- .attr('stroke', palette.textMuted)
1245
- .attr('stroke-width', 1);
1246
- // Directional colour beads: one small dot threaded on the outer ring per
1247
- // member, placed at the ANGLE of that member's spider leg (centroid → its
1248
- // expanded position) and filled with the member's own marker colour. So the
1249
- // collapsed badge previews WHAT is stacked here and roughly WHERE each item
1250
- // will fan out, before the spider opens. A bg-coloured halo keeps adjacent
1251
- // beads + the ring line legible.
1252
- const beadR = R + 2.5;
1253
- for (const leg of cl.legs) {
1254
- const a = Math.atan2(leg.y2 - cl.cy, leg.x2 - cl.cx);
1255
- g.append('circle')
1256
- .attr('class', 'dgmo-map-cluster-bead')
1257
- .attr('cx', cl.cx + beadR * Math.cos(a))
1258
- .attr('cy', cl.cy + beadR * Math.sin(a))
1259
- .attr('r', 1.8)
1260
- .attr('fill', leg.color)
1261
- .attr('stroke', palette.bg)
1262
- .attr('stroke-width', 0.5);
1263
- }
1264
- // Bare count (RQ1).
1265
- emitText(
1266
- g,
1267
- cl.cx,
1268
- cl.cy + 3,
1269
- String(cl.count),
1270
- 'middle',
1271
- palette.text,
1272
- palette.bg,
1273
- false,
1274
- LABEL_FONT
1275
- );
1276
- }
1277
- }
1278
-
1279
- // ── Legend (categorical via renderLegendD3 + ramp/size/weight blocks; AR1) ──
1280
- if (layout.legend) {
1281
- const legendY =
1282
- (layout.title ? TITLE_Y + TITLE_FONT_SIZE : 0) +
1283
- (layout.subtitle ? TITLE_FONT_SIZE : 0) +
1284
- 8;
1285
- const legendG = svg
1286
- .append('g')
1287
- .attr('class', 'dgmo-map-legend')
1288
- .attr('transform', `translate(0, ${legendY})`);
1289
- // The value ramp is a selectable colouring group alongside the tag groups
1290
- // (the user flips between them); its capsule renders the gradient inline.
1291
- // Built from the shared helper so the drawn legend matches the band the
1292
- // layout reserved for it (see legend-band.ts).
1293
- const groups = mapLegendGroups(layout.legend);
1294
- if (groups.length > 0) {
1295
- const config: LegendConfig = mapLegendConfig(
1296
- groups,
1297
- exportDims ? 'export' : 'preview'
1298
- );
1299
- renderIntegratedLegend(legendG, {
1300
- ...config,
1301
- palette,
1302
- isDark,
1303
- width,
1304
- activeGroup: layout.legend.activeGroup,
1305
- });
1306
- }
1307
- }
1308
-
1309
- // ── Title / subtitle / caption (foreground — drawn last so they sit above the
1310
- // basemap, POIs, and labels; layout reserves top padding so POIs clear them) ──
1311
- // Soft bg halo so the banner stays legible over busy land/water (the muted
1312
- // subtitle/caption otherwise wash out on mid-toned palettes like nord).
1313
- if (layout.title) {
1314
- svg
1315
- .append('text')
1316
- .attr('class', 'dgmo-map-title')
1317
- .attr('x', width / 2)
1318
- .attr('y', TITLE_Y)
1319
- .attr('text-anchor', 'middle')
1320
- .attr('font-size', TITLE_FONT_SIZE)
1321
- .attr('font-weight', TITLE_FONT_WEIGHT)
1322
- .attr('fill', palette.text)
1323
- .attr('paint-order', 'stroke fill')
1324
- .attr('stroke', palette.bg)
1325
- .attr('stroke-width', 4)
1326
- .attr('stroke-linejoin', 'round')
1327
- .attr('stroke-opacity', 0.7)
1328
- .text(layout.title);
1329
- }
1330
- if (layout.subtitle) {
1331
- svg
1332
- .append('text')
1333
- .attr('class', 'dgmo-map-subtitle')
1334
- .attr('x', width / 2)
1335
- .attr('y', TITLE_Y + TITLE_FONT_SIZE)
1336
- .attr('text-anchor', 'middle')
1337
- .attr('font-size', LABEL_FONT + 1)
1338
- .attr('fill', palette.textMuted)
1339
- .attr('paint-order', 'stroke fill')
1340
- .attr('stroke', palette.bg)
1341
- .attr('stroke-width', 3)
1342
- .attr('stroke-linejoin', 'round')
1343
- .attr('stroke-opacity', 0.7)
1344
- .text(layout.subtitle);
1345
- }
1346
- if (layout.caption) {
1347
- svg
1348
- .append('text')
1349
- .attr('x', width / 2)
1350
- .attr('y', height - 8)
1351
- .attr('text-anchor', 'middle')
1352
- .attr('font-size', LABEL_FONT)
1353
- .attr('fill', palette.textMuted)
1354
- .attr('paint-order', 'stroke fill')
1355
- .attr('stroke', palette.bg)
1356
- .attr('stroke-width', 3)
1357
- .attr('stroke-linejoin', 'round')
1358
- .attr('stroke-opacity', 0.7)
1359
- .text(layout.caption);
1360
- }
1361
- }
1362
-
1363
- /** Export wrapper (no click handler) — matches the structured-renderer contract. */
1364
- export function renderMapForExport(
1365
- container: HTMLDivElement,
1366
- resolved: ResolvedMap,
1367
- data: MapData,
1368
- palette: PaletteColors,
1369
- isDark: boolean,
1370
- exportDims?: D3ExportDimensions
1371
- ): void {
1372
- renderMap(container, resolved, data, palette, isDark, undefined, exportDims);
1373
- }
1374
-
1375
- type Sel = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
1376
-
1377
- function emitText(
1378
- g: Sel,
1379
- x: number,
1380
- y: number,
1381
- text: string,
1382
- anchor: PlacedLabel['anchor'],
1383
- color: string,
1384
- halo: string,
1385
- withHalo: boolean,
1386
- fontSize: number,
1387
- italic?: boolean,
1388
- letterSpacing?: number,
1389
- lines?: readonly string[],
1390
- valueLine?: string
1391
- ): d3Selection.Selection<SVGTextElement, unknown, null, undefined> {
1392
- const t = g
1393
- .append('text')
1394
- .attr('x', x)
1395
- .attr('y', y)
1396
- .attr('text-anchor', anchor)
1397
- .attr('font-size', fontSize)
1398
- .attr('fill', color);
1399
- // Multi-line context labels (water names): stack centred tspans around `y` so
1400
- // the block's vertical centre matches the placement rect. Single-line keeps the
1401
- // bare `.text()` form so every other call site stays byte-identical.
1402
- if (lines && lines.length > 1) {
1403
- const lineHeight = fontSize + 2; // MUST match context-labels LINE_HEIGHT
1404
- const startDy = -((lines.length - 1) / 2) * lineHeight;
1405
- lines.forEach((ln, i) => {
1406
- t.append('tspan')
1407
- .attr('x', x)
1408
- .attr('dy', i === 0 ? startDy : lineHeight)
1409
- .text(ln);
1410
- });
1411
- } else if (valueLine !== undefined) {
1412
- // A choropleth region's name over its metric value: name on top, value below
1413
- // in a smaller, dimmer line (same fill, reduced opacity). Raise the block so
1414
- // its centre sits on the placement `y`. The halo (set below) wraps both lines.
1415
- const valueSize = Math.round(fontSize * 0.82);
1416
- t.append('tspan')
1417
- .attr('x', x)
1418
- .attr('dy', -fontSize * 0.28)
1419
- .text(text);
1420
- t.append('tspan')
1421
- .attr('x', x)
1422
- .attr('dy', fontSize * 0.92)
1423
- .attr('font-size', valueSize)
1424
- .attr('fill-opacity', 0.72)
1425
- .text(valueLine);
1426
- } else {
1427
- t.text(text);
1428
- }
1429
- // Cartographic styling for context labels; absent on every other call site so
1430
- // existing output stays byte-identical (only emitted when explicitly set).
1431
- if (italic) t.attr('font-style', 'italic');
1432
- if (letterSpacing) t.attr('letter-spacing', letterSpacing);
1433
- if (withHalo) {
1434
- // Thin, even outline (2px / 1px-per-side at the 11px label font — 3px read
1435
- // top-heavy as adjacent glyph tops merged their strokes). Round join + cap
1436
- // keep the edge uniform around every glyph.
1437
- t.attr('paint-order', 'stroke fill')
1438
- .attr('stroke', halo)
1439
- .attr('stroke-width', 2)
1440
- .attr('stroke-linejoin', 'round')
1441
- .attr('stroke-linecap', 'round')
1442
- .attr('stroke-opacity', 0.55);
1443
- }
1444
- return t;
1445
- }