@diagrammo/dgmo 0.22.0 → 0.24.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 (51) hide show
  1. package/dist/advanced.cjs +372 -103
  2. package/dist/advanced.d.cts +52 -19
  3. package/dist/advanced.d.ts +52 -19
  4. package/dist/advanced.js +372 -103
  5. package/dist/auto.cjs +370 -97
  6. package/dist/auto.js +117 -117
  7. package/dist/auto.mjs +370 -97
  8. package/dist/cli.cjs +151 -151
  9. package/dist/editor.cjs +3 -0
  10. package/dist/editor.js +3 -0
  11. package/dist/highlight.cjs +3 -0
  12. package/dist/highlight.js +3 -0
  13. package/dist/index.cjs +498 -96
  14. package/dist/index.d.cts +37 -1
  15. package/dist/index.d.ts +37 -1
  16. package/dist/index.js +496 -96
  17. package/dist/internal.cjs +372 -103
  18. package/dist/internal.d.cts +52 -19
  19. package/dist/internal.d.ts +52 -19
  20. package/dist/internal.js +372 -103
  21. package/dist/map-data/PROVENANCE.json +1 -1
  22. package/dist/map-data/gazetteer.json +1 -1
  23. package/dist/map-data/mountain-ranges.json +1 -1
  24. package/dist/map-data/water-bodies.json +1 -1
  25. package/dist/map-data/world-coarse.json +1 -1
  26. package/dist/map-data/world-detail.json +1 -1
  27. package/docs/language-reference.md +38 -2
  28. package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
  29. package/package.json +1 -1
  30. package/src/boxes-and-lines/parser.ts +39 -0
  31. package/src/boxes-and-lines/renderer.ts +219 -14
  32. package/src/boxes-and-lines/types.ts +9 -0
  33. package/src/completion.ts +4 -5
  34. package/src/d3.ts +26 -6
  35. package/src/editor/keywords.ts +3 -0
  36. package/src/index.ts +8 -0
  37. package/src/map/data/PROVENANCE.json +1 -1
  38. package/src/map/data/README.md +6 -0
  39. package/src/map/data/gazetteer.json +1 -1
  40. package/src/map/data/mountain-ranges.json +1 -1
  41. package/src/map/data/water-bodies.json +1 -1
  42. package/src/map/data/world-coarse.json +1 -1
  43. package/src/map/data/world-detail.json +1 -1
  44. package/src/map/dimensions.ts +21 -5
  45. package/src/map/layout.ts +167 -63
  46. package/src/map/legend-band.ts +99 -0
  47. package/src/map/renderer.ts +105 -32
  48. package/src/map/resolver.ts +43 -1
  49. package/src/map/types.ts +20 -0
  50. package/src/utils/reserved-key-registry.ts +5 -3
  51. package/src/utils/svg-embed.ts +193 -0
@@ -83,10 +83,24 @@ export interface MapExportDimensions {
83
83
  export function mapExportDimensions(
84
84
  resolved: ResolvedMap,
85
85
  data: MapData,
86
- baseWidth = 1200
86
+ baseWidth = 1200,
87
+ /** WYSIWYG override (app export): the live preview pane's displayed aspect
88
+ * (width / height). When provided, the canvas adopts it verbatim and
89
+ * stretch-fills (no clamp, no contain) so the PNG matches exactly what's on
90
+ * screen. Omitted by every headless consumer (CLI / MCP / SSG / Obsidian),
91
+ * which keep the intrinsic-aspect sizing below. */
92
+ aspectOverride?: number
87
93
  ): MapExportDimensions {
88
- const raw = mapContentAspect(resolved, data);
89
- const clamped = Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
94
+ const useOverride =
95
+ aspectOverride !== undefined &&
96
+ Number.isFinite(aspectOverride) &&
97
+ aspectOverride > 0;
98
+ const raw = useOverride ? aspectOverride : mapContentAspect(resolved, data);
99
+ // The override is the user's on-screen aspect — honour it as-is (no clamp);
100
+ // only the intrinsic path guards against pathological extents.
101
+ const clamped = useOverride
102
+ ? raw
103
+ : Math.max(ASPECT_MIN, Math.min(ASPECT_MAX, raw));
90
104
  const width = baseWidth;
91
105
  let height = Math.round(width / clamped);
92
106
 
@@ -111,7 +125,9 @@ export function mapExportDimensions(
111
125
  }
112
126
 
113
127
  // The canvas was forced off the content aspect ⇒ tell the renderer to
114
- // contain-fit (letterbox) rather than stretch-distort.
115
- const preferContain = clamped !== raw || floored;
128
+ // contain-fit (letterbox) rather than stretch-distort. The WYSIWYG override is
129
+ // exempt: it stretch-fills (mirroring the preview pane) unless the MIN_MAP_BAND
130
+ // floor had to grow the canvas off-aspect.
131
+ const preferContain = useOverride ? floored : clamped !== raw || floored;
116
132
  return { width, height, preferContain };
117
133
  }
package/src/map/layout.ts CHANGED
@@ -36,6 +36,9 @@ import {
36
36
  import type { LabelRect, PointCircle } from '../label-layout';
37
37
  import { measureLegendText } from '../utils/legend-constants';
38
38
  import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
39
+ import type { LegendMode } from '../utils/legend-types';
40
+ import { mapLegendBand } from './legend-band';
41
+ import type { MapLayoutLegend } from './types';
39
42
  import type { DgmoError } from '../diagnostics';
40
43
  import type { BoundaryTopology } from './data/types';
41
44
  import type {
@@ -69,6 +72,16 @@ const R_MAX = 22;
69
72
  const W_MIN = 1.25; // edge stroke width
70
73
  const W_MAX = 8;
71
74
  const FONT = 11; // on-map label font px
75
+
76
+ // A few countries have far-flung territory that drags the area-weighted centroid
77
+ // off the mainland (US → Alaska pulls it up into Canada). Anchor their world-layer
78
+ // label/hover point to a mainland [lon, lat] instead. Antimeridian crossers whose
79
+ // body dominates by area (Russia) are NOT listed — their area-weighted centroid
80
+ // already lands on the mainland; only the naive bounding-box centre (which the app
81
+ // previously used for hover) mistook the wrapped sliver for half the shape.
82
+ const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
83
+ US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
84
+ };
72
85
  // POI-cluster hover-only gate (Decision #1). A ≥2-member cluster's callout
73
86
  // column falls back to hover-only labels when it would sprawl or overflow:
74
87
  // - MAX_CLUSTER_EXTENT_FACTOR × min(width,height) = the px diagonal beyond which
@@ -112,16 +125,21 @@ const RELIEF_MIN_DIM = 2; // px
112
125
  // Relief = horizontal hachure lines clipped to each range: a subtle
113
126
  // dark-on-light / light-on-dark texture that reads as "mountains here". Spacing
114
127
  // is SCREEN-space so density is constant regardless of zoom (geo-space spacing
115
- // would collapse a small range to 1–2 lines and read as a glitch). Kept FAINT:
116
- // thin sub-pixel lines drawn with a non-scaling stroke (constant device width at
117
- // any zoom/DPR) and low-contrast colour. NOT crispEdgesthat snaps the stroke
118
- // to a solid ~1px in WebKit and reads far too heavy; plain AA keeps them whisper-thin.
119
- const RELIEF_HATCH_SPACING = 2; // px between lines
120
- const RELIEF_HATCH_WIDTH = 0.15; // px stroke
128
+ // would collapse a small range to 1–2 lines and read as a glitch). Drawn with a
129
+ // non-scaling stroke (constant device width at any zoom/DPR) and a low-contrast
130
+ // colour so it reads as faint, fine terrain hachure dense thin lines that are
131
+ // almost indistinguishable as individual strokes (a whisper of texture, not
132
+ // stripes). NOT crispEdges that snaps the stroke to a solid ~1px in WebKit and
133
+ // reads too heavy; plain AA keeps the lines soft. The width is kept just ABOVE
134
+ // sub-pixel: at ~0.15px the AA fuzz spreads each line to ~1px and tight spacing
135
+ // merges them into a flat grey wash (a "blob"). 0.25px every 1.5px stays a fine,
136
+ // faint hatch on both zoomed-out world maps and zoomed-in regional views.
137
+ const RELIEF_HATCH_SPACING = 1.5; // px between lines
138
+ const RELIEF_HATCH_WIDTH = 0.2; // px stroke
121
139
  // % of the DARK reference (palette.bg on dark themes, palette.text on light)
122
140
  // blended into the land colour — so the lines read DARKER than the land in both
123
141
  // themes (palette.text alone flips to light on dark themes).
124
- const RELIEF_HATCH_STRENGTH = 32;
142
+ const RELIEF_HATCH_STRENGTH = 26;
125
143
  // Coastline water-lines (opt-in `coastline`, §24B.2). N equal-width coast-parallel
126
144
  // rings on the water side, evenly spaced and FADING seaward — the antique
127
145
  // nautical-chart depth-contour look. Offshore distances + thickness are
@@ -191,6 +209,14 @@ export interface MapLayoutRegion {
191
209
  /** The region's tag values keyed by group (lowercased) — emitted as
192
210
  * `data-tag-<group>` so the app can highlight on legend-entry hover. */
193
211
  readonly tags?: Readonly<Record<string, string>>;
212
+ /** Area-weighted screen centroid (px) of the DRAWN geometry — emitted as
213
+ * `data-label-x`/`data-label-y` so the app can anchor the hover label here
214
+ * instead of the path's bounding-box centre. The bbox centre breaks for
215
+ * antimeridian crossers (Russia's wrapped Chukotka sliver pins the box's left
216
+ * edge to the far side of the map, dropping the centre into the Atlantic); the
217
+ * area-weighted centroid stays on the body. Honours WORLD_LABEL_ANCHORS. */
218
+ readonly labelX?: number;
219
+ readonly labelY?: number;
194
220
  }
195
221
 
196
222
  /** A framed inset "cutout" (albers-usa AK/HI), in screen px. The frame is a
@@ -340,21 +366,11 @@ export interface PlacedLabel {
340
366
  readonly lineNumber: number;
341
367
  }
342
368
 
343
- export interface MapLayoutLegend {
344
- readonly tagGroups: ReadonlyArray<{
345
- name: string;
346
- entries: ReadonlyArray<{ value: string; color: string }>;
347
- }>;
348
- readonly activeGroup: string | null;
349
- readonly ramp?: {
350
- metric?: string;
351
- min: number;
352
- max: number;
353
- hue: string;
354
- /** Low end of the ramp gradient (the land colour the fills blend from). */
355
- base: string;
356
- };
357
- }
369
+ // MapLayoutLegend now lives in ./types (imported for local use + re-exported
370
+ // below) so that ./legend-band can consume the type without importing this
371
+ // module, which would re-introduce the layout↔legend-band cycle (this module
372
+ // value-imports mapLegendBand from ./legend-band).
373
+ export type { MapLayoutLegend };
358
374
 
359
375
  /** A drawn river centerline — an open stroked path (no fill). */
360
376
  export interface MapLayoutRiver {
@@ -459,6 +475,10 @@ export interface LayoutOptions {
459
475
  * canvas away from the content aspect, so the off-aspect canvas doesn't
460
476
  * re-distort. The in-app preview pane leaves this unset (keeps stretch-fill). */
461
477
  readonly preferContain?: boolean;
478
+ /** Which legend variant gets drawn — `'export'` shows only the active group,
479
+ * `'preview'` keeps inactive pills. Used to size the reserved legend band so
480
+ * the projected land starts below the legend. Defaults to `'preview'`. */
481
+ readonly legendMode?: LegendMode;
462
482
  }
463
483
 
464
484
  interface Size {
@@ -836,6 +856,60 @@ export function parsePathRings(d: string): Array<Array<[number, number]>> {
836
856
  return rings;
837
857
  }
838
858
 
859
+ /** Drop antimeridian wrap-slivers from a GLOBAL-view region path. A landmass that
860
+ * crosses ±180° (Russia's Chukotka, the western Aleutians, Fiji…) is clipped into
861
+ * fragments; the far one is a small sliver pinned to the OPPOSITE vertical frame
862
+ * edge — it reads as a stray island floating beside its true continent (e.g. the
863
+ * "island left of Alaska"). We drop any ring that (a) has an edge collinear with
864
+ * the LEFT or RIGHT canvas edge AND (b) is small AND (c) isn't the region's
865
+ * largest ring. The mainland (large, on its own edge) and interior islands (not
866
+ * frame-cut) are kept. Vertical edges only — a ring cut by the top/bottom lat
867
+ * crop is real content, not a wrap. Global-only: regional clipExtent cuts ARE
868
+ * real land at the viewport edge and must survive. */
869
+ function dropAntimeridianWrapSlivers(
870
+ d: string,
871
+ width: number,
872
+ height: number
873
+ ): string {
874
+ const rings = parsePathRings(d);
875
+ if (rings.length <= 1) return d;
876
+ const eps = 0.75;
877
+ const minArea = 0.003 * width * height; // 0.3% of canvas
878
+ const ringArea = (r: ReadonlyArray<[number, number]>): number => {
879
+ let s = 0;
880
+ for (let i = 0; i < r.length; i++) {
881
+ const a = r[i]!;
882
+ const b = r[(i + 1) % r.length]!;
883
+ s += a[0] * b[1] - b[0] * a[1];
884
+ }
885
+ return Math.abs(s) / 2;
886
+ };
887
+ const areas = rings.map(ringArea);
888
+ const maxArea = Math.max(...areas);
889
+ const onVEdge = (
890
+ a: readonly [number, number],
891
+ b: readonly [number, number]
892
+ ): boolean =>
893
+ (Math.abs(a[0]) <= eps && Math.abs(b[0]) <= eps) ||
894
+ (Math.abs(a[0] - width) <= eps && Math.abs(b[0] - width) <= eps);
895
+ let dropped = false;
896
+ const kept = rings.filter((r, idx) => {
897
+ if (areas[idx]! >= maxArea || areas[idx]! >= minArea) return true;
898
+ const touches = r.some((p, i) => onVEdge(p, r[(i + 1) % r.length]!));
899
+ if (touches) {
900
+ dropped = true;
901
+ return false;
902
+ }
903
+ return true;
904
+ });
905
+ if (!dropped) return d;
906
+ return kept
907
+ .map(
908
+ (r) => r.map((p, i) => (i ? 'L' : 'M') + p[0] + ',' + p[1]).join('') + 'Z'
909
+ )
910
+ .join('');
911
+ }
912
+
839
913
  export function layoutMap(
840
914
  resolved: ResolvedMap,
841
915
  data: MapData,
@@ -1091,6 +1165,35 @@ export function layoutMap(
1091
1165
 
1092
1166
  const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
1093
1167
 
1168
+ // -- Legend model (AR1: categorical via renderer's renderLegendD3). Built here
1169
+ // (before the fit) so the fit can reserve a band for it. Only the colouring
1170
+ // dimensions (value ramp + tag groups) get a legend; POI size and edge
1171
+ // thickness are self-evident from the marker/line scale and carry no key. --
1172
+ let legend: MapLayoutLegend | null = null;
1173
+ if (!resolved.directives.noLegend) {
1174
+ const legendTagGroups = resolved.tagGroups.map((g) => ({
1175
+ name: g.name,
1176
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
1177
+ }));
1178
+ if (legendTagGroups.length > 0 || hasRamp) {
1179
+ legend = {
1180
+ tagGroups: legendTagGroups,
1181
+ activeGroup,
1182
+ ...(hasRamp && {
1183
+ ramp: {
1184
+ ...(resolved.directives.regionMetric !== undefined && {
1185
+ metric: resolved.directives.regionMetric,
1186
+ }),
1187
+ min: rampMin,
1188
+ max: rampMax,
1189
+ hue: rampHue,
1190
+ base: rampBase,
1191
+ },
1192
+ }),
1193
+ };
1194
+ }
1195
+ }
1196
+
1094
1197
  // -- Fit the projection to the canvas (size-dependent; the projection + fit
1095
1198
  // target themselves came from buildMapProjection above). --
1096
1199
  // Reserve top padding for the title/subtitle banner ONLY when there are POIs,
@@ -1106,6 +1209,18 @@ export function layoutMap(
1106
1209
  TITLE_FONT_SIZE / 2;
1107
1210
  topPad = Math.max(FIT_PAD, bannerBottom + TITLE_GAP);
1108
1211
  }
1212
+ // Reserve a band for the top-center legend so the projected land starts BELOW
1213
+ // it (the legend is a foreground overlay — without this it covers land, e.g.
1214
+ // Europe on a world map). The band is measured from the SAME groups/config the
1215
+ // renderer draws (mode-aware: export shows only the active group), so the
1216
+ // reserve matches the rendered legend exactly.
1217
+ const legendBand = mapLegendBand(legend, {
1218
+ width,
1219
+ mode: opts.legendMode ?? 'preview',
1220
+ hasTitle: Boolean(resolved.title),
1221
+ hasSubtitle: Boolean(resolved.subtitle),
1222
+ });
1223
+ if (legendBand > topPad) topPad = legendBand;
1109
1224
  const fitBox: [[number, number], [number, number]] = [
1110
1225
  [FIT_PAD, topPad],
1111
1226
  [
@@ -1140,10 +1255,20 @@ export function layoutMap(
1140
1255
  const by0 = cb[0][1];
1141
1256
  const cw = cb[1][0] - bx0;
1142
1257
  const ch = cb[1][1] - by0;
1143
- const ox = fitBox[0][0];
1144
- const oy = fitBox[0][1];
1145
- const sx = cw > 0 ? (fitBox[1][0] - ox) / cw : 1;
1146
- const sy = ch > 0 ? (fitBox[1][1] - oy) / ch : 1;
1258
+ // A global stretch-fill runs the world to EVERY edge of the canvas — no
1259
+ // FIT_PAD inset. The equirectangular rectangle is the map, so its edges ARE
1260
+ // the render-area edges (the antimeridian sits exactly on the left/right
1261
+ // edge, not 24px short of it with a coastline ringing the gap). The title
1262
+ // overlays the top; we reserve a top band only when POIs are present (so
1263
+ // their markers don't project up under the foreground title banner).
1264
+ const topReserve =
1265
+ (resolved.title && resolved.pois.length > 0) || legendBand > 0
1266
+ ? topPad
1267
+ : 0;
1268
+ const ox = 0;
1269
+ const oy = topReserve;
1270
+ const sx = cw > 0 ? width / cw : 1;
1271
+ const sy = ch > 0 ? (height - topReserve) / ch : 1;
1147
1272
  stretchParams = { sx, sy, ox, oy, bx0, by0 };
1148
1273
  const stretch = (x: number, y: number): [number, number] => [
1149
1274
  ox + (x - bx0) * sx,
@@ -1588,7 +1713,12 @@ export function layoutMap(
1588
1713
  // but still drop antimeridian frame-fillers (Fiji et al.).
1589
1714
  const viewF = shouldCull ? cullFeatureToView(f) : dropFrameFillers(f);
1590
1715
  if (!viewF) continue;
1591
- const d = path(viewF as never) ?? '';
1716
+ const raw = path(viewF as never) ?? '';
1717
+ // Global views: strip the wrap-sliver a crossing landmass leaves pinned to
1718
+ // the far edge (Russia's Chukotka beside Alaska). Regional cuts are real.
1719
+ const d = fitIsGlobal
1720
+ ? dropAntimeridianWrapSlivers(raw, width, height)
1721
+ : raw;
1592
1722
  if (!d) continue;
1593
1723
  const isThisLayer = r?.layer === layerKind;
1594
1724
  // Non-US neighbour land in a US view is gray context, not yellow land.
@@ -1614,6 +1744,15 @@ export function layoutMap(
1614
1744
  // (the same source the resolver/inset/context-label layers read).
1615
1745
  label = (f.properties as { name?: string } | null)?.name;
1616
1746
  }
1747
+ // Label/hover anchor: a hardcoded mainland anchor when far-flung territory
1748
+ // would skew it, else the area-weighted screen centroid of the drawn shape.
1749
+ // The latter (unlike a bounding-box centre) survives antimeridian crossers.
1750
+ const labelAnchor = WORLD_LABEL_ANCHORS[iso];
1751
+ const c = labelAnchor
1752
+ ? project(labelAnchor[0], labelAnchor[1])
1753
+ : path.centroid(viewF as never);
1754
+ const hasCentroid =
1755
+ c != null && Number.isFinite(c[0]) && Number.isFinite(c[1]);
1617
1756
  regions.push({
1618
1757
  id: iso,
1619
1758
  d,
@@ -1622,6 +1761,7 @@ export function layoutMap(
1622
1761
  lineNumber,
1623
1762
  layer,
1624
1763
  ...(label !== undefined && { label }),
1764
+ ...(hasCentroid && { labelX: c[0], labelY: c[1] }),
1625
1765
  ...(isThisLayer && r.value !== undefined && { value: r.value }),
1626
1766
  ...(isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }),
1627
1767
  });
@@ -2284,12 +2424,6 @@ export function layoutMap(
2284
2424
  lineNumber,
2285
2425
  });
2286
2426
  };
2287
- // A few countries have far-flung territory that drags the area-weighted
2288
- // centroid off the mainland (US → Alaska pulls it up into Canada). Anchor
2289
- // their world-layer label to a mainland [lon, lat] instead.
2290
- const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
2291
- US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
2292
- };
2293
2427
  // A region label's screen footprint, middle-anchored on its centroid, used to
2294
2428
  // keep two region labels from overlapping (a small gap adds breathing room).
2295
2429
  const REGION_LABEL_GAP = 2;
@@ -2794,36 +2928,6 @@ export function layoutMap(
2794
2928
  labels.push(...contextLabels);
2795
2929
  }
2796
2930
 
2797
- // -- Legend model (AR1: categorical via renderer's renderLegendD3) --
2798
- let legend: MapLayoutLegend | null = null;
2799
- if (!resolved.directives.noLegend) {
2800
- const tagGroups = resolved.tagGroups.map((g) => ({
2801
- name: g.name,
2802
- entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
2803
- }));
2804
- // Only the colouring dimensions (value ramp + tag groups) get a legend.
2805
- // POI size and edge thickness are self-evident from the marker/line scale and
2806
- // intentionally carry no key (the poi-metric/flow-metric labels are captured
2807
- // for future use but not rendered as legend keys in v1).
2808
- if (tagGroups.length > 0 || hasRamp) {
2809
- legend = {
2810
- tagGroups,
2811
- activeGroup,
2812
- ...(hasRamp && {
2813
- ramp: {
2814
- ...(resolved.directives.regionMetric !== undefined && {
2815
- metric: resolved.directives.regionMetric,
2816
- }),
2817
- min: rampMin,
2818
- max: rampMax,
2819
- hue: rampHue,
2820
- base: rampBase,
2821
- },
2822
- }),
2823
- };
2824
- }
2825
- }
2826
-
2827
2931
  return {
2828
2932
  width,
2829
2933
  height,
@@ -0,0 +1,99 @@
1
+ // Vertical band reserved at the top of a map canvas for the choropleth / tag
2
+ // legend, so the projected land starts BELOW the legend instead of being covered
3
+ // by it. The legend (§24B.11) is drawn as a top-center foreground overlay; on a
4
+ // world map that placement sits over land (e.g. Europe), hiding it. Reserving a
5
+ // band in the fit box pushes the map down so the legend clears the land.
6
+ //
7
+ // Shared by `layoutMap` (grows `topPad`) and `mapExportDimensions` is intentionally
8
+ // NOT a consumer — the canvas height is independent; the band only repositions the
9
+ // map WITHIN the canvas. The group builder + config are reused by the renderer so
10
+ // the measured band matches exactly what gets drawn.
11
+ import { computeLegendLayout } from '../utils/legend-layout';
12
+ import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
13
+ import type {
14
+ LegendConfig,
15
+ LegendGroupData,
16
+ LegendMode,
17
+ LegendState,
18
+ } from '../utils/legend-types';
19
+ import type { MapLayoutLegend } from './types';
20
+
21
+ // Gap between the title/subtitle banner and the legend top — mirrors the `+ 8`
22
+ // in renderer.ts `legendY`.
23
+ const LEGEND_TOP_GAP = 8;
24
+ // Gap between the legend bottom and the start of the map content.
25
+ const LEGEND_BOTTOM_GAP = 10;
26
+
27
+ /** The legend's colouring groups (score ramp first, then non-empty tag groups) —
28
+ * the SAME array the renderer draws, so a measured layout matches the rendered
29
+ * one. Empty when the legend has neither a ramp nor any populated tag group. */
30
+ export function mapLegendGroups(legend: MapLayoutLegend): LegendGroupData[] {
31
+ const ramp = legend.ramp;
32
+ // Reserved name "Value" when no region-metric label is set — must match
33
+ // VALUE_NAME in layout.ts so the resolved activeGroup selects it.
34
+ const scoreGroup: LegendGroupData | null = ramp
35
+ ? {
36
+ name: ramp.metric?.trim() || 'Value',
37
+ entries: [],
38
+ gradient: {
39
+ min: ramp.min,
40
+ max: ramp.max,
41
+ hue: ramp.hue,
42
+ base: ramp.base,
43
+ },
44
+ }
45
+ : null;
46
+ const tagGroups: LegendGroupData[] = legend.tagGroups
47
+ .filter((g) => g.entries.length > 0)
48
+ .map((g) => ({ name: g.name, entries: [...g.entries] }));
49
+ return [...(scoreGroup ? [scoreGroup] : []), ...tagGroups];
50
+ }
51
+
52
+ /** The shared map-legend config (top-center, below the title, inactive pills kept
53
+ * so the preview can flip the active colouring dimension). `mode` gates the
54
+ * export-only filtering (active group only). */
55
+ export function mapLegendConfig(
56
+ groups: readonly LegendGroupData[],
57
+ mode: LegendMode
58
+ ): LegendConfig {
59
+ return {
60
+ groups,
61
+ position: { placement: 'top-center', titleRelation: 'below-title' },
62
+ mode,
63
+ showEmptyGroups: false,
64
+ showInactivePills: true,
65
+ };
66
+ }
67
+
68
+ /** Y of the legend's top edge — mirrors `legendY` in renderer.ts. */
69
+ export function mapLegendTop(hasTitle: boolean, hasSubtitle: boolean): number {
70
+ return (
71
+ (hasTitle ? TITLE_Y + TITLE_FONT_SIZE : 0) +
72
+ (hasSubtitle ? TITLE_FONT_SIZE : 0) +
73
+ LEGEND_TOP_GAP
74
+ );
75
+ }
76
+
77
+ /** Total vertical band (px from the canvas top) the legend occupies = its top Y +
78
+ * measured height + a bottom gap. Returns 0 when no legend is drawn, so callers
79
+ * can `Math.max` it into their existing top reserve without special-casing. */
80
+ export function mapLegendBand(
81
+ legend: MapLayoutLegend | null,
82
+ opts: {
83
+ width: number;
84
+ mode: LegendMode;
85
+ hasTitle: boolean;
86
+ hasSubtitle: boolean;
87
+ }
88
+ ): number {
89
+ if (!legend) return 0;
90
+ const groups = mapLegendGroups(legend);
91
+ if (groups.length === 0) return 0;
92
+ const config = mapLegendConfig(groups, opts.mode);
93
+ const state: LegendState = { activeGroup: legend.activeGroup };
94
+ const { height } = computeLegendLayout(config, state, opts.width);
95
+ if (height <= 0) return 0;
96
+ return (
97
+ mapLegendTop(opts.hasTitle, opts.hasSubtitle) + height + LEGEND_BOTTOM_GAP
98
+ );
99
+ }