@diagrammo/dgmo 0.25.5 → 0.27.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 (139) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4255 -2756
  3. package/dist/advanced.d.cts +285 -59
  4. package/dist/advanced.d.ts +285 -59
  5. package/dist/advanced.js +4253 -2750
  6. package/dist/auto.cjs +4051 -2589
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4051 -2589
  9. package/dist/cli.cjs +172 -170
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +4076 -2591
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4076 -2591
  18. package/dist/internal.cjs +4255 -2756
  19. package/dist/internal.d.cts +285 -59
  20. package/dist/internal.d.ts +285 -59
  21. package/dist/internal.js +4253 -2750
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +1 -1
  33. package/src/advanced.ts +3 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -1
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion-types.ts +0 -1
  48. package/src/completion.ts +106 -51
  49. package/src/cycle/layout.ts +55 -72
  50. package/src/cycle/renderer.ts +11 -6
  51. package/src/d3.ts +78 -117
  52. package/src/diagnostics.ts +16 -0
  53. package/src/echarts.ts +46 -33
  54. package/src/editor/keywords.ts +4 -0
  55. package/src/er/layout.ts +114 -22
  56. package/src/er/parser.ts +28 -1
  57. package/src/er/renderer.ts +55 -2
  58. package/src/er/types.ts +3 -0
  59. package/src/gantt/renderer.ts +46 -38
  60. package/src/gantt/resolver.ts +9 -2
  61. package/src/graph/edge-spline.ts +29 -0
  62. package/src/graph/flowchart-parser.ts +35 -2
  63. package/src/graph/flowchart-renderer.ts +80 -52
  64. package/src/graph/layout.ts +206 -23
  65. package/src/graph/notes.ts +21 -0
  66. package/src/graph/state-parser.ts +26 -1
  67. package/src/graph/state-renderer.ts +80 -52
  68. package/src/graph/types.ts +13 -0
  69. package/src/index.ts +1 -1
  70. package/src/infra/layout.ts +46 -26
  71. package/src/infra/parser.ts +1 -1
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +57 -12
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1196 -90
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/parser.ts +0 -14
  101. package/src/pert/renderer.ts +30 -43
  102. package/src/pyramid/renderer.ts +4 -5
  103. package/src/raci/renderer.ts +42 -70
  104. package/src/render.ts +1 -1
  105. package/src/ring/renderer.ts +1 -2
  106. package/src/sequence/parser.ts +100 -22
  107. package/src/sequence/renderer.ts +75 -50
  108. package/src/sitemap/layout.ts +27 -19
  109. package/src/sitemap/renderer.ts +12 -5
  110. package/src/tech-radar/renderer.ts +11 -35
  111. package/src/utils/arrow-markers.ts +51 -0
  112. package/src/utils/fit-canvas.ts +64 -0
  113. package/src/utils/legend-constants.ts +8 -54
  114. package/src/utils/legend-d3.ts +10 -7
  115. package/src/utils/legend-layout.ts +7 -4
  116. package/src/utils/legend-types.ts +10 -4
  117. package/src/utils/note-box/constants.ts +25 -0
  118. package/src/utils/note-box/index.ts +11 -0
  119. package/src/utils/note-box/metrics.ts +90 -0
  120. package/src/utils/note-box/svg.ts +331 -0
  121. package/src/utils/notes/bounds.ts +30 -0
  122. package/src/utils/notes/build.ts +131 -0
  123. package/src/utils/notes/index.ts +18 -0
  124. package/src/utils/notes/model.ts +19 -0
  125. package/src/utils/notes/parse.ts +131 -0
  126. package/src/utils/notes/place.ts +177 -0
  127. package/src/utils/notes/resolve.ts +88 -0
  128. package/src/utils/number-format.ts +36 -0
  129. package/src/utils/parsing.ts +41 -0
  130. package/src/utils/reserved-key-registry.ts +4 -0
  131. package/src/utils/text-measure.ts +122 -0
  132. package/src/wireframe/layout.ts +4 -2
  133. package/src/wireframe/renderer.ts +8 -6
  134. package/src/palettes/dracula.ts +0 -68
  135. package/src/palettes/gruvbox.ts +0 -85
  136. package/src/palettes/monokai.ts +0 -68
  137. package/src/palettes/one-dark.ts +0 -70
  138. package/src/palettes/rose-pine.ts +0 -84
  139. package/src/palettes/solarized.ts +0 -77
@@ -12,6 +12,7 @@ import {
12
12
  TITLE_Y,
13
13
  } from '../utils/title-constants';
14
14
  import { mix } from '../palettes/color-utils';
15
+ import { measureText } from '../utils/text-measure';
15
16
  import { renderLegendD3 } from '../utils/legend-d3';
16
17
  import type { LegendConfig, LegendState } from '../utils/legend-types';
17
18
  import { mapLegendConfig, mapLegendGroups } from './legend-band';
@@ -185,22 +186,32 @@ function coastlineOuterRings(
185
186
  * the accepted cross-region overdraw artifact behave exactly as before. */
186
187
  function appendWaterLines(
187
188
  g: Sel,
189
+ defs: Sel,
190
+ coastId: string,
188
191
  outerRings: readonly string[],
189
192
  style: MapLayoutCoastlineStyle,
190
193
  flatWater: string
191
194
  ): void {
192
195
  const d = outerRings.join(' ');
196
+ if (!d) return;
197
+ // The coast geometry is byte-for-byte identical across every line-level and
198
+ // both passes (only stroke colour/width differ). On a world map that compound
199
+ // path is ~200KB, so emitting it ~12× inline ballooned the SVG to multi-MB and
200
+ // overflowed the SSG HTML reparse. Define it once and `<use>` it: same pixels,
201
+ // a fraction of the bytes.
202
+ defs.append('path').attr('id', coastId).attr('d', d).attr('fill', 'none');
203
+ const ref = `#${coastId}`;
193
204
  const linesOuterFirst = [...style.lines].sort((a, b) => b.d - a.d);
194
205
  for (const line of linesOuterFirst) {
195
- g.append('path')
196
- .attr('d', d)
206
+ g.append('use')
207
+ .attr('href', ref)
197
208
  .attr('stroke', style.color)
198
209
  .attr('stroke-width', 2 * (line.d + line.thickness))
199
210
  .attr('stroke-opacity', line.opacity)
200
211
  .attr('stroke-linejoin', 'round')
201
212
  .attr('stroke-linecap', 'round');
202
- g.append('path')
203
- .attr('d', d)
213
+ g.append('use')
214
+ .attr('href', ref)
204
215
  .attr('stroke', flatWater)
205
216
  .attr('stroke-width', 2 * line.d)
206
217
  .attr('stroke-linejoin', 'round')
@@ -460,6 +471,8 @@ export function renderMap(
460
471
  .attr('mask', `url(#${maskId})`);
461
472
  appendWaterLines(
462
473
  gWater,
474
+ defs,
475
+ 'dgmo-map-coast',
463
476
  // Pass the canvas frame so edges collinear with it (the antimeridian on a
464
477
  // world map, regional clipExtent cuts) don't get ringed as fake coast —
465
478
  // land runs cleanly to the render-area edge.
@@ -513,6 +526,193 @@ export function renderMap(
513
526
  }
514
527
  }
515
528
 
529
+ // ── City dots — a faint gazetteer scatter for geographic orientation, over
530
+ // the basemap but under connectors/POIs/labels. Decorative (no pointer events,
531
+ // no data attributes); muted ink at low opacity so it reads as texture, not
532
+ // data. Suppressed entirely by the `no-cities` directive (empty array). ──
533
+ if (layout.cityDots.length) {
534
+ const gCities = svg
535
+ .append('g')
536
+ .attr('class', 'dgmo-map-cities')
537
+ .attr('fill', palette.textMuted)
538
+ .attr('fill-opacity', 0.28)
539
+ .style('pointer-events', 'none');
540
+ for (const c of layout.cityDots) {
541
+ gCities.append('circle').attr('cx', c.cx).attr('cy', c.cy).attr('r', c.r);
542
+ }
543
+ }
544
+
545
+ // ── Label clarity patch — repaint the clean region FILL under each POI label
546
+ // so the basemap line work beneath it (region borders, relief hachure, coast +
547
+ // river lines) dissolves where it would clutter the text. Crucially this is NOT
548
+ // a halo: the patch IS the same fill that already surrounds the label, so over
549
+ // plain land it is invisible and ONLY the crossing lines disappear — no white
550
+ // pad over flat colour. A blurred-blob mask feathers each patch into the map
551
+ // (soft edges, no hard rectangle). Sits above all basemap line work, below the
552
+ // dots / legs / labels. POI labels only; context/water labels keep their lines
553
+ // (they read as cartographic texture, not clutter).
554
+ //
555
+ // Collapsed cluster members are HIDDEN at rest, so their labels aren't on screen
556
+ // — patching under them would fade the basemap beneath an invisible label. So
557
+ // each cluster's member patches go in a per-cluster group tagged
558
+ // `data-cluster-deco`, which the app's spiderfy controller toggles in lockstep
559
+ // with the legs / member dots: the fade only appears once the fan is expanded.
560
+ // Non-clustered POI labels (and ALL labels in export / `no-cluster-pois`, where
561
+ // the fan is permanently open) keep the always-on patch so the static image
562
+ // matches what's on screen.
563
+ const clusterUi = !exportDims && !resolved.directives.noClusterPois;
564
+ const poiLabels = layout.labels.filter(
565
+ (l) => l.poiId !== undefined && !l.hidden
566
+ );
567
+ if (poiLabels.length) {
568
+ const patchBlurId = 'dgmo-map-label-patch-blur';
569
+ // Soft falloff so the patch dissolves into the surrounding basemap instead of
570
+ // ending on a hard edge. Tuned at the 11px label font. One shared filter for
571
+ // every patch group.
572
+ defs
573
+ .append('filter')
574
+ .attr('id', patchBlurId)
575
+ .attr('x', '-50%')
576
+ .attr('y', '-50%')
577
+ .attr('width', '200%')
578
+ .attr('height', '200%')
579
+ .append('feGaussianBlur')
580
+ .attr('in', 'SourceGraphic')
581
+ .attr('stdDeviation', 2.5);
582
+ const PAD = 8; // ≳ blur reach so culled regions can't lose a fringe
583
+
584
+ // Build one patch (mask of blurred blobs + the region fills they cross) for a
585
+ // set of labels. `decoCluster` tags the group so the app toggles it with the
586
+ // rest of the cluster's spiderfy decoration.
587
+ const buildPatch = (
588
+ labels: typeof poiLabels,
589
+ maskId: string,
590
+ decoCluster?: string
591
+ ): void => {
592
+ if (!labels.length) return;
593
+ const patchMask = defs
594
+ .append('mask')
595
+ .attr('id', maskId)
596
+ // userSpaceOnUse: the patch group's bbox is the whole basemap, but keep
597
+ // the mask region pinned to the canvas (parity with the water masks).
598
+ .attr('maskUnits', 'userSpaceOnUse')
599
+ .attr('x', 0)
600
+ .attr('y', 0)
601
+ .attr('width', width)
602
+ .attr('height', height);
603
+ // White blobs = where the patch shows; the surrounding black hides it. Each
604
+ // blob is a SOLID rounded rect sized to the text bbox so the core fully
605
+ // hides the line work behind the glyphs; the blur only feathers the outer
606
+ // edge. Solid core → if an engine drops the in-mask blur (some WebKit
607
+ // builds), it degrades to a clean stadium patch, never a halo.
608
+ const blobs = patchMask
609
+ .append('g')
610
+ .attr('filter', `url(#${patchBlurId})`);
611
+ // Padded rects (incl. the blur reach) drive both the mask AND the region
612
+ // cull below, so the two never disagree.
613
+ const blobRects: Array<{
614
+ x0: number;
615
+ y0: number;
616
+ x1: number;
617
+ y1: number;
618
+ }> = [];
619
+ for (const l of labels) {
620
+ const multi = l.lines && l.lines.length > 1;
621
+ const tw = multi
622
+ ? Math.max(...l.lines!.map((s) => measureText(s, LABEL_FONT)))
623
+ : measureText(l.text, LABEL_FONT);
624
+ const th = multi ? l.lines!.length * (LABEL_FONT + 2) : LABEL_FONT;
625
+ // Anchor → blob centre x; cy nudged up from the text baseline to the
626
+ // glyph visual centre.
627
+ const cx =
628
+ l.anchor === 'start'
629
+ ? l.x + tw / 2
630
+ : l.anchor === 'end'
631
+ ? l.x - tw / 2
632
+ : l.x;
633
+ const cy = l.y - LABEL_FONT * 0.3;
634
+ const w = tw + 8;
635
+ const h = th + 6;
636
+ blobs
637
+ .append('rect')
638
+ .attr('x', cx - w / 2)
639
+ .attr('y', cy - h / 2)
640
+ .attr('width', w)
641
+ .attr('height', h)
642
+ .attr('rx', h / 2)
643
+ .attr('fill', 'white');
644
+ blobRects.push({
645
+ x0: cx - w / 2 - PAD,
646
+ y0: cy - h / 2 - PAD,
647
+ x1: cx + w / 2 + PAD,
648
+ y1: cy + h / 2 + PAD,
649
+ });
650
+ }
651
+ const gPatch = svg
652
+ .append('g')
653
+ .attr('class', 'dgmo-map-label-patch')
654
+ .attr('mask', `url(#${maskId})`)
655
+ // Decorative cover — never a pointer target so region hover / name-on-
656
+ // hover reaches the real region paths beneath (WebKit hit-tests masked
657
+ // content).
658
+ .style('pointer-events', 'none');
659
+ // Per-cluster patch: tag as cluster decoration so the spiderfy controller
660
+ // shows/hides it in step with the fan. (The app collapses all `[data-
661
+ // cluster-deco]` to opacity 0 at rest, pre-paint, so there's no flash.)
662
+ if (decoCluster !== undefined)
663
+ gPatch.attr('data-cluster-deco', decoCluster);
664
+ // Redraw fills only (no stroke), in document order, but ONLY for regions
665
+ // whose bbox overlaps a label blob — the mask already hides everything
666
+ // else, so emitting the rest is pure SVG weight (matters on dense world
667
+ // maps). The kept regions reproduce the exact composite colour under each
668
+ // label, so the patch is seamless and the only visible change is the
669
+ // vanished line work.
670
+ for (const r of layout.regions) {
671
+ let minX = Infinity,
672
+ minY = Infinity,
673
+ maxX = -Infinity,
674
+ maxY = -Infinity;
675
+ for (const ring of parsePathRings(r.d))
676
+ for (const [px, py] of ring) {
677
+ if (px < minX) minX = px;
678
+ if (px > maxX) maxX = px;
679
+ if (py < minY) minY = py;
680
+ if (py > maxY) maxY = py;
681
+ }
682
+ const hit = blobRects.some(
683
+ (b) => minX <= b.x1 && maxX >= b.x0 && minY <= b.y1 && maxY >= b.y0
684
+ );
685
+ if (hit) gPatch.append('path').attr('d', r.d).attr('fill', r.fill);
686
+ }
687
+ };
688
+
689
+ if (clusterUi) {
690
+ // Always-on patch for labels that are visible at rest (non-cluster members).
691
+ buildPatch(
692
+ poiLabels.filter((l) => l.clusterMember === undefined),
693
+ 'dgmo-map-label-patch'
694
+ );
695
+ // Per-cluster patches, hidden until the fan expands.
696
+ const byCluster = new Map<string, typeof poiLabels>();
697
+ for (const l of poiLabels) {
698
+ if (l.clusterMember === undefined) continue;
699
+ const arr = byCluster.get(l.clusterMember);
700
+ if (arr) arr.push(l);
701
+ else byCluster.set(l.clusterMember, [l]);
702
+ }
703
+ // Safe mask id from the iteration index — cluster ids can be coord-based
704
+ // (`@lat,lon`) and aren't `url(#…)`-safe; the raw id rides on the
705
+ // data-cluster-deco attribute instead.
706
+ let ci = 0;
707
+ for (const [cid, labs] of byCluster)
708
+ buildPatch(labs, `dgmo-map-label-patch-c${ci++}`, cid);
709
+ } else {
710
+ // Export / `no-cluster-pois`: fan is permanently open → one always-on patch
711
+ // over every POI label.
712
+ buildPatch(poiLabels, 'dgmo-map-label-patch');
713
+ }
714
+ }
715
+
516
716
  // ── AK / HI insets (albers-usa) — drawn in the FOREGROUND so the opaque ocean
517
717
  // box hides the main-map neighbour land (Mexico's Baja) behind it; the state
518
718
  // then draws on top, framed by the box border. ──
@@ -560,11 +760,23 @@ export function renderMap(
560
760
  .attr('y', 0)
561
761
  .attr('width', width)
562
762
  .attr('height', height);
763
+ // White reveal = the box interior, but eroded inward by `reach` (a black
764
+ // border stroke) so the seaward rings fade out before the frame instead of
765
+ // being hard-clipped at it — clipped ring fragments otherwise read as
766
+ // artifacts bumping into the crisp 1px border. Mirrors the main-map moat,
767
+ // applied inward.
768
+ const reach = Math.max(0, ...cs.lines.map((l) => l.d + l.thickness));
563
769
  for (const box of layout.insets) {
564
770
  const d =
565
771
  box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
566
772
  'Z';
567
- mask.append('path').attr('d', d).attr('fill', 'white');
773
+ mask
774
+ .append('path')
775
+ .attr('d', d)
776
+ .attr('fill', 'white')
777
+ .attr('stroke', 'black')
778
+ .attr('stroke-width', 2 * reach)
779
+ .attr('stroke-linejoin', 'round');
568
780
  }
569
781
  // Neighbour land masks as land too — clipped to its box so it can't darken
570
782
  // an adjacent inset — keeping the AK/Canada land border free of rings.
@@ -607,6 +819,8 @@ export function renderMap(
607
819
  .attr('mask', `url(#${maskId})`);
608
820
  appendWaterLines(
609
821
  gInsetWater,
822
+ defs,
823
+ 'dgmo-map-inset-coast',
610
824
  coastlineOuterRings(layout.insetRegions, cs.minExtent),
611
825
  cs,
612
826
  layout.background
@@ -620,6 +834,25 @@ export function renderMap(
620
834
  .attr('stroke-width', 0.5)
621
835
  .attr('stroke-linejoin', 'round');
622
836
  }
837
+
838
+ // Re-stroke each inset frame ON TOP (stroke-only, no fill) as the final
839
+ // inset layer — the early border path sits under the contextLand fill,
840
+ // region fills, and water-lines, so its inner half gets covered near the
841
+ // top/right edges and the frame reads as thin/fuzzy. This overlay keeps a
842
+ // crisp full-width border on every side.
843
+ for (const box of layout.insets) {
844
+ const d =
845
+ box.points.map((p, i) => `${i ? 'L' : 'M'}${p[0]},${p[1]}`).join('') +
846
+ 'Z';
847
+ insetG
848
+ .append('path')
849
+ .attr('d', d)
850
+ .attr('fill', 'none')
851
+ .attr('stroke', mix(palette.text, palette.bg, 55))
852
+ .attr('stroke-width', 1)
853
+ .attr('stroke-linejoin', 'round')
854
+ .style('pointer-events', 'none');
855
+ }
623
856
  }
624
857
 
625
858
  // Code↔diagram sync: tag a synced element with its 1-based source line and,
@@ -647,7 +880,17 @@ export function renderMap(
647
880
  .attr('d', leg.d)
648
881
  .attr('stroke', leg.color)
649
882
  .attr('stroke-width', leg.width)
650
- .attr('stroke-linecap', 'round');
883
+ .attr('stroke-linecap', 'round')
884
+ // Endpoint POI ids so a focused leg can co-highlight its two POIs (§17 sync).
885
+ .attr('data-from-id', leg.fromId)
886
+ .attr('data-to-id', leg.toId);
887
+ // Tag the line per tag value (lowercased, matching the legend-entry attrs) so
888
+ // a legend hover spotlights only the matching lines (§24B.6) — mirrors POIs.
889
+ if (leg.tags) {
890
+ for (const [group, value] of Object.entries(leg.tags)) {
891
+ p.attr(`data-tag-${group.toLowerCase()}`, value.toLowerCase());
892
+ }
893
+ }
651
894
  // A 0-width invisible leg path is hard to hit; pointer-events on the visible
652
895
  // stroke is enough for the line widths legs use.
653
896
  wireSync(p, leg.lineNumber);
@@ -698,7 +941,6 @@ export function renderMap(
698
941
  // the legs/hub/members visible but emit NO hit-area + NO badge, so there is
699
942
  // nothing for the app's spiderfy controller to collapse — the map reads the
700
943
  // same on screen as on paper.
701
- const clusterUi = !exportDims && !resolved.directives.noClusterPois;
702
944
  const gSpider = svg.append('g').attr('class', 'dgmo-map-spider');
703
945
  for (const cl of layout.clusters) {
704
946
  // Pointer hit-area — bottom of the stack so member dots still take their own
@@ -755,6 +997,7 @@ export function renderMap(
755
997
  .attr('cy', poi.cy)
756
998
  .attr('r', poi.r)
757
999
  .attr('fill', poi.fill)
1000
+ .attr('fill-opacity', poi.fillOpacity)
758
1001
  .attr('stroke', poi.stroke)
759
1002
  .attr('stroke-width', 1)
760
1003
  .attr('data-line-number', poi.lineNumber)
@@ -807,7 +1050,7 @@ export function renderMap(
807
1050
  lab.color,
808
1051
  lab.haloColor,
809
1052
  lab.halo,
810
- LABEL_FONT,
1053
+ lab.fontSize ?? LABEL_FONT,
811
1054
  lab.italic,
812
1055
  lab.letterSpacing
813
1056
  )
@@ -836,6 +1079,18 @@ export function renderMap(
836
1079
  line.attr('data-cluster-member', lab.clusterMember);
837
1080
  wireSync(line, lab.lineNumber);
838
1081
  }
1082
+ // Region callout anchor dot: a small disc at the (too-small) region's true
1083
+ // centroid, in the region's fill, so the leader visibly originates on the map.
1084
+ if (lab.calloutDot) {
1085
+ gLabels
1086
+ .append('circle')
1087
+ .attr('cx', lab.calloutDot.x)
1088
+ .attr('cy', lab.calloutDot.y)
1089
+ .attr('r', 2.5)
1090
+ .attr('fill', lab.calloutDot.color)
1091
+ .attr('stroke', mix(palette.text, palette.bg, 55))
1092
+ .attr('stroke-width', 0.75);
1093
+ }
839
1094
  const t = emitText(
840
1095
  gLabels,
841
1096
  lab.x,
@@ -845,10 +1100,11 @@ export function renderMap(
845
1100
  lab.color,
846
1101
  lab.haloColor,
847
1102
  lab.halo,
848
- LABEL_FONT,
1103
+ lab.fontSize ?? LABEL_FONT,
849
1104
  lab.italic,
850
1105
  lab.letterSpacing,
851
- lab.lines
1106
+ lab.lines,
1107
+ lab.valueLine
852
1108
  );
853
1109
  // POI labels are spotlightable: tag with the POI id and make the text the
854
1110
  // hover target (the app dims the other dots/labels on enter).
@@ -955,7 +1211,7 @@ export function renderMap(
955
1211
  // ── Title / subtitle / caption (foreground — drawn last so they sit above the
956
1212
  // basemap, POIs, and labels; layout reserves top padding so POIs clear them) ──
957
1213
  // Soft bg halo so the banner stays legible over busy land/water (the muted
958
- // subtitle/caption otherwise wash out on mid-toned palettes like gruvbox).
1214
+ // subtitle/caption otherwise wash out on mid-toned palettes like nord).
959
1215
  if (layout.title) {
960
1216
  svg
961
1217
  .append('text')
@@ -1032,7 +1288,8 @@ function emitText(
1032
1288
  fontSize: number,
1033
1289
  italic?: boolean,
1034
1290
  letterSpacing?: number,
1035
- lines?: readonly string[]
1291
+ lines?: readonly string[],
1292
+ valueLine?: string
1036
1293
  ): d3Selection.Selection<SVGTextElement, unknown, null, undefined> {
1037
1294
  const t = g
1038
1295
  .append('text')
@@ -1053,6 +1310,21 @@ function emitText(
1053
1310
  .attr('dy', i === 0 ? startDy : lineHeight)
1054
1311
  .text(ln);
1055
1312
  });
1313
+ } else if (valueLine !== undefined) {
1314
+ // A choropleth region's name over its metric value: name on top, value below
1315
+ // in a smaller, dimmer line (same fill, reduced opacity). Raise the block so
1316
+ // its centre sits on the placement `y`. The halo (set below) wraps both lines.
1317
+ const valueSize = Math.round(fontSize * 0.82);
1318
+ t.append('tspan')
1319
+ .attr('x', x)
1320
+ .attr('dy', -fontSize * 0.28)
1321
+ .text(text);
1322
+ t.append('tspan')
1323
+ .attr('x', x)
1324
+ .attr('dy', fontSize * 0.92)
1325
+ .attr('font-size', valueSize)
1326
+ .attr('fill-opacity', 0.72)
1327
+ .text(valueLine);
1056
1328
  } else {
1057
1329
  t.text(text);
1058
1330
  }
@@ -4,7 +4,12 @@
4
4
  import type { DgmoError } from '../diagnostics';
5
5
  import type { TagGroup } from '../utils/tag-groups';
6
6
  import type { MapDirectives } from './types';
7
- import type { Gazetteer, BoundaryTopology, WaterBodies } from './data/types';
7
+ import type {
8
+ Gazetteer,
9
+ BoundaryTopology,
10
+ WaterBodies,
11
+ AirportData,
12
+ } from './data/types';
8
13
 
9
14
  /** The four static assets, injected into the pure resolver (DI). */
10
15
  export interface MapData {
@@ -32,6 +37,10 @@ export interface MapData {
32
37
  * `context-labels` directive is on — oceans/seas/gulfs/bays/etc. Optional, so
33
38
  * hand-built test fixtures and older bundles need not supply it. */
34
39
  waterBodies?: WaterBodies;
40
+ /** IATA-coded airports (`airports.json`) — lets `poi JFK` / `route JFK -> LAX`
41
+ * resolve. Optional so hand-built fixtures and older DI bundles need not supply
42
+ * it; the resolver guards `data.airports?.…` everywhere. */
43
+ airports?: AirportData;
35
44
  gazetteer: Gazetteer;
36
45
  }
37
46
 
@@ -40,6 +49,7 @@ export type ProjectionFamily =
40
49
  | 'natural-earth'
41
50
  | 'equirectangular'
42
51
  | 'albers-usa'
52
+ | 'conic-equal-area'
43
53
  | 'mercator';
44
54
 
45
55
  /** Which geometry layers the renderer draws. */
@@ -89,6 +99,8 @@ export interface ResolvedEdge {
89
99
  readonly directed: boolean;
90
100
  readonly style: 'straight' | 'arc';
91
101
  readonly meta: Readonly<Record<string, string>>;
102
+ /** Tag(s) on the edge line → colour the LINE (§24B.6). */
103
+ readonly tags: Readonly<Record<string, string>>;
92
104
  readonly lineNumber: number;
93
105
  }
94
106
 
@@ -98,6 +110,8 @@ export interface ResolvedRouteLeg {
98
110
  readonly label?: string;
99
111
  readonly style: 'straight' | 'arc';
100
112
  readonly value?: string; // leg thickness
113
+ /** Tag(s) on the leg line → colour the LINE (§24B.6). */
114
+ readonly tags: Readonly<Record<string, string>>;
101
115
  readonly lineNumber: number;
102
116
  }
103
117