@diagrammo/dgmo 0.21.1 → 0.22.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 (73) hide show
  1. package/README.md +16 -6
  2. package/dist/advanced.cjs +2003 -466
  3. package/dist/advanced.d.cts +5714 -0
  4. package/dist/advanced.d.ts +5714 -0
  5. package/dist/advanced.js +1999 -466
  6. package/dist/auto.cjs +2048 -449
  7. package/dist/auto.d.cts +39 -0
  8. package/dist/auto.d.ts +39 -0
  9. package/dist/auto.js +121 -121
  10. package/dist/auto.mjs +2050 -450
  11. package/dist/cli.cjs +170 -170
  12. package/dist/editor.cjs +13 -16
  13. package/dist/editor.js +13 -16
  14. package/dist/highlight.cjs +15 -13
  15. package/dist/highlight.js +15 -13
  16. package/dist/index.cjs +2032 -435
  17. package/dist/index.d.cts +339 -0
  18. package/dist/index.d.ts +339 -0
  19. package/dist/index.js +2034 -436
  20. package/dist/internal.cjs +2003 -466
  21. package/dist/internal.d.cts +5714 -0
  22. package/dist/internal.d.ts +5714 -0
  23. package/dist/internal.js +1999 -466
  24. package/dist/map-data/water-bodies.json +1 -0
  25. package/docs/language-reference.md +20 -9
  26. package/gallery/fixtures/map-categorical-world.dgmo +16 -0
  27. package/gallery/fixtures/map-categorical.dgmo +0 -1
  28. package/gallery/fixtures/map-choropleth.dgmo +0 -1
  29. package/gallery/fixtures/map-coastline.dgmo +7 -0
  30. package/gallery/fixtures/map-colorize.dgmo +11 -0
  31. package/gallery/fixtures/map-direct-color.dgmo +0 -1
  32. package/gallery/fixtures/map-reference-world.dgmo +11 -0
  33. package/gallery/fixtures/map-region-scope.dgmo +0 -3
  34. package/gallery/fixtures/map-route.dgmo +0 -1
  35. package/package.json +1 -1
  36. package/src/advanced.ts +12 -1
  37. package/src/boxes-and-lines/renderer.ts +39 -12
  38. package/src/cli.ts +1 -1
  39. package/src/completion.ts +32 -25
  40. package/src/cycle/renderer.ts +14 -1
  41. package/src/d3.ts +8 -2
  42. package/src/editor/highlight-api.ts +4 -0
  43. package/src/editor/keywords.ts +13 -16
  44. package/src/infra/renderer.ts +35 -7
  45. package/src/map/colorize.ts +54 -0
  46. package/src/map/context-labels.ts +429 -0
  47. package/src/map/data/types.ts +34 -0
  48. package/src/map/data/water-bodies.json +1 -0
  49. package/src/map/dimensions.ts +117 -0
  50. package/src/map/geo-query.ts +21 -3
  51. package/src/map/geo.ts +47 -1
  52. package/src/map/layout.ts +1300 -251
  53. package/src/map/load-data.ts +10 -2
  54. package/src/map/parser.ts +42 -116
  55. package/src/map/renderer.ts +512 -13
  56. package/src/map/resolved-types.ts +16 -2
  57. package/src/map/resolver.ts +208 -59
  58. package/src/map/types.ts +30 -32
  59. package/src/mindmap/renderer.ts +10 -1
  60. package/src/palettes/atlas.ts +77 -0
  61. package/src/palettes/blueprint.ts +73 -0
  62. package/src/palettes/color-utils.ts +58 -1
  63. package/src/palettes/index.ts +12 -3
  64. package/src/palettes/slate.ts +73 -0
  65. package/src/palettes/tidewater.ts +73 -0
  66. package/src/render.ts +8 -1
  67. package/src/tech-radar/renderer.ts +3 -0
  68. package/src/tech-radar/types.ts +3 -0
  69. package/src/utils/d3-types.ts +5 -0
  70. package/src/utils/legend-layout.ts +21 -4
  71. package/src/utils/legend-types.ts +7 -0
  72. package/src/utils/reserved-key-registry.ts +3 -0
  73. package/src/palettes/bold.ts +0 -67
@@ -17,10 +17,131 @@ 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
+ /** Coast outlines to buffer: every region's OUTER rings whose bbox extent clears
61
+ * `minExtent`. Holes/enclaves are skipped via containment depth (even depth =
62
+ * outer landmass boundary, odd = a hole) so an enclave (Lesotho) or a lake-hole
63
+ * is never ringed as a fake coast on land (R11). `minExtent` is a bare
64
+ * degenerate-ring floor now — every island, however small, grows coast rings. */
65
+ function coastlineOuterRings(
66
+ regions: readonly MapLayoutRegion[],
67
+ minExtent: number
68
+ ): string[] {
69
+ const paths: string[] = [];
70
+ for (const r of regions) {
71
+ const rings = parsePathRings(r.d);
72
+ for (let i = 0; i < rings.length; i++) {
73
+ const ring = rings[i]!;
74
+ if (ring.length < 3) continue;
75
+ let minX = Infinity;
76
+ let minY = Infinity;
77
+ let maxX = -Infinity;
78
+ let maxY = -Infinity;
79
+ for (const [x, y] of ring) {
80
+ if (x < minX) minX = x;
81
+ if (x > maxX) maxX = x;
82
+ if (y < minY) minY = y;
83
+ if (y > maxY) maxY = y;
84
+ }
85
+ if (Math.max(maxX - minX, maxY - minY) < minExtent) continue;
86
+ const [fx, fy] = ring[0]!;
87
+ let depth = 0;
88
+ for (let j = 0; j < rings.length; j++)
89
+ if (j !== i && pointInRing(fx, fy, rings[j]!)) depth++;
90
+ if (depth % 2 === 1) continue; // hole/enclave — skip
91
+ paths.push(ringToPath(ring));
92
+ }
93
+ }
94
+ return paths;
95
+ }
96
+
97
+ /** Stroke the coast-parallel water-lines into a masked group. Per line, outer→
98
+ * inner so the inner ring draws on top: a colour pass (the symmetric buffer
99
+ * band) then a flat-water overdraw that erodes it to a thin offshore ring. The
100
+ * group's `<mask>` keeps only the water-side half of each band.
101
+ *
102
+ * The outer→inner ordering protects a single ring (the inner band never reaches
103
+ * the outer ring because `d1+thickness < d2`, the layout invariant). It does NOT
104
+ * protect across regions: where two coasts sit closer than ~2·d1 (a tripoint, a
105
+ * narrow strait, an inset box edge), one region's flat-water overdraw can paint
106
+ * over a neighbour's inner ring — the same accepted "tripoint stub / narrow
107
+ * inlet fills solid" artifact the tech-spec calls out, bounded by small d.
108
+ *
109
+ * Perf: every outer ring in a given (level, pass) shares identical stroke
110
+ * attributes, so they collapse into ONE multi-subpath `<path>` (each ring's
111
+ * `d` already starts with `M`, so joining is a valid compound path). A world
112
+ * map drops from ~6k coastline paths to 2 per ring-level (~10 total), which is
113
+ * ~87% of the whole map's path count — the dominant cost in any repaint. The
114
+ * only visible consequence: the colour-band pass strokes at `stroke-opacity`,
115
+ * and a single path's stroke rasterises as ONE coverage region, so adjacent
116
+ * bands that overlap (straits/tripoints) no longer double-darken — they read
117
+ * uniform, which is if anything cleaner. Draw order (all colour bands, then all
118
+ * flat-water, outer level first) is unchanged, so the single-ring invariant and
119
+ * the accepted cross-region overdraw artifact behave exactly as before. */
120
+ function appendWaterLines(
121
+ g: Sel,
122
+ outerRings: readonly string[],
123
+ style: MapLayoutCoastlineStyle,
124
+ flatWater: string
125
+ ): void {
126
+ const d = outerRings.join(' ');
127
+ const linesOuterFirst = [...style.lines].sort((a, b) => b.d - a.d);
128
+ for (const line of linesOuterFirst) {
129
+ g.append('path')
130
+ .attr('d', d)
131
+ .attr('stroke', style.color)
132
+ .attr('stroke-width', 2 * (line.d + line.thickness))
133
+ .attr('stroke-opacity', line.opacity)
134
+ .attr('stroke-linejoin', 'round')
135
+ .attr('stroke-linecap', 'round');
136
+ g.append('path')
137
+ .attr('d', d)
138
+ .attr('stroke', flatWater)
139
+ .attr('stroke-width', 2 * line.d)
140
+ .attr('stroke-linejoin', 'round')
141
+ .attr('stroke-linecap', 'round');
142
+ }
143
+ }
144
+
24
145
  /** Render a resolved map into `container` (d3-selection appends an `<svg>`). */
25
146
  export function renderMap(
26
147
  container: HTMLDivElement,
@@ -45,6 +166,11 @@ export function renderMap(
45
166
  {
46
167
  palette,
47
168
  isDark,
169
+ // Export-only: forward the contain-fit request from mapExportDimensions so a
170
+ // clamped/floored (off-aspect) export canvas letterboxes instead of
171
+ // stretch-distorting. The in-app preview pane passes no exportDims → unset →
172
+ // keeps the global stretch-fill.
173
+ preferContain: exportDims?.preferContain ?? false,
48
174
  ...(activeGroupOverride !== undefined && {
49
175
  activeGroup: activeGroupOverride,
50
176
  }),
@@ -100,6 +226,9 @@ export function renderMap(
100
226
  .attr('fill', r.fill)
101
227
  .attr('stroke', r.stroke)
102
228
  .attr('stroke-width', strokeWidth);
229
+ // Display name on EVERY region (authored + base/context) so the app can
230
+ // surface it on hover — decorative metadata, no visible text drawn here.
231
+ if (r.label) p.attr('data-region-name', r.label);
103
232
  // Data layer? Tag it so the app can highlight on legend hover / gradient
104
233
  // scrub. `data-value` for ramp-proximity, `data-tag-<group>` per tag value
105
234
  // (both lowercased to match the lowercased legend-entry attributes).
@@ -166,6 +295,113 @@ export function renderMap(
166
295
  }
167
296
  }
168
297
 
298
+ // ── Coastline water-lines (faint nautical-chart lines on the WATER side) ──
299
+ // 2 discrete coast-parallel lines hugging the ocean shore + lake shores, fading
300
+ // seaward. Each region's outer ring is buffered as a symmetric SVG stroke band
301
+ // then eroded with a flat-water overdraw to a thin offshore ring; a luminance
302
+ // <mask> (white canvas − black land + white lakes) reveals only the water side,
303
+ // so land/land borders self-remove (their band falls on
304
+ // the neighbour's land, which the mask hides — no topojson.mesh needed). NOT a
305
+ // clipPath: sibling clip paths UNION (can't subtract land). Decorative — no data
306
+ // attrs, plain strokes. Below rivers/POIs/legs/labels, above region/relief fills
307
+ // (so with `relief` on, water-lines sit on water and relief on land — disjoint).
308
+ // §24B.2, ADR-1/3/6.
309
+ if (layout.coastlineStyle) {
310
+ const cs = layout.coastlineStyle;
311
+ const maskId = 'dgmo-map-water-mask';
312
+ const mask = defs
313
+ .append('mask')
314
+ .attr('id', maskId)
315
+ // userSpaceOnUse: the default objectBoundingBox clamps the mask region to
316
+ // the group's own bbox and drops the canvas-edge reveal (round-2 #2).
317
+ .attr('maskUnits', 'userSpaceOnUse')
318
+ .attr('x', 0)
319
+ .attr('y', 0)
320
+ .attr('width', width)
321
+ .attr('height', height);
322
+ mask
323
+ .append('rect')
324
+ .attr('x', 0)
325
+ .attr('y', 0)
326
+ .attr('width', width)
327
+ .attr('height', height)
328
+ .attr('fill', 'white');
329
+ // All land fills are opaque black and all lake fills opaque white, so each
330
+ // group collapses into ONE compound path (land first, lakes over it to
331
+ // re-reveal their interiors). Identical pixels, ~hundreds of paths → 2.
332
+ const landD = layout.regions
333
+ .filter((r) => r.id !== 'lake')
334
+ .map((r) => r.d)
335
+ .join(' ');
336
+ const lakeD = layout.regions
337
+ .filter((r) => r.id === 'lake')
338
+ .map((r) => r.d)
339
+ .join(' ');
340
+ if (landD) mask.append('path').attr('d', landD).attr('fill', 'black');
341
+ if (lakeD) mask.append('path').attr('d', lakeD).attr('fill', 'white');
342
+ // Moat around each AK/HI inset box: the opaque box is drawn in the FOREGROUND
343
+ // over open ocean, so the main-map offshore rings get chopped by its border
344
+ // and butt into it — sloppy. Paint each box quad black (interior, under the
345
+ // box) with a black stroke whose half-width = the band's outer reach, so the
346
+ // main rings also clear a margin OUTSIDE the border. The ring-ends then fall
347
+ // in open water away from the frame instead of crashing into it.
348
+ if (layout.insets.length) {
349
+ const reach = Math.max(0, ...cs.lines.map((l) => l.d + l.thickness));
350
+ for (const box of layout.insets) {
351
+ const d =
352
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
353
+ 'Z';
354
+ mask
355
+ .append('path')
356
+ .attr('d', d)
357
+ .attr('fill', 'black')
358
+ .attr('stroke', 'black')
359
+ .attr('stroke-width', 2 * reach)
360
+ .attr('stroke-linejoin', 'round');
361
+ }
362
+ }
363
+ // NO frame band: a synthetic frame-cut edge (clipExtent/cullFeatureToView
364
+ // trims region `d` to the view rect) has the region INTERIOR — land — on the
365
+ // canvas-interior side, which the mask already paints black, so its band is
366
+ // hidden anyway. A border band here only suppressed REAL coastal rings near
367
+ // the canvas edge, leaving an empty top/bottom strip — so the water-lines now
368
+ // carry through to every edge of the visible map.
369
+ const gWater = svg
370
+ .append('g')
371
+ .attr('class', 'dgmo-map-water-lines')
372
+ .attr('fill', 'none')
373
+ .attr('mask', `url(#${maskId})`);
374
+ appendWaterLines(
375
+ gWater,
376
+ coastlineOuterRings(layout.regions, cs.minExtent),
377
+ cs,
378
+ layout.background
379
+ );
380
+ // Restore the seaward half of the coast stroke: the rings' flat-water erosion
381
+ // overdraws repaint the water out to d_max, which paints over the water-side
382
+ // half of each region's coast outline and makes coastlines read faded. Re-
383
+ // stroke every region inside the SAME masked group (so it only repaints the
384
+ // water side — the land side was never touched, and interior land/land borders
385
+ // stay hidden), on top of the rings. The strokes sit at the coast (offset 0),
386
+ // well inside d0, so they never cover the offshore rings.
387
+ // Group the restroke by stroke colour (base borders share one colour; the
388
+ // occasional data region may differ) so same-coloured coasts collapse into a
389
+ // single compound path instead of one path per region.
390
+ const byStroke = new Map<string, string[]>();
391
+ for (const r of layout.regions) {
392
+ const arr = byStroke.get(r.stroke);
393
+ if (arr) arr.push(r.d);
394
+ else byStroke.set(r.stroke, [r.d]);
395
+ }
396
+ for (const [stroke, ds] of byStroke)
397
+ gWater
398
+ .append('path')
399
+ .attr('d', ds.join(' '))
400
+ .attr('stroke', stroke)
401
+ .attr('stroke-width', 0.5)
402
+ .attr('stroke-linejoin', 'round');
403
+ }
404
+
169
405
  // ── Rivers (thin water centerlines over the land, under POIs/edges) ──
170
406
  if (layout.rivers.length) {
171
407
  const gRivers = svg
@@ -188,7 +424,7 @@ export function renderMap(
188
424
  // then draws on top, framed by the box border. ──
189
425
  if (layout.insets.length) {
190
426
  const insetG = svg.append('g').attr('class', 'dgmo-map-insets');
191
- for (const box of layout.insets) {
427
+ layout.insets.forEach((box, bi) => {
192
428
  // Angled-top quad frame — rides under the conus coast so it never covers
193
429
  // neighbouring states. Closed path from the four corners.
194
430
  const d =
@@ -201,10 +437,110 @@ export function renderMap(
201
437
  .attr('stroke', mix(palette.text, palette.bg, 55))
202
438
  .attr('stroke-width', 1)
203
439
  .attr('stroke-linejoin', 'round');
204
- }
440
+ // Neighbour land (Canada beside Alaska) clipped to this box, behind the
441
+ // state — so a land border reads as land rather than sprouting coast rings.
442
+ if (box.contextLand) {
443
+ const clipId = `dgmo-map-inset-clip-${bi}`;
444
+ defs.append('clipPath').attr('id', clipId).append('path').attr('d', d);
445
+ insetG
446
+ .append('path')
447
+ .attr('d', box.contextLand.d)
448
+ .attr('fill', box.contextLand.fill)
449
+ .attr('clip-path', `url(#${clipId})`);
450
+ }
451
+ });
205
452
  for (const r of layout.insetRegions) drawRegion(insetG, r, 0.5);
453
+
454
+ // Inset coastline water-lines (AK/HI box interiors) for visual parity with
455
+ // the main map. Mask = the inset box quads (white reveal) − inset regions
456
+ // (black land / white lake); buffer+erode the inset region outer rings the
457
+ // same way. Inside the inset group so it composites over the box fills.
458
+ if (layout.coastlineStyle) {
459
+ const cs = layout.coastlineStyle;
460
+ const maskId = 'dgmo-map-inset-water-mask';
461
+ const mask = defs
462
+ .append('mask')
463
+ .attr('id', maskId)
464
+ .attr('maskUnits', 'userSpaceOnUse')
465
+ .attr('x', 0)
466
+ .attr('y', 0)
467
+ .attr('width', width)
468
+ .attr('height', height);
469
+ for (const box of layout.insets) {
470
+ const d =
471
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
472
+ 'Z';
473
+ mask.append('path').attr('d', d).attr('fill', 'white');
474
+ }
475
+ // Neighbour land masks as land too — clipped to its box so it can't darken
476
+ // an adjacent inset — keeping the AK/Canada land border free of rings.
477
+ layout.insets.forEach((box, bi) => {
478
+ if (box.contextLand)
479
+ mask
480
+ .append('path')
481
+ .attr('d', box.contextLand.d)
482
+ .attr('fill', 'black')
483
+ .attr('clip-path', `url(#dgmo-map-inset-clip-${bi})`);
484
+ });
485
+ for (const r of layout.insetRegions)
486
+ if (r.id !== 'lake')
487
+ mask.append('path').attr('d', r.d).attr('fill', 'black');
488
+ for (const r of layout.insetRegions)
489
+ if (r.id === 'lake')
490
+ mask.append('path').attr('d', r.d).attr('fill', 'white');
491
+ // Clip the water-line strokes to the inset box quads — the mask controls
492
+ // which side reads as water, but SVG strokes still extend stroke-width/2
493
+ // past their path, so without this the seaward rings bleed over the box
494
+ // border. Union of all inset quads = one clipPath shared by the group.
495
+ const clipId = 'dgmo-map-inset-water-clip';
496
+ const clip = defs.append('clipPath').attr('id', clipId);
497
+ for (const box of layout.insets) {
498
+ const d =
499
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
500
+ 'Z';
501
+ clip.append('path').attr('d', d);
502
+ }
503
+ // Nest clip (outer) + mask (inner) rather than both on one element —
504
+ // WebKit (Tauri) renders the combined attributes inconsistently; the
505
+ // nested form is honored by every engine.
506
+ const gInsetWater = insetG
507
+ .append('g')
508
+ .attr('clip-path', `url(#${clipId})`)
509
+ .append('g')
510
+ .attr('class', 'dgmo-map-inset-water-lines')
511
+ .attr('fill', 'none')
512
+ .attr('mask', `url(#${maskId})`);
513
+ appendWaterLines(
514
+ gInsetWater,
515
+ coastlineOuterRings(layout.insetRegions, cs.minExtent),
516
+ cs,
517
+ layout.background
518
+ );
519
+ // Restore the seaward half of the inset coast strokes (see main pass).
520
+ for (const r of layout.insetRegions)
521
+ gInsetWater
522
+ .append('path')
523
+ .attr('d', r.d)
524
+ .attr('stroke', r.stroke)
525
+ .attr('stroke-width', 0.5)
526
+ .attr('stroke-linejoin', 'round');
527
+ }
206
528
  }
207
529
 
530
+ // Code↔diagram sync: tag a synced element with its 1-based source line and,
531
+ // in preview, make it jump the editor cursor on click. Source-derived elements
532
+ // (regions, POIs, legs, labels) all carry `lineNumber`; generated context
533
+ // labels use 0 as a "no source line" sentinel, so guard on `>= 1`.
534
+ const wireSync = <E extends Element>(
535
+ sel: d3Selection.Selection<E, unknown, null, undefined>,
536
+ lineNumber: number
537
+ ): void => {
538
+ if (lineNumber < 1) return;
539
+ sel.attr('data-line-number', lineNumber);
540
+ if (onClickItem)
541
+ sel.style('cursor', 'pointer').on('click', () => onClickItem(lineNumber));
542
+ };
543
+
208
544
  // ── Legs (edges + route legs) ──
209
545
  const gLegs = svg
210
546
  .append('g')
@@ -217,6 +553,9 @@ export function renderMap(
217
553
  .attr('stroke', leg.color)
218
554
  .attr('stroke-width', leg.width)
219
555
  .attr('stroke-linecap', 'round');
556
+ // A 0-width invisible leg path is hard to hit; pointer-events on the visible
557
+ // stroke is enough for the line widths legs use.
558
+ wireSync(p, leg.lineNumber);
220
559
  if (leg.arrow) {
221
560
  const id = `dgmo-map-arrow-${i}`;
222
561
  const s = arrowSize(leg.width);
@@ -236,20 +575,67 @@ export function renderMap(
236
575
  p.attr('marker-end', `url(#${id})`);
237
576
  }
238
577
  if (leg.label !== undefined && leg.labelX !== undefined) {
239
- emitText(
578
+ // Text shade is contrast-picked in layout against the fill under the label
579
+ // (dark scored country ⇒ light text, pale land ⇒ dark), with the ghost halo
580
+ // only when that contrast is marginal. Fall back to the muted default for
581
+ // legs that predate the computed style.
582
+ const lt = emitText(
240
583
  gLegs,
241
584
  leg.labelX,
242
585
  leg.labelY ?? 0,
243
586
  leg.label,
244
587
  'middle',
245
- palette.textMuted,
246
- haloColor,
247
- true,
588
+ leg.labelColor ?? palette.textMuted,
589
+ leg.labelHaloColor ?? haloColor,
590
+ leg.labelHalo ?? true,
248
591
  LABEL_FONT - 1
249
592
  );
593
+ wireSync(lt, leg.lineNumber);
250
594
  }
251
595
  });
252
596
 
597
+ // ── Coincident-stack spider legs + hub (under the dots) ──
598
+ // Legs + hub are DECORATIVE (`data-cluster-deco`, pointer-events none) — the app
599
+ // toggles only their opacity. Drawn VISIBLE so export + the no-JS default show
600
+ // the expanded fan. An invisible hit-area circle (interactive only) owns all
601
+ // pointer interaction so hover/click drives the spiderfy controller robustly.
602
+ const gSpider = svg.append('g').attr('class', 'dgmo-map-spider');
603
+ for (const cl of layout.clusters) {
604
+ // Pointer hit-area — bottom of the stack so member dots still take their own
605
+ // clicks (line-jump); clicks on the empty centre fall through to here.
606
+ if (!exportDims) {
607
+ gSpider
608
+ .append('circle')
609
+ .attr('cx', cl.cx)
610
+ .attr('cy', cl.cy)
611
+ .attr('r', cl.hitR)
612
+ .attr('fill', 'transparent')
613
+ .attr('data-cluster-hit', cl.id)
614
+ .style('cursor', 'pointer');
615
+ }
616
+ for (const leg of cl.legs) {
617
+ gSpider
618
+ .append('line')
619
+ .attr('x1', cl.cx)
620
+ .attr('y1', cl.cy)
621
+ .attr('x2', leg.x2)
622
+ .attr('y2', leg.y2)
623
+ .attr('stroke', leg.color)
624
+ .attr('stroke-width', 1)
625
+ .attr('data-cluster-deco', cl.id)
626
+ .style('pointer-events', 'none');
627
+ }
628
+ // Faint neutral hub anchoring the legs (RQ3).
629
+ gSpider
630
+ .append('circle')
631
+ .attr('cx', cl.cx)
632
+ .attr('cy', cl.cy)
633
+ .attr('r', 2)
634
+ .attr('fill', mix(palette.textMuted, palette.bg, 40))
635
+ .attr('data-cluster-deco', cl.id)
636
+ .style('pointer-events', 'none');
637
+ }
638
+
253
639
  // ── POIs ──
254
640
  const gPois = svg.append('g').attr('class', 'dgmo-map-pois');
255
641
  for (const poi of layout.pois) {
@@ -273,6 +659,9 @@ export function renderMap(
273
659
  .attr('stroke-width', 1)
274
660
  .attr('data-line-number', poi.lineNumber)
275
661
  .attr('data-poi', poi.id);
662
+ // Coincident-stack member: tag so the app hides it when collapsing the stack.
663
+ if (poi.clusterId !== undefined)
664
+ c.attr('data-cluster-member', poi.clusterId);
276
665
  // Tag the marker per tag value (lowercased, matching the lowercased
277
666
  // legend-entry attributes) so the app can spotlight it on legend hover.
278
667
  if (poi.tags) {
@@ -303,6 +692,31 @@ export function renderMap(
303
692
  // ── Labels (leaders + halo text) ──
304
693
  const gLabels = svg.append('g').attr('class', 'dgmo-map-labels');
305
694
  for (const lab of layout.labels) {
695
+ // Hover-only labels: OMIT entirely from static export (export = the
696
+ // hover-less default view); in preview emit invisible + flagged so the app
697
+ // can reveal them on hover. They carry no leader, so the leader block below
698
+ // is skipped naturally.
699
+ if (lab.hidden) {
700
+ if (exportDims) continue;
701
+ emitText(
702
+ gLabels,
703
+ lab.x,
704
+ lab.y,
705
+ lab.text,
706
+ lab.anchor,
707
+ lab.color,
708
+ lab.haloColor,
709
+ lab.halo,
710
+ LABEL_FONT,
711
+ lab.italic,
712
+ lab.letterSpacing
713
+ )
714
+ .attr('data-poi', lab.poiId ?? null)
715
+ .attr('data-poi-hidden', '')
716
+ .style('opacity', 0)
717
+ .style('pointer-events', 'none');
718
+ continue;
719
+ }
306
720
  if (lab.leader) {
307
721
  const line = gLabels
308
722
  .append('line')
@@ -317,6 +731,10 @@ export function renderMap(
317
731
  )
318
732
  .attr('stroke-width', lab.leaderColor ? 1 : 0.75);
319
733
  if (lab.poiId !== undefined) line.attr('data-poi', lab.poiId);
734
+ // Spiderfy member leader: toggle it with the badge (same as its text).
735
+ if (lab.clusterMember !== undefined)
736
+ line.attr('data-cluster-member', lab.clusterMember);
737
+ wireSync(line, lab.lineNumber);
320
738
  }
321
739
  const t = emitText(
322
740
  gLabels,
@@ -327,13 +745,68 @@ export function renderMap(
327
745
  lab.color,
328
746
  lab.haloColor,
329
747
  lab.halo,
330
- LABEL_FONT
748
+ LABEL_FONT,
749
+ lab.italic,
750
+ lab.letterSpacing,
751
+ lab.lines
331
752
  );
332
753
  // POI labels are spotlightable: tag with the POI id and make the text the
333
754
  // hover target (the app dims the other dots/labels on enter).
334
755
  if (lab.poiId !== undefined) {
335
756
  t.attr('data-poi', lab.poiId).style('cursor', 'default');
336
757
  }
758
+ // Coincident-stack member label: hidden by the app when its stack collapses.
759
+ if (lab.clusterMember !== undefined) {
760
+ t.attr('data-cluster-member', lab.clusterMember);
761
+ }
762
+ // Click a region/POI label to jump to its source line (sets cursor:pointer,
763
+ // overriding the default above for POI labels).
764
+ wireSync(t, lab.lineNumber);
765
+ }
766
+
767
+ // ── Coincident-stack badges (collapsed view). Interactive ONLY — a static
768
+ // export keeps the expanded fan (every label visible), so no badge there. A
769
+ // neutral dot ringed with the bare member count, emitted hidden; the app shows
770
+ // it at rest and hides it (revealing the spider) on click. ──
771
+ if (!exportDims && layout.clusters.length) {
772
+ const gBadge = svg.append('g').attr('class', 'dgmo-map-cluster-badges');
773
+ for (const cl of layout.clusters) {
774
+ // Decorative: the hit-area (drawn under the dots) owns hover + click; the
775
+ // badge just shows the count, so it never intercepts pointer events.
776
+ const g = gBadge
777
+ .append('g')
778
+ .attr('data-cluster', cl.id)
779
+ .style('opacity', 0)
780
+ .style('pointer-events', 'none');
781
+ const R = 9;
782
+ // Inner neutral disc + outer ring → reads as "more than one here" (RQ2).
783
+ g.append('circle')
784
+ .attr('cx', cl.cx)
785
+ .attr('cy', cl.cy)
786
+ .attr('r', R)
787
+ .attr('fill', mix(palette.textMuted, palette.bg, 35))
788
+ .attr('stroke', palette.textMuted)
789
+ .attr('stroke-width', 1);
790
+ g.append('circle')
791
+ .attr('cx', cl.cx)
792
+ .attr('cy', cl.cy)
793
+ .attr('r', R + 2.5)
794
+ .attr('fill', 'none')
795
+ .attr('stroke', palette.textMuted)
796
+ .attr('stroke-width', 1);
797
+ // Bare count (RQ1).
798
+ emitText(
799
+ g,
800
+ cl.cx,
801
+ cl.cy + 3,
802
+ String(cl.count),
803
+ 'middle',
804
+ palette.text,
805
+ palette.bg,
806
+ false,
807
+ LABEL_FONT
808
+ );
809
+ }
337
810
  }
338
811
 
339
812
  // ── Legend (categorical via renderLegendD3 + ramp/size/weight blocks; AR1) ──
@@ -407,6 +880,7 @@ export function renderMap(
407
880
  if (layout.subtitle) {
408
881
  svg
409
882
  .append('text')
883
+ .attr('class', 'dgmo-map-subtitle')
410
884
  .attr('x', width / 2)
411
885
  .attr('y', TITLE_Y + TITLE_FONT_SIZE)
412
886
  .attr('text-anchor', 'middle')
@@ -459,7 +933,10 @@ function emitText(
459
933
  color: string,
460
934
  halo: string,
461
935
  withHalo: boolean,
462
- fontSize: number
936
+ fontSize: number,
937
+ italic?: boolean,
938
+ letterSpacing?: number,
939
+ lines?: readonly string[]
463
940
  ): d3Selection.Selection<SVGTextElement, unknown, null, undefined> {
464
941
  const t = g
465
942
  .append('text')
@@ -467,14 +944,36 @@ function emitText(
467
944
  .attr('y', y)
468
945
  .attr('text-anchor', anchor)
469
946
  .attr('font-size', fontSize)
470
- .attr('fill', color)
471
- .text(text);
947
+ .attr('fill', color);
948
+ // Multi-line context labels (water names): stack centred tspans around `y` so
949
+ // the block's vertical centre matches the placement rect. Single-line keeps the
950
+ // bare `.text()` form so every other call site stays byte-identical.
951
+ if (lines && lines.length > 1) {
952
+ const lineHeight = fontSize + 2; // MUST match context-labels LINE_HEIGHT
953
+ const startDy = -((lines.length - 1) / 2) * lineHeight;
954
+ lines.forEach((ln, i) => {
955
+ t.append('tspan')
956
+ .attr('x', x)
957
+ .attr('dy', i === 0 ? startDy : lineHeight)
958
+ .text(ln);
959
+ });
960
+ } else {
961
+ t.text(text);
962
+ }
963
+ // Cartographic styling for context labels; absent on every other call site so
964
+ // existing output stays byte-identical (only emitted when explicitly set).
965
+ if (italic) t.attr('font-style', 'italic');
966
+ if (letterSpacing) t.attr('letter-spacing', letterSpacing);
472
967
  if (withHalo) {
968
+ // Thin, even outline (2px / 1px-per-side at the 11px label font — 3px read
969
+ // top-heavy as adjacent glyph tops merged their strokes). Round join + cap
970
+ // keep the edge uniform around every glyph.
473
971
  t.attr('paint-order', 'stroke fill')
474
972
  .attr('stroke', halo)
475
- .attr('stroke-width', 3)
973
+ .attr('stroke-width', 2)
476
974
  .attr('stroke-linejoin', 'round')
477
- .attr('stroke-opacity', 0.7);
975
+ .attr('stroke-linecap', 'round')
976
+ .attr('stroke-opacity', 0.55);
478
977
  }
479
978
  return t;
480
979
  }