@diagrammo/dgmo 0.21.1 → 0.23.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 (87) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2230 -503
  3. package/dist/advanced.d.cts +5731 -0
  4. package/dist/advanced.d.ts +5731 -0
  5. package/dist/advanced.js +2226 -503
  6. package/dist/auto.cjs +2272 -479
  7. package/dist/auto.d.cts +39 -0
  8. package/dist/auto.d.ts +39 -0
  9. package/dist/auto.js +124 -124
  10. package/dist/auto.mjs +2274 -480
  11. package/dist/cli.cjs +170 -170
  12. package/dist/editor.cjs +16 -16
  13. package/dist/editor.js +16 -16
  14. package/dist/highlight.cjs +18 -13
  15. package/dist/highlight.js +18 -13
  16. package/dist/index.cjs +2253 -465
  17. package/dist/index.d.cts +339 -0
  18. package/dist/index.d.ts +339 -0
  19. package/dist/index.js +2255 -466
  20. package/dist/internal.cjs +2230 -503
  21. package/dist/internal.d.cts +5731 -0
  22. package/dist/internal.d.ts +5731 -0
  23. package/dist/internal.js +2226 -503
  24. package/dist/map-data/PROVENANCE.json +1 -1
  25. package/dist/map-data/gazetteer.json +1 -1
  26. package/dist/map-data/mountain-ranges.json +1 -1
  27. package/dist/map-data/water-bodies.json +1 -0
  28. package/dist/map-data/world-coarse.json +1 -1
  29. package/dist/map-data/world-detail.json +1 -1
  30. package/docs/language-reference.md +55 -9
  31. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  32. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  33. package/gallery/fixtures/map-categorical.dgmo +0 -1
  34. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  35. package/gallery/fixtures/map-coastline.dgmo +7 -0
  36. package/gallery/fixtures/map-colorize.dgmo +11 -0
  37. package/gallery/fixtures/map-direct-color.dgmo +0 -1
  38. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  39. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  40. package/gallery/fixtures/map-route.dgmo +0 -1
  41. package/package.json +1 -1
  42. package/src/advanced.ts +12 -1
  43. package/src/boxes-and-lines/parser.ts +39 -0
  44. package/src/boxes-and-lines/renderer.ts +205 -20
  45. package/src/boxes-and-lines/types.ts +9 -0
  46. package/src/cli.ts +1 -1
  47. package/src/completion.ts +36 -30
  48. package/src/cycle/renderer.ts +14 -1
  49. package/src/d3.ts +20 -6
  50. package/src/editor/highlight-api.ts +4 -0
  51. package/src/editor/keywords.ts +16 -16
  52. package/src/infra/renderer.ts +35 -7
  53. package/src/map/colorize.ts +54 -0
  54. package/src/map/context-labels.ts +429 -0
  55. package/src/map/data/PROVENANCE.json +1 -1
  56. package/src/map/data/README.md +6 -0
  57. package/src/map/data/gazetteer.json +1 -1
  58. package/src/map/data/mountain-ranges.json +1 -1
  59. package/src/map/data/types.ts +34 -0
  60. package/src/map/data/water-bodies.json +1 -0
  61. package/src/map/data/world-coarse.json +1 -1
  62. package/src/map/data/world-detail.json +1 -1
  63. package/src/map/dimensions.ts +117 -0
  64. package/src/map/geo-query.ts +21 -3
  65. package/src/map/geo.ts +47 -1
  66. package/src/map/layout.ts +1408 -266
  67. package/src/map/load-data.ts +10 -2
  68. package/src/map/parser.ts +42 -116
  69. package/src/map/renderer.ts +604 -14
  70. package/src/map/resolved-types.ts +16 -2
  71. package/src/map/resolver.ts +208 -59
  72. package/src/map/types.ts +30 -32
  73. package/src/mindmap/renderer.ts +10 -1
  74. package/src/palettes/atlas.ts +77 -0
  75. package/src/palettes/blueprint.ts +73 -0
  76. package/src/palettes/color-utils.ts +58 -1
  77. package/src/palettes/index.ts +12 -3
  78. package/src/palettes/slate.ts +73 -0
  79. package/src/palettes/tidewater.ts +73 -0
  80. package/src/render.ts +8 -1
  81. package/src/tech-radar/renderer.ts +3 -0
  82. package/src/tech-radar/types.ts +3 -0
  83. package/src/utils/d3-types.ts +5 -0
  84. package/src/utils/legend-layout.ts +21 -4
  85. package/src/utils/legend-types.ts +7 -0
  86. package/src/utils/reserved-key-registry.ts +8 -3
  87. package/src/palettes/bold.ts +0 -67
@@ -17,10 +17,196 @@ import type { LegendConfig, LegendState } from '../utils/legend-types';
17
17
  import type { PaletteColors } from '../palettes/types';
18
18
  import type { D3ExportDimensions } from '../utils/d3-types';
19
19
  import type { MapData, ResolvedMap } from './resolved-types';
20
- import { layoutMap, type MapLayoutRegion, type PlacedLabel } from './layout';
20
+ import {
21
+ layoutMap,
22
+ parsePathRings,
23
+ type MapLayoutRegion,
24
+ type MapLayoutCoastlineStyle,
25
+ type PlacedLabel,
26
+ } from './layout';
21
27
 
22
28
  const LABEL_FONT = 11;
23
29
 
30
+ // ── Coastline water-lines helpers (opt-in `coastline`, §24B.2) ──
31
+ // Geometry is derived from the already-drawn region paths: each outer ring is
32
+ // buffered as a symmetric SVG stroke band then eroded (flat-water overdraw) to a
33
+ // thin offshore ring; a luminance <mask> reveals only the water side. See the
34
+ // render block + ADR-1/6 in the tech-spec.
35
+
36
+ /** Even-odd point-in-ring test (screen space). */
37
+ function pointInRing(
38
+ px: number,
39
+ py: number,
40
+ ring: ReadonlyArray<[number, number]>
41
+ ): boolean {
42
+ let inside = false;
43
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
44
+ const [xi, yi] = ring[i]!;
45
+ const [xj, yj] = ring[j]!;
46
+ if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi)
47
+ inside = !inside;
48
+ }
49
+ return inside;
50
+ }
51
+
52
+ /** Build an SVG subpath `d` (`M…L…Z`) from a ring's points. */
53
+ function ringToPath(ring: ReadonlyArray<[number, number]>): string {
54
+ let d = '';
55
+ for (let i = 0; i < ring.length; i++)
56
+ d += (i ? 'L' : 'M') + ring[i]![0] + ',' + ring[i]![1];
57
+ return d + 'Z';
58
+ }
59
+
60
+ /** Open SVG polyline (`M…L…`, no `Z`) from a run of points. */
61
+ function polylineToPath(pts: ReadonlyArray<[number, number]>): string {
62
+ let d = '';
63
+ for (let i = 0; i < pts.length; i++)
64
+ d += (i ? 'L' : 'M') + pts[i]![0] + ',' + pts[i]![1];
65
+ return d;
66
+ }
67
+
68
+ /** Coast subpaths for one ring, dropping any edge that runs ALONG a canvas edge.
69
+ * A region clipped to the viewport (the antimeridian on a world map, or a
70
+ * regional clipExtent cut) gains a synthetic straight edge collinear with the
71
+ * frame — that edge is NOT a real coast and must not be buffered into a coast
72
+ * band (which would ring the cut with water-lines short of the edge). Without a
73
+ * `frame` the ring is returned closed (`M…Z`) as before. With one, the ring is
74
+ * split at every frame-collinear edge into open coast arcs (`M…L…`), so the land
75
+ * runs cleanly to the edge and only true coastline gets a water-line. */
76
+ function ringToCoastPaths(
77
+ ring: ReadonlyArray<[number, number]>,
78
+ frame?: { w: number; h: number }
79
+ ): string[] {
80
+ if (!frame) return [ringToPath(ring)];
81
+ const n = ring.length;
82
+ const eps = 0.75;
83
+ const onL = (x: number): boolean => Math.abs(x) <= eps;
84
+ const onR = (x: number): boolean => Math.abs(x - frame.w) <= eps;
85
+ const onT = (y: number): boolean => Math.abs(y) <= eps;
86
+ const onB = (y: number): boolean => Math.abs(y - frame.h) <= eps;
87
+ const isFrameEdge = (
88
+ a: readonly [number, number],
89
+ b: readonly [number, number]
90
+ ): boolean =>
91
+ (onL(a[0]) && onL(b[0])) ||
92
+ (onR(a[0]) && onR(b[0])) ||
93
+ (onT(a[1]) && onT(b[1])) ||
94
+ (onB(a[1]) && onB(b[1]));
95
+ // No frame-collinear edge anywhere → ordinary interior coastline (closed).
96
+ let firstBreak = -1;
97
+ for (let i = 0; i < n; i++)
98
+ if (isFrameEdge(ring[i]!, ring[(i + 1) % n]!)) {
99
+ firstBreak = i;
100
+ break;
101
+ }
102
+ if (firstBreak === -1) return [ringToPath(ring)];
103
+ // Walk the loop from just after the first cut, accumulating runs of real-coast
104
+ // edges into open polylines and breaking at each frame-collinear edge.
105
+ const paths: string[] = [];
106
+ let cur: Array<[number, number]> = [];
107
+ const start = (firstBreak + 1) % n;
108
+ for (let k = 0; k < n; k++) {
109
+ const i = (start + k) % n;
110
+ const a = ring[i]!;
111
+ const b = ring[(i + 1) % n]!;
112
+ if (isFrameEdge(a, b)) {
113
+ if (cur.length >= 2) paths.push(polylineToPath(cur));
114
+ cur = [];
115
+ continue;
116
+ }
117
+ if (cur.length === 0) cur.push(a);
118
+ cur.push(b);
119
+ }
120
+ if (cur.length >= 2) paths.push(polylineToPath(cur));
121
+ return paths;
122
+ }
123
+
124
+ /** Coast outlines to buffer: every region's OUTER rings whose bbox extent clears
125
+ * `minExtent`. Holes/enclaves are skipped via containment depth (even depth =
126
+ * outer landmass boundary, odd = a hole) so an enclave (Lesotho) or a lake-hole
127
+ * is never ringed as a fake coast on land (R11). `minExtent` is a bare
128
+ * degenerate-ring floor now — every island, however small, grows coast rings. */
129
+ function coastlineOuterRings(
130
+ regions: readonly MapLayoutRegion[],
131
+ minExtent: number,
132
+ frame?: { w: number; h: number }
133
+ ): string[] {
134
+ const paths: string[] = [];
135
+ for (const r of regions) {
136
+ const rings = parsePathRings(r.d);
137
+ for (let i = 0; i < rings.length; i++) {
138
+ const ring = rings[i]!;
139
+ if (ring.length < 3) continue;
140
+ let minX = Infinity;
141
+ let minY = Infinity;
142
+ let maxX = -Infinity;
143
+ let maxY = -Infinity;
144
+ for (const [x, y] of ring) {
145
+ if (x < minX) minX = x;
146
+ if (x > maxX) maxX = x;
147
+ if (y < minY) minY = y;
148
+ if (y > maxY) maxY = y;
149
+ }
150
+ if (Math.max(maxX - minX, maxY - minY) < minExtent) continue;
151
+ const [fx, fy] = ring[0]!;
152
+ let depth = 0;
153
+ for (let j = 0; j < rings.length; j++)
154
+ if (j !== i && pointInRing(fx, fy, rings[j]!)) depth++;
155
+ if (depth % 2 === 1) continue; // hole/enclave — skip
156
+ paths.push(...ringToCoastPaths(ring, frame));
157
+ }
158
+ }
159
+ return paths;
160
+ }
161
+
162
+ /** Stroke the coast-parallel water-lines into a masked group. Per line, outer→
163
+ * inner so the inner ring draws on top: a colour pass (the symmetric buffer
164
+ * band) then a flat-water overdraw that erodes it to a thin offshore ring. The
165
+ * group's `<mask>` keeps only the water-side half of each band.
166
+ *
167
+ * The outer→inner ordering protects a single ring (the inner band never reaches
168
+ * the outer ring because `d1+thickness < d2`, the layout invariant). It does NOT
169
+ * protect across regions: where two coasts sit closer than ~2·d1 (a tripoint, a
170
+ * narrow strait, an inset box edge), one region's flat-water overdraw can paint
171
+ * over a neighbour's inner ring — the same accepted "tripoint stub / narrow
172
+ * inlet fills solid" artifact the tech-spec calls out, bounded by small d.
173
+ *
174
+ * Perf: every outer ring in a given (level, pass) shares identical stroke
175
+ * attributes, so they collapse into ONE multi-subpath `<path>` (each ring's
176
+ * `d` already starts with `M`, so joining is a valid compound path). A world
177
+ * map drops from ~6k coastline paths to 2 per ring-level (~10 total), which is
178
+ * ~87% of the whole map's path count — the dominant cost in any repaint. The
179
+ * only visible consequence: the colour-band pass strokes at `stroke-opacity`,
180
+ * and a single path's stroke rasterises as ONE coverage region, so adjacent
181
+ * bands that overlap (straits/tripoints) no longer double-darken — they read
182
+ * uniform, which is if anything cleaner. Draw order (all colour bands, then all
183
+ * flat-water, outer level first) is unchanged, so the single-ring invariant and
184
+ * the accepted cross-region overdraw artifact behave exactly as before. */
185
+ function appendWaterLines(
186
+ g: Sel,
187
+ outerRings: readonly string[],
188
+ style: MapLayoutCoastlineStyle,
189
+ flatWater: string
190
+ ): void {
191
+ const d = outerRings.join(' ');
192
+ const linesOuterFirst = [...style.lines].sort((a, b) => b.d - a.d);
193
+ for (const line of linesOuterFirst) {
194
+ g.append('path')
195
+ .attr('d', d)
196
+ .attr('stroke', style.color)
197
+ .attr('stroke-width', 2 * (line.d + line.thickness))
198
+ .attr('stroke-opacity', line.opacity)
199
+ .attr('stroke-linejoin', 'round')
200
+ .attr('stroke-linecap', 'round');
201
+ g.append('path')
202
+ .attr('d', d)
203
+ .attr('stroke', flatWater)
204
+ .attr('stroke-width', 2 * line.d)
205
+ .attr('stroke-linejoin', 'round')
206
+ .attr('stroke-linecap', 'round');
207
+ }
208
+ }
209
+
24
210
  /** Render a resolved map into `container` (d3-selection appends an `<svg>`). */
25
211
  export function renderMap(
26
212
  container: HTMLDivElement,
@@ -45,6 +231,11 @@ export function renderMap(
45
231
  {
46
232
  palette,
47
233
  isDark,
234
+ // Export-only: forward the contain-fit request from mapExportDimensions so a
235
+ // clamped/floored (off-aspect) export canvas letterboxes instead of
236
+ // stretch-distorting. The in-app preview pane passes no exportDims → unset →
237
+ // keeps the global stretch-fill.
238
+ preferContain: exportDims?.preferContain ?? false,
48
239
  ...(activeGroupOverride !== undefined && {
49
240
  activeGroup: activeGroupOverride,
50
241
  }),
@@ -100,6 +291,19 @@ export function renderMap(
100
291
  .attr('fill', r.fill)
101
292
  .attr('stroke', r.stroke)
102
293
  .attr('stroke-width', strokeWidth);
294
+ // Display name on EVERY region (authored + base/context) so the app can
295
+ // surface it on hover — decorative metadata, no visible text drawn here.
296
+ if (r.label) p.attr('data-region-name', r.label);
297
+ // ISO id on EVERY political region (authored + base/context + inset states),
298
+ // so the Inspect tool can resolve a reverse-geocoded `{iso}` to its drawn path
299
+ // and outline it. Distinct from `data-region` (data-layer-only, legend hover):
300
+ // this lands on base/context land too. Lakes carry no iso (not a place).
301
+ if (r.id && r.id !== 'lake') p.attr('data-iso', r.id);
302
+ // Area-weighted centroid (px) the app anchors the hover label to — robust to
303
+ // antimeridian crossers where a bounding-box centre lands in open ocean.
304
+ if (r.labelX !== undefined && r.labelY !== undefined) {
305
+ p.attr('data-label-x', r.labelX).attr('data-label-y', r.labelY);
306
+ }
103
307
  // Data layer? Tag it so the app can highlight on legend hover / gradient
104
308
  // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
105
309
  // (both lowercased to match the lowercased legend-entry attributes).
@@ -147,6 +351,10 @@ export function renderMap(
147
351
  const gRelief = svg
148
352
  .append('g')
149
353
  .attr('clip-path', `url(#${landClipId})`) // outer: land only
354
+ // Decorative texture — never a pointer target, so region hover (the app's
355
+ // name-on-hover) always reaches the region path beneath. WebKit hit-tests
356
+ // masked/clipped overlays unlike Chromium, so this must be explicit.
357
+ .style('pointer-events', 'none')
150
358
  .append('g')
151
359
  .attr('class', 'dgmo-map-relief')
152
360
  .attr('clip-path', `url(#${rangeClipId})`) // inner: ∩ ranges
@@ -166,12 +374,130 @@ export function renderMap(
166
374
  }
167
375
  }
168
376
 
377
+ // ── Coastline water-lines (faint nautical-chart lines on the WATER side) ──
378
+ // 2 discrete coast-parallel lines hugging the ocean shore + lake shores, fading
379
+ // seaward. Each region's outer ring is buffered as a symmetric SVG stroke band
380
+ // then eroded with a flat-water overdraw to a thin offshore ring; a luminance
381
+ // <mask> (white canvas − black land + white lakes) reveals only the water side,
382
+ // so land/land borders self-remove (their band falls on
383
+ // the neighbour's land, which the mask hides — no topojson.mesh needed). NOT a
384
+ // clipPath: sibling clip paths UNION (can't subtract land). Decorative — no data
385
+ // attrs, plain strokes. Below rivers/POIs/legs/labels, above region/relief fills
386
+ // (so with `relief` on, water-lines sit on water and relief on land — disjoint).
387
+ // §24B.2, ADR-1/3/6.
388
+ if (layout.coastlineStyle) {
389
+ const cs = layout.coastlineStyle;
390
+ const maskId = 'dgmo-map-water-mask';
391
+ const mask = defs
392
+ .append('mask')
393
+ .attr('id', maskId)
394
+ // userSpaceOnUse: the default objectBoundingBox clamps the mask region to
395
+ // the group's own bbox and drops the canvas-edge reveal (round-2 #2).
396
+ .attr('maskUnits', 'userSpaceOnUse')
397
+ .attr('x', 0)
398
+ .attr('y', 0)
399
+ .attr('width', width)
400
+ .attr('height', height);
401
+ mask
402
+ .append('rect')
403
+ .attr('x', 0)
404
+ .attr('y', 0)
405
+ .attr('width', width)
406
+ .attr('height', height)
407
+ .attr('fill', 'white');
408
+ // All land fills are opaque black and all lake fills opaque white, so each
409
+ // group collapses into ONE compound path (land first, lakes over it to
410
+ // re-reveal their interiors). Identical pixels, ~hundreds of paths → 2.
411
+ const landD = layout.regions
412
+ .filter((r) => r.id !== 'lake')
413
+ .map((r) => r.d)
414
+ .join(' ');
415
+ const lakeD = layout.regions
416
+ .filter((r) => r.id === 'lake')
417
+ .map((r) => r.d)
418
+ .join(' ');
419
+ if (landD) mask.append('path').attr('d', landD).attr('fill', 'black');
420
+ if (lakeD) mask.append('path').attr('d', lakeD).attr('fill', 'white');
421
+ // Moat around each AK/HI inset box: the opaque box is drawn in the FOREGROUND
422
+ // over open ocean, so the main-map offshore rings get chopped by its border
423
+ // and butt into it — sloppy. Paint each box quad black (interior, under the
424
+ // box) with a black stroke whose half-width = the band's outer reach, so the
425
+ // main rings also clear a margin OUTSIDE the border. The ring-ends then fall
426
+ // in open water away from the frame instead of crashing into it.
427
+ if (layout.insets.length) {
428
+ const reach = Math.max(0, ...cs.lines.map((l) => l.d + l.thickness));
429
+ for (const box of layout.insets) {
430
+ const d =
431
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
432
+ 'Z';
433
+ mask
434
+ .append('path')
435
+ .attr('d', d)
436
+ .attr('fill', 'black')
437
+ .attr('stroke', 'black')
438
+ .attr('stroke-width', 2 * reach)
439
+ .attr('stroke-linejoin', 'round');
440
+ }
441
+ }
442
+ // NO frame band: a synthetic frame-cut edge (clipExtent/cullFeatureToView
443
+ // trims region `d` to the view rect) has the region INTERIOR — land — on the
444
+ // canvas-interior side, which the mask already paints black, so its band is
445
+ // hidden anyway. A border band here only suppressed REAL coastal rings near
446
+ // the canvas edge, leaving an empty top/bottom strip — so the water-lines now
447
+ // carry through to every edge of the visible map.
448
+ const gWater = svg
449
+ .append('g')
450
+ .attr('class', 'dgmo-map-water-lines')
451
+ .attr('fill', 'none')
452
+ // Decorative nautical lines — never a pointer target. Without this the wide
453
+ // pre-mask coastal ring bands swallow region hover over coastal countries in
454
+ // WebKit (which hit-tests masked content unlike Chromium); e.g. Portugal.
455
+ .style('pointer-events', 'none')
456
+ .attr('mask', `url(#${maskId})`);
457
+ appendWaterLines(
458
+ gWater,
459
+ // Pass the canvas frame so edges collinear with it (the antimeridian on a
460
+ // world map, regional clipExtent cuts) don't get ringed as fake coast —
461
+ // land runs cleanly to the render-area edge.
462
+ coastlineOuterRings(layout.regions, cs.minExtent, {
463
+ w: width,
464
+ h: height,
465
+ }),
466
+ cs,
467
+ layout.background
468
+ );
469
+ // Restore the seaward half of the coast stroke: the rings' flat-water erosion
470
+ // overdraws repaint the water out to d_max, which paints over the water-side
471
+ // half of each region's coast outline and makes coastlines read faded. Re-
472
+ // stroke every region inside the SAME masked group (so it only repaints the
473
+ // water side — the land side was never touched, and interior land/land borders
474
+ // stay hidden), on top of the rings. The strokes sit at the coast (offset 0),
475
+ // well inside d0, so they never cover the offshore rings.
476
+ // Group the restroke by stroke colour (base borders share one colour; the
477
+ // occasional data region may differ) so same-coloured coasts collapse into a
478
+ // single compound path instead of one path per region.
479
+ const byStroke = new Map<string, string[]>();
480
+ for (const r of layout.regions) {
481
+ const arr = byStroke.get(r.stroke);
482
+ if (arr) arr.push(r.d);
483
+ else byStroke.set(r.stroke, [r.d]);
484
+ }
485
+ for (const [stroke, ds] of byStroke)
486
+ gWater
487
+ .append('path')
488
+ .attr('d', ds.join(' '))
489
+ .attr('stroke', stroke)
490
+ .attr('stroke-width', 0.5)
491
+ .attr('stroke-linejoin', 'round');
492
+ }
493
+
169
494
  // ── Rivers (thin water centerlines over the land, under POIs/edges) ──
170
495
  if (layout.rivers.length) {
171
496
  const gRivers = svg
172
497
  .append('g')
173
498
  .attr('class', 'dgmo-map-rivers')
174
- .attr('fill', 'none');
499
+ .attr('fill', 'none')
500
+ .style('pointer-events', 'none'); // decorative — pass hover to regions below
175
501
  for (const r of layout.rivers) {
176
502
  gRivers
177
503
  .append('path')
@@ -188,7 +514,7 @@ export function renderMap(
188
514
  // then draws on top, framed by the box border. ──
189
515
  if (layout.insets.length) {
190
516
  const insetG = svg.append('g').attr('class', 'dgmo-map-insets');
191
- for (const box of layout.insets) {
517
+ layout.insets.forEach((box, bi) => {
192
518
  // Angled-top quad frame — rides under the conus coast so it never covers
193
519
  // neighbouring states. Closed path from the four corners.
194
520
  const d =
@@ -201,10 +527,111 @@ export function renderMap(
201
527
  .attr('stroke', mix(palette.text, palette.bg, 55))
202
528
  .attr('stroke-width', 1)
203
529
  .attr('stroke-linejoin', 'round');
204
- }
530
+ // Neighbour land (Canada beside Alaska) clipped to this box, behind the
531
+ // state — so a land border reads as land rather than sprouting coast rings.
532
+ if (box.contextLand) {
533
+ const clipId = `dgmo-map-inset-clip-${bi}`;
534
+ defs.append('clipPath').attr('id', clipId).append('path').attr('d', d);
535
+ insetG
536
+ .append('path')
537
+ .attr('d', box.contextLand.d)
538
+ .attr('fill', box.contextLand.fill)
539
+ .attr('clip-path', `url(#${clipId})`);
540
+ }
541
+ });
205
542
  for (const r of layout.insetRegions) drawRegion(insetG, r, 0.5);
543
+
544
+ // Inset coastline water-lines (AK/HI box interiors) for visual parity with
545
+ // the main map. Mask = the inset box quads (white reveal) − inset regions
546
+ // (black land / white lake); buffer+erode the inset region outer rings the
547
+ // same way. Inside the inset group so it composites over the box fills.
548
+ if (layout.coastlineStyle) {
549
+ const cs = layout.coastlineStyle;
550
+ const maskId = 'dgmo-map-inset-water-mask';
551
+ const mask = defs
552
+ .append('mask')
553
+ .attr('id', maskId)
554
+ .attr('maskUnits', 'userSpaceOnUse')
555
+ .attr('x', 0)
556
+ .attr('y', 0)
557
+ .attr('width', width)
558
+ .attr('height', height);
559
+ for (const box of layout.insets) {
560
+ const d =
561
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
562
+ 'Z';
563
+ mask.append('path').attr('d', d).attr('fill', 'white');
564
+ }
565
+ // Neighbour land masks as land too — clipped to its box so it can't darken
566
+ // an adjacent inset — keeping the AK/Canada land border free of rings.
567
+ layout.insets.forEach((box, bi) => {
568
+ if (box.contextLand)
569
+ mask
570
+ .append('path')
571
+ .attr('d', box.contextLand.d)
572
+ .attr('fill', 'black')
573
+ .attr('clip-path', `url(#dgmo-map-inset-clip-${bi})`);
574
+ });
575
+ for (const r of layout.insetRegions)
576
+ if (r.id !== 'lake')
577
+ mask.append('path').attr('d', r.d).attr('fill', 'black');
578
+ for (const r of layout.insetRegions)
579
+ if (r.id === 'lake')
580
+ mask.append('path').attr('d', r.d).attr('fill', 'white');
581
+ // Clip the water-line strokes to the inset box quads — the mask controls
582
+ // which side reads as water, but SVG strokes still extend stroke-width/2
583
+ // past their path, so without this the seaward rings bleed over the box
584
+ // border. Union of all inset quads = one clipPath shared by the group.
585
+ const clipId = 'dgmo-map-inset-water-clip';
586
+ const clip = defs.append('clipPath').attr('id', clipId);
587
+ for (const box of layout.insets) {
588
+ const d =
589
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
590
+ 'Z';
591
+ clip.append('path').attr('d', d);
592
+ }
593
+ // Nest clip (outer) + mask (inner) rather than both on one element —
594
+ // WebKit (Tauri) renders the combined attributes inconsistently; the
595
+ // nested form is honored by every engine.
596
+ const gInsetWater = insetG
597
+ .append('g')
598
+ .attr('clip-path', `url(#${clipId})`)
599
+ .append('g')
600
+ .attr('class', 'dgmo-map-inset-water-lines')
601
+ .attr('fill', 'none')
602
+ .style('pointer-events', 'none') // decorative — pass hover to inset regions
603
+ .attr('mask', `url(#${maskId})`);
604
+ appendWaterLines(
605
+ gInsetWater,
606
+ coastlineOuterRings(layout.insetRegions, cs.minExtent),
607
+ cs,
608
+ layout.background
609
+ );
610
+ // Restore the seaward half of the inset coast strokes (see main pass).
611
+ for (const r of layout.insetRegions)
612
+ gInsetWater
613
+ .append('path')
614
+ .attr('d', r.d)
615
+ .attr('stroke', r.stroke)
616
+ .attr('stroke-width', 0.5)
617
+ .attr('stroke-linejoin', 'round');
618
+ }
206
619
  }
207
620
 
621
+ // Code↔diagram sync: tag a synced element with its 1-based source line and,
622
+ // in preview, make it jump the editor cursor on click. Source-derived elements
623
+ // (regions, POIs, legs, labels) all carry `lineNumber`; generated context
624
+ // labels use 0 as a "no source line" sentinel, so guard on `>= 1`.
625
+ const wireSync = <E extends Element>(
626
+ sel: d3Selection.Selection<E, unknown, null, undefined>,
627
+ lineNumber: number
628
+ ): void => {
629
+ if (lineNumber < 1) return;
630
+ sel.attr('data-line-number', lineNumber);
631
+ if (onClickItem)
632
+ sel.style('cursor', 'pointer').on('click', () => onClickItem(lineNumber));
633
+ };
634
+
208
635
  // ── Legs (edges + route legs) ──
209
636
  const gLegs = svg
210
637
  .append('g')
@@ -217,6 +644,9 @@ export function renderMap(
217
644
  .attr('stroke', leg.color)
218
645
  .attr('stroke-width', leg.width)
219
646
  .attr('stroke-linecap', 'round');
647
+ // A 0-width invisible leg path is hard to hit; pointer-events on the visible
648
+ // stroke is enough for the line widths legs use.
649
+ wireSync(p, leg.lineNumber);
220
650
  if (leg.arrow) {
221
651
  const id = `dgmo-map-arrow-${i}`;
222
652
  const s = arrowSize(leg.width);
@@ -236,20 +666,67 @@ export function renderMap(
236
666
  p.attr('marker-end', `url(#${id})`);
237
667
  }
238
668
  if (leg.label !== undefined && leg.labelX !== undefined) {
239
- emitText(
669
+ // Text shade is contrast-picked in layout against the fill under the label
670
+ // (dark scored country ⇒ light text, pale land ⇒ dark), with the ghost halo
671
+ // only when that contrast is marginal. Fall back to the muted default for
672
+ // legs that predate the computed style.
673
+ const lt = emitText(
240
674
  gLegs,
241
675
  leg.labelX,
242
676
  leg.labelY ?? 0,
243
677
  leg.label,
244
678
  'middle',
245
- palette.textMuted,
246
- haloColor,
247
- true,
679
+ leg.labelColor ?? palette.textMuted,
680
+ leg.labelHaloColor ?? haloColor,
681
+ leg.labelHalo ?? true,
248
682
  LABEL_FONT - 1
249
683
  );
684
+ wireSync(lt, leg.lineNumber);
250
685
  }
251
686
  });
252
687
 
688
+ // ── Coincident-stack spider legs + hub (under the dots) ──
689
+ // Legs + hub are DECORATIVE (`data-cluster-deco`, pointer-events none) — the app
690
+ // toggles only their opacity. Drawn VISIBLE so export + the no-JS default show
691
+ // the expanded fan. An invisible hit-area circle (interactive only) owns all
692
+ // pointer interaction so hover/click drives the spiderfy controller robustly.
693
+ const gSpider = svg.append('g').attr('class', 'dgmo-map-spider');
694
+ for (const cl of layout.clusters) {
695
+ // Pointer hit-area — bottom of the stack so member dots still take their own
696
+ // clicks (line-jump); clicks on the empty centre fall through to here.
697
+ if (!exportDims) {
698
+ gSpider
699
+ .append('circle')
700
+ .attr('cx', cl.cx)
701
+ .attr('cy', cl.cy)
702
+ .attr('r', cl.hitR)
703
+ .attr('fill', 'transparent')
704
+ .attr('data-cluster-hit', cl.id)
705
+ .style('cursor', 'pointer');
706
+ }
707
+ for (const leg of cl.legs) {
708
+ gSpider
709
+ .append('line')
710
+ .attr('x1', cl.cx)
711
+ .attr('y1', cl.cy)
712
+ .attr('x2', leg.x2)
713
+ .attr('y2', leg.y2)
714
+ .attr('stroke', leg.color)
715
+ .attr('stroke-width', 1)
716
+ .attr('data-cluster-deco', cl.id)
717
+ .style('pointer-events', 'none');
718
+ }
719
+ // Faint neutral hub anchoring the legs (RQ3).
720
+ gSpider
721
+ .append('circle')
722
+ .attr('cx', cl.cx)
723
+ .attr('cy', cl.cy)
724
+ .attr('r', 2)
725
+ .attr('fill', mix(palette.textMuted, palette.bg, 40))
726
+ .attr('data-cluster-deco', cl.id)
727
+ .style('pointer-events', 'none');
728
+ }
729
+
253
730
  // ── POIs ──
254
731
  const gPois = svg.append('g').attr('class', 'dgmo-map-pois');
255
732
  for (const poi of layout.pois) {
@@ -273,6 +750,9 @@ export function renderMap(
273
750
  .attr('stroke-width', 1)
274
751
  .attr('data-line-number', poi.lineNumber)
275
752
  .attr('data-poi', poi.id);
753
+ // Coincident-stack member: tag so the app hides it when collapsing the stack.
754
+ if (poi.clusterId !== undefined)
755
+ c.attr('data-cluster-member', poi.clusterId);
276
756
  // Tag the marker per tag value (lowercased, matching the lowercased
277
757
  // legend-entry attributes) so the app can spotlight it on legend hover.
278
758
  if (poi.tags) {
@@ -303,6 +783,31 @@ export function renderMap(
303
783
  // ── Labels (leaders + halo text) ──
304
784
  const gLabels = svg.append('g').attr('class', 'dgmo-map-labels');
305
785
  for (const lab of layout.labels) {
786
+ // Hover-only labels: OMIT entirely from static export (export = the
787
+ // hover-less default view); in preview emit invisible + flagged so the app
788
+ // can reveal them on hover. They carry no leader, so the leader block below
789
+ // is skipped naturally.
790
+ if (lab.hidden) {
791
+ if (exportDims) continue;
792
+ emitText(
793
+ gLabels,
794
+ lab.x,
795
+ lab.y,
796
+ lab.text,
797
+ lab.anchor,
798
+ lab.color,
799
+ lab.haloColor,
800
+ lab.halo,
801
+ LABEL_FONT,
802
+ lab.italic,
803
+ lab.letterSpacing
804
+ )
805
+ .attr('data-poi', lab.poiId ?? null)
806
+ .attr('data-poi-hidden', '')
807
+ .style('opacity', 0)
808
+ .style('pointer-events', 'none');
809
+ continue;
810
+ }
306
811
  if (lab.leader) {
307
812
  const line = gLabels
308
813
  .append('line')
@@ -317,6 +822,10 @@ export function renderMap(
317
822
  )
318
823
  .attr('stroke-width', lab.leaderColor ? 1 : 0.75);
319
824
  if (lab.poiId !== undefined) line.attr('data-poi', lab.poiId);
825
+ // Spiderfy member leader: toggle it with the badge (same as its text).
826
+ if (lab.clusterMember !== undefined)
827
+ line.attr('data-cluster-member', lab.clusterMember);
828
+ wireSync(line, lab.lineNumber);
320
829
  }
321
830
  const t = emitText(
322
831
  gLabels,
@@ -327,13 +836,68 @@ export function renderMap(
327
836
  lab.color,
328
837
  lab.haloColor,
329
838
  lab.halo,
330
- LABEL_FONT
839
+ LABEL_FONT,
840
+ lab.italic,
841
+ lab.letterSpacing,
842
+ lab.lines
331
843
  );
332
844
  // POI labels are spotlightable: tag with the POI id and make the text the
333
845
  // hover target (the app dims the other dots/labels on enter).
334
846
  if (lab.poiId !== undefined) {
335
847
  t.attr('data-poi', lab.poiId).style('cursor', 'default');
336
848
  }
849
+ // Coincident-stack member label: hidden by the app when its stack collapses.
850
+ if (lab.clusterMember !== undefined) {
851
+ t.attr('data-cluster-member', lab.clusterMember);
852
+ }
853
+ // Click a region/POI label to jump to its source line (sets cursor:pointer,
854
+ // overriding the default above for POI labels).
855
+ wireSync(t, lab.lineNumber);
856
+ }
857
+
858
+ // ── Coincident-stack badges (collapsed view). Interactive ONLY — a static
859
+ // export keeps the expanded fan (every label visible), so no badge there. A
860
+ // neutral dot ringed with the bare member count, emitted hidden; the app shows
861
+ // it at rest and hides it (revealing the spider) on click. ──
862
+ if (!exportDims && layout.clusters.length) {
863
+ const gBadge = svg.append('g').attr('class', 'dgmo-map-cluster-badges');
864
+ for (const cl of layout.clusters) {
865
+ // Decorative: the hit-area (drawn under the dots) owns hover + click; the
866
+ // badge just shows the count, so it never intercepts pointer events.
867
+ const g = gBadge
868
+ .append('g')
869
+ .attr('data-cluster', cl.id)
870
+ .style('opacity', 0)
871
+ .style('pointer-events', 'none');
872
+ const R = 9;
873
+ // Inner neutral disc + outer ring → reads as "more than one here" (RQ2).
874
+ g.append('circle')
875
+ .attr('cx', cl.cx)
876
+ .attr('cy', cl.cy)
877
+ .attr('r', R)
878
+ .attr('fill', mix(palette.textMuted, palette.bg, 35))
879
+ .attr('stroke', palette.textMuted)
880
+ .attr('stroke-width', 1);
881
+ g.append('circle')
882
+ .attr('cx', cl.cx)
883
+ .attr('cy', cl.cy)
884
+ .attr('r', R + 2.5)
885
+ .attr('fill', 'none')
886
+ .attr('stroke', palette.textMuted)
887
+ .attr('stroke-width', 1);
888
+ // Bare count (RQ1).
889
+ emitText(
890
+ g,
891
+ cl.cx,
892
+ cl.cy + 3,
893
+ String(cl.count),
894
+ 'middle',
895
+ palette.text,
896
+ palette.bg,
897
+ false,
898
+ LABEL_FONT
899
+ );
900
+ }
337
901
  }
338
902
 
339
903
  // ── Legend (categorical via renderLegendD3 + ramp/size/weight blocks; AR1) ──
@@ -407,6 +971,7 @@ export function renderMap(
407
971
  if (layout.subtitle) {
408
972
  svg
409
973
  .append('text')
974
+ .attr('class', 'dgmo-map-subtitle')
410
975
  .attr('x', width / 2)
411
976
  .attr('y', TITLE_Y + TITLE_FONT_SIZE)
412
977
  .attr('text-anchor', 'middle')
@@ -459,7 +1024,10 @@ function emitText(
459
1024
  color: string,
460
1025
  halo: string,
461
1026
  withHalo: boolean,
462
- fontSize: number
1027
+ fontSize: number,
1028
+ italic?: boolean,
1029
+ letterSpacing?: number,
1030
+ lines?: readonly string[]
463
1031
  ): d3Selection.Selection<SVGTextElement, unknown, null, undefined> {
464
1032
  const t = g
465
1033
  .append('text')
@@ -467,14 +1035,36 @@ function emitText(
467
1035
  .attr('y', y)
468
1036
  .attr('text-anchor', anchor)
469
1037
  .attr('font-size', fontSize)
470
- .attr('fill', color)
471
- .text(text);
1038
+ .attr('fill', color);
1039
+ // Multi-line context labels (water names): stack centred tspans around `y` so
1040
+ // the block's vertical centre matches the placement rect. Single-line keeps the
1041
+ // bare `.text()` form so every other call site stays byte-identical.
1042
+ if (lines && lines.length > 1) {
1043
+ const lineHeight = fontSize + 2; // MUST match context-labels LINE_HEIGHT
1044
+ const startDy = -((lines.length - 1) / 2) * lineHeight;
1045
+ lines.forEach((ln, i) => {
1046
+ t.append('tspan')
1047
+ .attr('x', x)
1048
+ .attr('dy', i === 0 ? startDy : lineHeight)
1049
+ .text(ln);
1050
+ });
1051
+ } else {
1052
+ t.text(text);
1053
+ }
1054
+ // Cartographic styling for context labels; absent on every other call site so
1055
+ // existing output stays byte-identical (only emitted when explicitly set).
1056
+ if (italic) t.attr('font-style', 'italic');
1057
+ if (letterSpacing) t.attr('letter-spacing', letterSpacing);
472
1058
  if (withHalo) {
1059
+ // Thin, even outline (2px / 1px-per-side at the 11px label font — 3px read
1060
+ // top-heavy as adjacent glyph tops merged their strokes). Round join + cap
1061
+ // keep the edge uniform around every glyph.
473
1062
  t.attr('paint-order', 'stroke fill')
474
1063
  .attr('stroke', halo)
475
- .attr('stroke-width', 3)
1064
+ .attr('stroke-width', 2)
476
1065
  .attr('stroke-linejoin', 'round')
477
- .attr('stroke-opacity', 0.7);
1066
+ .attr('stroke-linecap', 'round')
1067
+ .attr('stroke-opacity', 0.55);
478
1068
  }
479
1069
  return t;
480
1070
  }