@diagrammo/dgmo 0.21.0 → 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.
- package/README.md +16 -6
- package/dist/advanced.cjs +2521 -623
- package/dist/advanced.d.cts +917 -534
- package/dist/advanced.d.ts +917 -534
- package/dist/advanced.js +2516 -623
- package/dist/auto.cjs +2333 -608
- package/dist/auto.js +119 -119
- package/dist/auto.mjs +2335 -609
- package/dist/cli.cjs +168 -168
- package/dist/editor.cjs +13 -15
- package/dist/editor.js +13 -15
- package/dist/highlight.cjs +15 -12
- package/dist/highlight.js +15 -12
- package/dist/index.cjs +2317 -595
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2319 -596
- package/dist/internal.cjs +2521 -623
- package/dist/internal.d.cts +917 -534
- package/dist/internal.d.ts +917 -534
- package/dist/internal.js +2516 -623
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/mountain-ranges.json +1 -0
- package/dist/map-data/water-bodies.json +1 -0
- package/docs/language-reference.md +44 -31
- package/gallery/fixtures/map-categorical-world.dgmo +16 -0
- package/gallery/fixtures/map-categorical.dgmo +0 -1
- package/gallery/fixtures/map-choropleth.dgmo +0 -1
- package/gallery/fixtures/map-coastline.dgmo +7 -0
- package/gallery/fixtures/map-colorize.dgmo +11 -0
- package/gallery/fixtures/map-direct-color.dgmo +9 -0
- package/gallery/fixtures/map-reference-world.dgmo +11 -0
- package/gallery/fixtures/map-region-scope.dgmo +0 -3
- package/gallery/fixtures/map-route.dgmo +0 -1
- package/package.json +1 -1
- package/src/advanced.ts +26 -1
- package/src/boxes-and-lines/renderer.ts +39 -12
- package/src/cli.ts +1 -1
- package/src/completion.ts +32 -24
- package/src/cycle/renderer.ts +14 -1
- package/src/d3.ts +23 -11
- package/src/editor/highlight-api.ts +4 -0
- package/src/editor/keywords.ts +13 -15
- package/src/infra/renderer.ts +35 -7
- package/src/map/colorize.ts +54 -0
- package/src/map/context-labels.ts +429 -0
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/mountain-ranges.json +1 -0
- package/src/map/data/types.ts +34 -0
- package/src/map/data/water-bodies.json +1 -0
- package/src/map/dimensions.ts +117 -0
- package/src/map/geo-query.ts +295 -0
- package/src/map/geo.ts +305 -2
- package/src/map/invert.ts +111 -0
- package/src/map/layout.ts +1504 -335
- package/src/map/load-data.ts +16 -2
- package/src/map/parser.ts +57 -111
- package/src/map/renderer.ts +556 -13
- package/src/map/resolved-types.ts +24 -2
- package/src/map/resolver.ts +237 -67
- package/src/map/types.ts +39 -23
- package/src/mindmap/renderer.ts +10 -1
- package/src/palettes/atlas.ts +77 -0
- package/src/palettes/blueprint.ts +73 -0
- package/src/palettes/color-utils.ts +58 -1
- package/src/palettes/index.ts +12 -3
- package/src/palettes/slate.ts +73 -0
- package/src/palettes/tidewater.ts +73 -0
- package/src/render.ts +8 -1
- package/src/tech-radar/renderer.ts +3 -0
- package/src/tech-radar/types.ts +3 -0
- package/src/utils/d3-types.ts +5 -0
- package/src/utils/legend-layout.ts +21 -4
- package/src/utils/legend-types.ts +7 -0
- package/src/utils/reserved-key-registry.ts +3 -0
- package/src/palettes/bold.ts +0 -67
|
@@ -4,7 +4,7 @@
|
|
|
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 } from './data/types';
|
|
7
|
+
import type { Gazetteer, BoundaryTopology, WaterBodies } from './data/types';
|
|
8
8
|
|
|
9
9
|
/** The four static assets, injected into the pure resolver (DI). */
|
|
10
10
|
export interface MapData {
|
|
@@ -17,6 +17,10 @@ export interface MapData {
|
|
|
17
17
|
/** Major river centerlines (Natural Earth 110m) drawn as thin water lines over
|
|
18
18
|
* land — e.g. the Amazon, Nile, Mississippi. Optional, like `lakes`. */
|
|
19
19
|
rivers?: BoundaryTopology;
|
|
20
|
+
/** Notable mountain-range polygons (Natural Earth 50m geography regions) drawn
|
|
21
|
+
* as a subtle gradient relief cue over base land when the `relief` directive
|
|
22
|
+
* is on — e.g. the Rockies, Andes, Himalayas. Optional, like `lakes`. */
|
|
23
|
+
mountainRanges?: BoundaryTopology;
|
|
20
24
|
/** North-America-clipped 10m country land, used as crisp neighbour context
|
|
21
25
|
* under the albers-usa US view so Canada/Mexico match the 10m states instead
|
|
22
26
|
* of the coarser world tiers. Optional, like `lakes`. */
|
|
@@ -24,12 +28,17 @@ export interface MapData {
|
|
|
24
28
|
/** North-America-clipped 10m major lakes (Great Lakes etc.), used in place of
|
|
25
29
|
* the coarse `lakes` under the albers-usa US view. Optional. */
|
|
26
30
|
naLakes?: BoundaryTopology;
|
|
31
|
+
/** Water-body orientation labels (Natural Earth marine polys) drawn when the
|
|
32
|
+
* `context-labels` directive is on — oceans/seas/gulfs/bays/etc. Optional, so
|
|
33
|
+
* hand-built test fixtures and older bundles need not supply it. */
|
|
34
|
+
waterBodies?: WaterBodies;
|
|
27
35
|
gazetteer: Gazetteer;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
export type ProjectionFamily =
|
|
31
|
-
| '
|
|
39
|
+
| 'equal-earth'
|
|
32
40
|
| 'natural-earth'
|
|
41
|
+
| 'equirectangular'
|
|
33
42
|
| 'albers-usa'
|
|
34
43
|
| 'mercator';
|
|
35
44
|
|
|
@@ -47,6 +56,8 @@ export interface ResolvedRegion {
|
|
|
47
56
|
readonly name: string; // display name
|
|
48
57
|
readonly layer: 'country' | 'us-state';
|
|
49
58
|
readonly value?: number;
|
|
59
|
+
/** §1.5 trailing-token color NAME → flat override fill (§24B.4). */
|
|
60
|
+
readonly color?: string;
|
|
50
61
|
readonly tags: Readonly<Record<string, string>>;
|
|
51
62
|
readonly meta: Readonly<Record<string, string>>;
|
|
52
63
|
readonly lineNumber: number;
|
|
@@ -62,6 +73,8 @@ export interface ResolvedPoi {
|
|
|
62
73
|
readonly lat: number;
|
|
63
74
|
readonly lon: number;
|
|
64
75
|
readonly label?: string;
|
|
76
|
+
/** §1.5 trailing-token color NAME → flat marker fill (§24B.5). */
|
|
77
|
+
readonly color?: string;
|
|
65
78
|
readonly tags: Readonly<Record<string, string>>;
|
|
66
79
|
readonly meta: Readonly<Record<string, string>>;
|
|
67
80
|
readonly lineNumber: number;
|
|
@@ -108,6 +121,9 @@ export type GeoExtent = [[number, number], [number, number]];
|
|
|
108
121
|
|
|
109
122
|
export interface ResolvedMap {
|
|
110
123
|
readonly title: string | null;
|
|
124
|
+
/** DEAD — the `subtitle` directive was removed (2026-06-02 defaults-on review).
|
|
125
|
+
* Never populated; the renderer's subtitle branch is now unreachable. Left for
|
|
126
|
+
* a later cleanup pass. */
|
|
111
127
|
readonly subtitle?: string;
|
|
112
128
|
readonly caption?: string;
|
|
113
129
|
readonly tagGroups: readonly TagGroup[];
|
|
@@ -119,6 +135,12 @@ export interface ResolvedMap {
|
|
|
119
135
|
readonly routes: readonly ResolvedRoute[];
|
|
120
136
|
readonly extent: GeoExtent;
|
|
121
137
|
readonly projection: ProjectionFamily;
|
|
138
|
+
/** POI-only region framing: the region(s) that CONTAIN the POIs — us-state ids
|
|
139
|
+
* (`US-CA`) or country isos (`FR`). The frame is snapped to the union of their
|
|
140
|
+
* bboxes, and the layout labels them prominently (vs. muted neighbours). Empty
|
|
141
|
+
* for non-POI-only maps or when POIs fall outside every polygon. Optional so
|
|
142
|
+
* older/foreign ResolvedMap literals need not supply it. */
|
|
143
|
+
readonly poiFrameContainers?: readonly string[];
|
|
122
144
|
readonly diagnostics: readonly DgmoError[];
|
|
123
145
|
readonly error: string | null;
|
|
124
146
|
}
|
package/src/map/resolver.ts
CHANGED
|
@@ -18,7 +18,15 @@ import type {
|
|
|
18
18
|
ProjectionFamily,
|
|
19
19
|
GeoExtent,
|
|
20
20
|
} from './resolved-types';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
featureIndex,
|
|
23
|
+
featureBbox,
|
|
24
|
+
featureBboxPrimary,
|
|
25
|
+
unionExtent,
|
|
26
|
+
fold,
|
|
27
|
+
decodeFeatures,
|
|
28
|
+
regionAt,
|
|
29
|
+
} from './geo';
|
|
22
30
|
|
|
23
31
|
/** Discriminated result of a gazetteer name lookup (#5): `defer` is "ambiguous,
|
|
24
32
|
* retry in pass B with inferred scope" — distinct from `miss` (errored, drop) so
|
|
@@ -30,13 +38,36 @@ type LookupResult =
|
|
|
30
38
|
|
|
31
39
|
// Projection / tier thresholds (degrees of span) — tunable (R10).
|
|
32
40
|
const WORLD_SPAN = 90;
|
|
33
|
-
|
|
41
|
+
// Mercator is used for everything sub-world (tight clusters AND single-continent
|
|
42
|
+
// regional views — a mid-latitude continent reads with its familiar conventional
|
|
43
|
+
// shape). Two guards push back to the equirectangular world projection: a
|
|
44
|
+
// world/multi-continent `span` (> WORLD_SPAN), or
|
|
45
|
+
// a frame that reaches into polar latitudes (> MERCATOR_MAX_LAT) where Mercator's
|
|
46
|
+
// sec(φ) area blow-up turns gross. Europe (≈71°N) and East Asia stay on Mercator.
|
|
47
|
+
const MERCATOR_MAX_LAT = 80;
|
|
34
48
|
const PAD_FRACTION = 0.05;
|
|
49
|
+
// Region/choropleth maps frame to the NAMED countries/states; a tight 5% pad cuts
|
|
50
|
+
// the surrounding continent right at the data edge (a Europe choropleth stops at
|
|
51
|
+
// ~31°E, slicing off western Russia/Ukraine). A larger pad lets the neighbouring
|
|
52
|
+
// land peek in as gray context for orientation. POI maps keep the tight pad — they
|
|
53
|
+
// already get container-region framing + the zoom floor.
|
|
54
|
+
const REGION_PAD_FRACTION = 0.12;
|
|
35
55
|
// Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
|
|
36
56
|
// Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
|
|
37
57
|
// populated continents fill the frame rather than waste it on ice.
|
|
38
58
|
const WORLD_LAT_SOUTH = -58;
|
|
39
59
|
const WORLD_LAT_NORTH = 78;
|
|
60
|
+
// Tightest zoom for a POI-only cluster: never frame narrower than this many
|
|
61
|
+
// degrees on the longer axis. The basemap is state/country geometry only (no
|
|
62
|
+
// counties/cities/roads), so a metro-scale frame is an empty box — clamp up to a
|
|
63
|
+
// multi-state window that always shows coastline + a state outline or two around
|
|
64
|
+
// the dots. A tight cluster (e.g. Bay Area cities) therefore frames as ≈ its
|
|
65
|
+
// home state + neighbours rather than the whole nation. Tunable.
|
|
66
|
+
const POI_ZOOM_FLOOR_DEG = 7;
|
|
67
|
+
// Above this longitudinal span a US POI-only extent is "national" — use the
|
|
68
|
+
// albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
|
|
69
|
+
// CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
|
|
70
|
+
const US_NATIONAL_LON_SPAN = 48;
|
|
40
71
|
|
|
41
72
|
// Long-form (or common-alias) country name → the folded Natural-Earth display
|
|
42
73
|
// name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
|
|
@@ -143,6 +174,30 @@ function looksUS(lat: number, lon: number): boolean {
|
|
|
143
174
|
return (lon >= -180 && lon <= -64) || lon >= 172;
|
|
144
175
|
}
|
|
145
176
|
|
|
177
|
+
/** Classifies a bare-coordinate POI as a North-American neighbour (vs a far-away
|
|
178
|
+
* blocker) when deciding `albers-usa`. Used only for bare coords — named POIs
|
|
179
|
+
* carry an ISO. This deliberately only covers what `looksUS`'s broad box does
|
|
180
|
+
* NOT: the Atlantic-Canada strip east of −64° (Newfoundland/Labrador/Nova
|
|
181
|
+
* Scotia, e.g. St. John's at −52.7) which falls outside `looksUS`'s
|
|
182
|
+
* [−180, −64] longitude window. Most of Canada and all of Mexico already pass
|
|
183
|
+
* `looksUS` (their lon is ≤ −64, lat ≥ 15) so they never reach here. The
|
|
184
|
+
* latitude is capped at 72° (matching `looksUS`) so a high-Arctic coord is NOT
|
|
185
|
+
* treated as a neighbour — that would both distort the conus fit and collide
|
|
186
|
+
* with the Alaska inset bbox (`inAlaska`, lat ≥ 51 ∧ lon ≤ −129). NOTE: the
|
|
187
|
+
* east strip can also catch the SE-Greenland coast (rare, acceptable; named
|
|
188
|
+
* GL/DK POIs are unaffected since they classify by ISO). */
|
|
189
|
+
function looksNorthAmericaNeighbor(lat: number, lon: number): boolean {
|
|
190
|
+
return lat >= 14 && lat <= 72 && lon >= -141 && lon <= -52;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A bbox that (near-)spans the whole globe — the sentinel spherical geoBounds
|
|
194
|
+
* returns for a malformed/missing feature. Such a box is useless for framing. */
|
|
195
|
+
function isWholeSphere(bb: GeoExtent): boolean {
|
|
196
|
+
return (
|
|
197
|
+
bb[0][0] <= -179 && bb[1][0] >= 179 && bb[0][1] <= -89 && bb[1][1] >= 89
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
146
201
|
export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
147
202
|
const diagnostics: DgmoError[] = [...parsed.diagnostics]; // seed with parse diags (R14)
|
|
148
203
|
const err = (line: number, message: string, code?: string): void => {
|
|
@@ -154,9 +209,6 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
154
209
|
|
|
155
210
|
const result: Writable<ResolvedMap> = {
|
|
156
211
|
title: parsed.title,
|
|
157
|
-
...(parsed.directives.subtitle !== undefined && {
|
|
158
|
-
subtitle: parsed.directives.subtitle,
|
|
159
|
-
}),
|
|
160
212
|
...(parsed.directives.caption !== undefined && {
|
|
161
213
|
caption: parsed.directives.caption,
|
|
162
214
|
}),
|
|
@@ -166,7 +218,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
166
218
|
// renderer's job (step 4) — the resolver only carries `tags` + `tagGroups`
|
|
167
219
|
// through; it never resolves a tag value to a palette color (#10).
|
|
168
220
|
directives: { ...parsed.directives },
|
|
169
|
-
basemaps: { world: '
|
|
221
|
+
basemaps: { world: 'detail', subdivisions: [] },
|
|
170
222
|
regions: [],
|
|
171
223
|
pois: [],
|
|
172
224
|
edges: [],
|
|
@@ -175,13 +227,18 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
175
227
|
[-180, -85],
|
|
176
228
|
[180, 85],
|
|
177
229
|
] as GeoExtent,
|
|
178
|
-
projection: '
|
|
230
|
+
projection: 'equirectangular',
|
|
231
|
+
poiFrameContainers: [],
|
|
179
232
|
diagnostics,
|
|
180
233
|
error: parsed.error,
|
|
181
234
|
};
|
|
182
235
|
|
|
183
236
|
// Per-layer indexes (never merged — R12; coarse is the authoritative name
|
|
184
|
-
// index, ids shared with detail — R13).
|
|
237
|
+
// index, ids shared with detail — R13). LOAD-BEARING: `worldCoarse` (110m) is
|
|
238
|
+
// NOT used for rendering anymore (the world basemap is pinned to detail/50m,
|
|
239
|
+
// see basemaps assignment below) — it is retained solely as this name index
|
|
240
|
+
// (featureIndex) and the dominant-landmass bbox source (featureBboxPrimary).
|
|
241
|
+
// Do not delete it.
|
|
185
242
|
const countryIndex = featureIndex(data.worldCoarse);
|
|
186
243
|
const usStateIndex = featureIndex(data.usStates);
|
|
187
244
|
const allNames = [
|
|
@@ -189,10 +246,15 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
189
246
|
...[...usStateIndex.values()].map((v) => v.name),
|
|
190
247
|
];
|
|
191
248
|
|
|
249
|
+
// ── locale <ISO> → country + optional subdivision (§24B.8) ──
|
|
250
|
+
const localeRaw = parsed.directives.locale?.toUpperCase();
|
|
251
|
+
const localeCountry = localeRaw ? localeRaw.split('-')[0] : undefined;
|
|
252
|
+
const localeSubdivision =
|
|
253
|
+
localeRaw && /^[A-Z]{2}-/.test(localeRaw) ? localeRaw : undefined;
|
|
254
|
+
|
|
192
255
|
// ── US-scope signal (drives the country-vs-state collision, R2) ──
|
|
193
256
|
const usScoped =
|
|
194
|
-
|
|
195
|
-
parsed.directives.defaultCountry?.toUpperCase() === 'US' ||
|
|
257
|
+
localeCountry === 'US' ||
|
|
196
258
|
parsed.regions.some((r) => {
|
|
197
259
|
const f = fold(r.name);
|
|
198
260
|
return usStateIndex.has(f) && !countryIndex.has(f);
|
|
@@ -255,17 +317,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
255
317
|
}
|
|
256
318
|
} else if (inCountry && inState) {
|
|
257
319
|
if (usScoped) {
|
|
320
|
+
// A US scope (e.g. `locale US`) makes the state the unambiguous
|
|
321
|
+
// intent — resolve silently, no disambiguation warning needed.
|
|
258
322
|
chosen = { ...inState, layer: 'us-state' };
|
|
259
323
|
} else {
|
|
260
324
|
chosen = { ...inCountry, layer: 'country' };
|
|
325
|
+
// Teach the disambiguation syntax so the author can pin it explicitly.
|
|
326
|
+
// Suggest the non-redundant forms: a bare ISO code, or name + scope.
|
|
327
|
+
warn(
|
|
328
|
+
r.lineNumber,
|
|
329
|
+
`"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
|
|
330
|
+
'W_MAP_REGION_AMBIGUOUS'
|
|
331
|
+
);
|
|
261
332
|
}
|
|
262
|
-
// Teach the disambiguation syntax so the author can pin it explicitly.
|
|
263
|
-
// Suggest the non-redundant forms: a bare ISO code, or name + scope.
|
|
264
|
-
warn(
|
|
265
|
-
r.lineNumber,
|
|
266
|
-
`"${r.name}" is both a country and a US state — resolved as ${chosen.layer} (${chosen.id}). Pin it with an ISO code (${inState.id} / ${inCountry.id}) or name + scope ("${r.name} US" / "${r.name} ${inCountry.id}").`,
|
|
267
|
-
'W_MAP_REGION_AMBIGUOUS'
|
|
268
|
-
);
|
|
269
333
|
} else if (inState) {
|
|
270
334
|
chosen = { ...inState, layer: 'us-state' };
|
|
271
335
|
} else if (inCountry) {
|
|
@@ -289,6 +353,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
289
353
|
name: chosen.name,
|
|
290
354
|
layer: chosen.layer,
|
|
291
355
|
...(r.value !== undefined && { value: r.value }),
|
|
356
|
+
...(r.color !== undefined && { color: r.color }),
|
|
292
357
|
tags: r.tags,
|
|
293
358
|
meta: r.meta,
|
|
294
359
|
lineNumber: r.lineNumber,
|
|
@@ -389,7 +454,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
389
454
|
if (!scope)
|
|
390
455
|
warn(
|
|
391
456
|
line,
|
|
392
|
-
`"${name}" is ambiguous — resolved to the most-populous match.`,
|
|
457
|
+
`"${name}" is ambiguous — resolved to the most-populous match. Set a default with \`locale <ISO>\` (e.g. \`locale US\` / \`locale US-GA\`) to steer it.`,
|
|
393
458
|
'W_MAP_AMBIGUOUS_NAME'
|
|
394
459
|
);
|
|
395
460
|
}
|
|
@@ -407,11 +472,16 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
407
472
|
// projection test (#13). Named POIs contribute their gazetteer ISO; bare-coord
|
|
408
473
|
// POIs contribute a rough US-or-not classification (no reverse-geocode).
|
|
409
474
|
const poiCountries: string[] = [];
|
|
410
|
-
let
|
|
475
|
+
let anyUsPoi = false;
|
|
476
|
+
// `anyNonNaPoi` = a POI outside North America (not US/CA/MX). Gates the relaxed
|
|
477
|
+
// US-orientation test (#13): US + Canada/Mexico content keeps `albers-usa`, but
|
|
478
|
+
// anything beyond NA forces a geographic projection that frames all content.
|
|
479
|
+
let anyNonNaPoi = false;
|
|
411
480
|
const noteCountry = (iso: string | undefined): void => {
|
|
412
481
|
if (iso) {
|
|
413
482
|
poiCountries.push(iso);
|
|
414
|
-
if (iso
|
|
483
|
+
if (iso === 'US') anyUsPoi = true;
|
|
484
|
+
if (iso !== 'US' && iso !== 'CA' && iso !== 'MX') anyNonNaPoi = true;
|
|
415
485
|
}
|
|
416
486
|
};
|
|
417
487
|
|
|
@@ -419,7 +489,9 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
419
489
|
const deferred: (typeof parsed.pois)[number][] = [];
|
|
420
490
|
for (const p of parsed.pois) {
|
|
421
491
|
if (p.pos.kind === 'coords') {
|
|
422
|
-
if (
|
|
492
|
+
if (looksUS(p.pos.lat, p.pos.lon)) anyUsPoi = true;
|
|
493
|
+
else if (!looksNorthAmericaNeighbor(p.pos.lat, p.pos.lon))
|
|
494
|
+
anyNonNaPoi = true;
|
|
423
495
|
addResolvedPoi(p.pos.lat, p.pos.lon, p);
|
|
424
496
|
continue;
|
|
425
497
|
}
|
|
@@ -443,18 +515,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
443
515
|
// resolved regions AND Pass-A POIs (#3 — POIs were previously voided, so a
|
|
444
516
|
// POI-only US map never inferred US).
|
|
445
517
|
const inferredCountry =
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
518
|
+
localeCountry ?? mostCommonCountry(regions, poiCountries) ?? undefined;
|
|
519
|
+
// A `locale US-GA` subdivision further scopes ambiguous bare cities to that
|
|
520
|
+
// state (soft preference — see lookupName); else fall back to the country.
|
|
521
|
+
const inferredScope = localeSubdivision ?? inferredCountry;
|
|
449
522
|
|
|
450
|
-
// Pass B: ambiguous bare names, scoped by inferred
|
|
523
|
+
// Pass B: ambiguous bare names, scoped by the inferred locale.
|
|
451
524
|
for (const p of deferred) {
|
|
452
525
|
if (p.pos.kind !== 'name') continue;
|
|
453
526
|
const got = lookupName(
|
|
454
527
|
p.pos.name,
|
|
455
528
|
p.pos.scope,
|
|
456
529
|
p.lineNumber,
|
|
457
|
-
|
|
530
|
+
inferredScope,
|
|
458
531
|
true
|
|
459
532
|
);
|
|
460
533
|
if (got.kind === 'ok') {
|
|
@@ -476,6 +549,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
476
549
|
lat,
|
|
477
550
|
lon,
|
|
478
551
|
...(p.label !== undefined && { label: p.label }),
|
|
552
|
+
...(p.color !== undefined && { color: p.color }),
|
|
479
553
|
tags: p.tags,
|
|
480
554
|
meta: p.meta,
|
|
481
555
|
lineNumber: p.lineNumber,
|
|
@@ -550,7 +624,8 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
550
624
|
sizeValue !== undefined ? { value: sizeValue } : {};
|
|
551
625
|
if (pos.kind === 'coords') {
|
|
552
626
|
const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
|
|
553
|
-
if (
|
|
627
|
+
if (looksUS(pos.lat, pos.lon)) anyUsPoi = true;
|
|
628
|
+
else if (!looksNorthAmericaNeighbor(pos.lat, pos.lon)) anyNonNaPoi = true;
|
|
554
629
|
if (!registry.has(id)) {
|
|
555
630
|
registerPoi(
|
|
556
631
|
id,
|
|
@@ -574,7 +649,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
574
649
|
if (registry.has(f)) return f;
|
|
575
650
|
const aliased = declaredByName.get(f);
|
|
576
651
|
if (aliased) return aliased;
|
|
577
|
-
const got = lookupName(pos.name, pos.scope, line,
|
|
652
|
+
const got = lookupName(pos.name, pos.scope, line, inferredScope, true);
|
|
578
653
|
if (got.kind !== 'ok') return null;
|
|
579
654
|
noteCountry(got.iso);
|
|
580
655
|
registerPoi(
|
|
@@ -633,9 +708,37 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
633
708
|
}
|
|
634
709
|
|
|
635
710
|
// ── Basemaps + scope ──
|
|
711
|
+
// "US-oriented" = there is US content AND all other content stays within North
|
|
712
|
+
// America (US, Canada, or Mexico — no non-NA POI, no non-US/CA/MX country
|
|
713
|
+
// fill). Such a map renders as the conventional US states map: the full state
|
|
714
|
+
// mesh on albers-usa, every state outlined even with no data — including a
|
|
715
|
+
// POI-only named-city map (§24B.2), and now also a US map with a Canadian or
|
|
716
|
+
// Mexican neighbour POI/fill (the neighbour is framed alongside, never clipped).
|
|
717
|
+
// "Has US content" is STATE-level (a US state fill, a US POI, or `locale US`)
|
|
718
|
+
// — NOT a country-level `United States` fill, which means "treat the US as one
|
|
719
|
+
// unit" and should keep the country shape, not explode into 50 states.
|
|
720
|
+
const hasUsContent =
|
|
721
|
+
usSubdivisionReferenced || anyUsPoi || localeCountry === 'US';
|
|
722
|
+
const usOriented =
|
|
723
|
+
!anyNonNaPoi &&
|
|
724
|
+
!regions.some(
|
|
725
|
+
(r) => r.layer === 'country' && !['US', 'CA', 'MX'].includes(r.iso)
|
|
726
|
+
) &&
|
|
727
|
+
hasUsContent;
|
|
728
|
+
|
|
636
729
|
const subdivisions: Array<'us-states'> = [];
|
|
637
|
-
|
|
638
|
-
|
|
730
|
+
// Draw the US state mesh in two cases:
|
|
731
|
+
// 1. `usSubdivisionReferenced` — a US state is named as a data region
|
|
732
|
+
// (e.g. `California value: 92`), so the states ARE the subject; detail
|
|
733
|
+
// them even on a global projection alongside non-NA content.
|
|
734
|
+
// 2. `usOriented` — US content with everything else inside North America,
|
|
735
|
+
// i.e. the conventional US states map (including a POI-only named-city
|
|
736
|
+
// map, or a US map with a Canadian/Mexican neighbour).
|
|
737
|
+
// Deliberately NOT drawn for bare US POIs on an otherwise-global map (e.g. a
|
|
738
|
+
// worldwide backbone with `us-east-1` + Tokyo + Mumbai): the map already knows
|
|
739
|
+
// it spans beyond NA (`anyNonNaPoi`), so exploding the US into 50 states reads
|
|
740
|
+
// as noise, not signal. Such a map renders country-only.
|
|
741
|
+
if (usSubdivisionReferenced || usOriented) subdivisions.push('us-states');
|
|
639
742
|
|
|
640
743
|
// ── Extent + projection (R5/R10) ──
|
|
641
744
|
const regionBoxes: GeoExtent[] = [];
|
|
@@ -643,10 +746,12 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
643
746
|
const bb = featureBbox(data.usStates, ref.id);
|
|
644
747
|
if (bb) regionBoxes.push(bb);
|
|
645
748
|
}
|
|
646
|
-
// country regions contribute their country bbox
|
|
749
|
+
// country regions contribute their country bbox — but framed on the dominant
|
|
750
|
+
// landmass, ignoring far-detached minor territories (e.g. French Guiana) so a
|
|
751
|
+
// Europe map naming France doesn't auto-fit across the Atlantic (R5).
|
|
647
752
|
for (const r of regions) {
|
|
648
753
|
if (r.layer === 'country') {
|
|
649
|
-
const bb =
|
|
754
|
+
const bb = featureBboxPrimary(data.worldCoarse, r.iso);
|
|
650
755
|
if (bb) regionBoxes.push(bb);
|
|
651
756
|
}
|
|
652
757
|
}
|
|
@@ -656,47 +761,103 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
656
761
|
[-180, -85],
|
|
657
762
|
[180, 85],
|
|
658
763
|
];
|
|
659
|
-
|
|
764
|
+
// Region/choropleth maps get a wider context pad; POI maps stay tight (the
|
|
765
|
+
// isPoiOnly block below re-pads container bboxes with PAD_FRACTION anyway).
|
|
766
|
+
const basePad = regions.length > 0 ? REGION_PAD_FRACTION : PAD_FRACTION;
|
|
767
|
+
let extent: GeoExtent = unioned ? pad(unioned, basePad) : DEFAULT_EXTENT; // empty → default
|
|
768
|
+
|
|
769
|
+
const isPoiOnly = pois.length > 0 && regions.length === 0;
|
|
770
|
+
|
|
771
|
+
// POI-only region framing (R-poi-region): snap the frame to the region(s) that
|
|
772
|
+
// CONTAIN the POIs, not an arbitrary window. Reverse-geocode each POI to its US
|
|
773
|
+
// state / country (point-in-polygon), then frame to the union of those regions'
|
|
774
|
+
// bboxes — so a cluster of Bay Area cities shows the whole of California, with
|
|
775
|
+
// the containing region available for labelling. Falls back to the raw POI
|
|
776
|
+
// extent when a POI sits outside every polygon (open ocean). The floor below
|
|
777
|
+
// still applies as a *minimum* so a tiny container (e.g. Rhode Island) keeps
|
|
778
|
+
// breathing room.
|
|
779
|
+
const containerRegionIds: string[] = []; // 'US-CA' (state) or 'FR' (country)
|
|
780
|
+
if (isPoiOnly) {
|
|
781
|
+
const countries = decodeFeatures(data.worldDetail);
|
|
782
|
+
const states = decodeFeatures(data.usStates);
|
|
783
|
+
const seen = new Set<string>();
|
|
784
|
+
const containerBoxes: GeoExtent[] = [];
|
|
785
|
+
for (const p of pois) {
|
|
786
|
+
const { country, state } = regionAt([p.lon, p.lat], countries, states);
|
|
787
|
+
const id = state?.iso ?? country?.iso;
|
|
788
|
+
if (!id || seen.has(id)) continue;
|
|
789
|
+
seen.add(id);
|
|
790
|
+
containerRegionIds.push(id);
|
|
791
|
+
const bb = state
|
|
792
|
+
? featureBbox(data.usStates, id)
|
|
793
|
+
: featureBboxPrimary(data.worldCoarse, id);
|
|
794
|
+
// Skip a degenerate whole-sphere bbox: spherical geoBounds returns the full
|
|
795
|
+
// globe for a malformed/missing geometry, which would blow the frame out to
|
|
796
|
+
// the entire world. Real region geometry never spans the full sphere — fall
|
|
797
|
+
// back to the raw POI extent (+ floor) instead.
|
|
798
|
+
if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
|
|
799
|
+
}
|
|
800
|
+
const containerUnion = unionExtent(containerBoxes, points);
|
|
801
|
+
if (containerUnion) extent = pad(containerUnion, PAD_FRACTION);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// POI-only fit-to-cluster zoom floor. With region framing above, the extent is
|
|
805
|
+
// usually the containing region(s); the floor only kicks in for a tiny region
|
|
806
|
+
// or a POI that fell outside every polygon. Expand a too-tight extent
|
|
807
|
+
// symmetrically about its centroid until the longer axis reaches
|
|
808
|
+
// POI_ZOOM_FLOOR_DEG so recognizable land always frames the dots. Uniform scale
|
|
809
|
+
// preserves the aspect; the layout's fitExtent letterboxes to canvas.
|
|
810
|
+
if (isPoiOnly) {
|
|
811
|
+
const cx = (extent[0][0] + extent[1][0]) / 2;
|
|
812
|
+
const cy = (extent[0][1] + extent[1][1]) / 2;
|
|
813
|
+
const lon = extent[1][0] - extent[0][0];
|
|
814
|
+
const lat = extent[1][1] - extent[0][1];
|
|
815
|
+
const longer = Math.max(lon, lat);
|
|
816
|
+
if (longer > 0 && longer < POI_ZOOM_FLOOR_DEG) {
|
|
817
|
+
const k = POI_ZOOM_FLOOR_DEG / longer;
|
|
818
|
+
const halfLon = (lon * k) / 2;
|
|
819
|
+
const halfLat = (lat * k) / 2;
|
|
820
|
+
extent = [
|
|
821
|
+
[cx - halfLon, cy - halfLat],
|
|
822
|
+
[cx + halfLon, cy + halfLat],
|
|
823
|
+
];
|
|
824
|
+
}
|
|
825
|
+
}
|
|
660
826
|
|
|
661
827
|
const lonSpan = extent[1][0] - extent[0][0];
|
|
662
828
|
const latSpan = extent[1][1] - extent[0][1];
|
|
663
829
|
const span = Math.max(lonSpan, latSpan);
|
|
664
|
-
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
const usDominant =
|
|
674
|
-
(subdivisions.includes('us-states') ||
|
|
675
|
-
regions.some((r) => r.layer === 'us-state')) &&
|
|
676
|
-
!regions.some((r) => r.layer === 'country' && r.iso !== 'US') &&
|
|
677
|
-
!anyNonUsPoi;
|
|
678
|
-
|
|
830
|
+
const maxAbsLat = Math.max(Math.abs(extent[0][1]), Math.abs(extent[1][1]));
|
|
831
|
+
// albers-usa is the national US composite (insets AK/HI when referenced, the
|
|
832
|
+
// conic projects neighbour land around the states). Choose it exactly when the
|
|
833
|
+
// map is US-oriented (#13): US content plus only US/Canada/Mexico elsewhere.
|
|
834
|
+
// Content reaching BEYOND North America fails `usOriented` and stays on a
|
|
835
|
+
// geographic world/regional projection that frames everything. Note: this
|
|
836
|
+
// intentionally snaps a POI-only US city map to the national frame ("show all
|
|
837
|
+
// states") rather than fit-zooming to the cluster on a geographic projection.
|
|
838
|
+
// (§24B.2 — projection is inferred, never configured.)
|
|
679
839
|
let projection: ProjectionFamily;
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
} else if (usDominant) {
|
|
840
|
+
if (isPoiOnly && usOriented && lonSpan < US_NATIONAL_LON_SPAN) {
|
|
841
|
+
// Sub-national US POI cluster: regional Mercator (familiar shapes), fit to
|
|
842
|
+
// the floored extent above. The us-states mesh is still drawn (subdivisions
|
|
843
|
+
// pushed via usOriented), so the home state + neighbours frame the dots.
|
|
844
|
+
// albers-usa is reserved for genuinely national-span content below — a local
|
|
845
|
+
// cluster no longer snaps to the whole-nation composite (#13, §24B.2).
|
|
846
|
+
projection = 'mercator';
|
|
847
|
+
} else if (usOriented) {
|
|
689
848
|
projection = 'albers-usa';
|
|
690
|
-
} else if (span > WORLD_SPAN) {
|
|
691
|
-
// World/
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
849
|
+
} else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
|
|
850
|
+
// World/multi-continent scale (or a polar-reaching frame). Every world map —
|
|
851
|
+
// data choropleth OR dataless reference — gets equirectangular: a clean,
|
|
852
|
+
// conventional rectangular wall-map frame. (Trade: not equal-area, so a
|
|
853
|
+
// choropleth's high-latitude regions stretch vertically — accepted for the
|
|
854
|
+
// consistent rectangular look over Equal Earth's area honesty.)
|
|
695
855
|
projection = 'equirectangular';
|
|
696
|
-
} else if (span < MERCATOR_MAX_SPAN) {
|
|
697
|
-
projection = 'mercator';
|
|
698
856
|
} else {
|
|
699
|
-
|
|
857
|
+
// Tight clusters AND single-continent regional views: Mercator gives every
|
|
858
|
+
// mid-latitude landmass its familiar conventional shape (a world projection
|
|
859
|
+
// squashes a continent like Europe horizontally).
|
|
860
|
+
projection = 'mercator';
|
|
700
861
|
}
|
|
701
862
|
|
|
702
863
|
// World-scale framing (R10): a multi-continent spread frames most cleanly as
|
|
@@ -711,7 +872,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
711
872
|
// would slice off South Africa, southern Argentina, northern Russia, …). The
|
|
712
873
|
// ≥180° gate leaves regional spreads tight — `region` continents (Europe
|
|
713
874
|
// ≈70°, Asia ≈155°) and antimeridian clusters (mercator anyway) untouched.
|
|
714
|
-
// Applies to
|
|
875
|
+
// Applies to the equirectangular world projection (data + reference alike).
|
|
715
876
|
if (lonSpan >= 180) {
|
|
716
877
|
extent = [
|
|
717
878
|
[-180, Math.min(extent[0][1], WORLD_LAT_SOUTH)],
|
|
@@ -724,11 +885,20 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
724
885
|
result.edges = edges;
|
|
725
886
|
result.routes = routes;
|
|
726
887
|
result.basemaps = {
|
|
727
|
-
|
|
888
|
+
// Tier is intentionally pinned to detail (50m) at ALL scales. Diagrammo maps
|
|
889
|
+
// are presentational (palette tints, relief hachures, POI hubs), not
|
|
890
|
+
// survey-grade — recognizability > generalization: 110m coarse drops the
|
|
891
|
+
// Italian boot to a stump at world scale. `WORLD_SPAN` lives on only for the
|
|
892
|
+
// projection decision (the `usOriented`/`span > WORLD_SPAN` chain above); it
|
|
893
|
+
// no longer gates basemap resolution.
|
|
894
|
+
// `worldCoarse` is still loaded — it's the authoritative name/bbox index
|
|
895
|
+
// (featureIndex, featureBboxPrimary), not dead code.
|
|
896
|
+
world: 'detail',
|
|
728
897
|
subdivisions,
|
|
729
898
|
};
|
|
730
899
|
result.extent = extent;
|
|
731
900
|
result.projection = projection;
|
|
901
|
+
result.poiFrameContainers = containerRegionIds;
|
|
732
902
|
result.error = parsed.error ?? firstError(diagnostics);
|
|
733
903
|
// `Writable` widens the GeoExtent tuple to an array; the runtime value is a
|
|
734
904
|
// correct GeoExtent, so cast back on return (through unknown — tuple vs array).
|