@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.
- package/dist/advanced.cjs +372 -103
- package/dist/advanced.d.cts +52 -19
- package/dist/advanced.d.ts +52 -19
- package/dist/advanced.js +372 -103
- package/dist/auto.cjs +370 -97
- package/dist/auto.js +117 -117
- package/dist/auto.mjs +370 -97
- package/dist/cli.cjs +151 -151
- package/dist/editor.cjs +3 -0
- package/dist/editor.js +3 -0
- package/dist/highlight.cjs +3 -0
- package/dist/highlight.js +3 -0
- package/dist/index.cjs +498 -96
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +496 -96
- package/dist/internal.cjs +372 -103
- package/dist/internal.d.cts +52 -19
- package/dist/internal.d.ts +52 -19
- package/dist/internal.js +372 -103
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/gazetteer.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -1
- package/dist/map-data/water-bodies.json +1 -1
- package/dist/map-data/world-coarse.json +1 -1
- package/dist/map-data/world-detail.json +1 -1
- package/docs/language-reference.md +38 -2
- package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
- package/package.json +1 -1
- package/src/boxes-and-lines/parser.ts +39 -0
- package/src/boxes-and-lines/renderer.ts +219 -14
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/completion.ts +4 -5
- package/src/d3.ts +26 -6
- package/src/editor/keywords.ts +3 -0
- package/src/index.ts +8 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/README.md +6 -0
- package/src/map/data/gazetteer.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -1
- package/src/map/data/water-bodies.json +1 -1
- package/src/map/data/world-coarse.json +1 -1
- package/src/map/data/world-detail.json +1 -1
- package/src/map/dimensions.ts +21 -5
- package/src/map/layout.ts +167 -63
- package/src/map/legend-band.ts +99 -0
- package/src/map/renderer.ts +105 -32
- package/src/map/resolver.ts +43 -1
- package/src/map/types.ts +20 -0
- package/src/utils/reserved-key-registry.ts +5 -3
- package/src/utils/svg-embed.ts +193 -0
package/src/map/dimensions.ts
CHANGED
|
@@ -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
|
|
89
|
-
|
|
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
|
-
|
|
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).
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
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 =
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
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
|
+
}
|