@diagrammo/dgmo 0.21.1 → 0.23.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 +2230 -503
- package/dist/advanced.d.cts +5731 -0
- package/dist/advanced.d.ts +5731 -0
- package/dist/advanced.js +2226 -503
- package/dist/auto.cjs +2272 -479
- package/dist/auto.d.cts +39 -0
- package/dist/auto.d.ts +39 -0
- package/dist/auto.js +124 -124
- package/dist/auto.mjs +2274 -480
- package/dist/cli.cjs +170 -170
- package/dist/editor.cjs +16 -16
- package/dist/editor.js +16 -16
- package/dist/highlight.cjs +18 -13
- package/dist/highlight.js +18 -13
- package/dist/index.cjs +2253 -465
- package/dist/index.d.cts +339 -0
- package/dist/index.d.ts +339 -0
- package/dist/index.js +2255 -466
- package/dist/internal.cjs +2230 -503
- package/dist/internal.d.cts +5731 -0
- package/dist/internal.d.ts +5731 -0
- package/dist/internal.js +2226 -503
- 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 -0
- package/dist/map-data/world-coarse.json +1 -1
- package/dist/map-data/world-detail.json +1 -1
- package/docs/language-reference.md +55 -9
- package/gallery/fixtures/boxes-and-lines.dgmo +6 -4
- 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 +0 -1
- 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 +12 -1
- package/src/boxes-and-lines/parser.ts +39 -0
- package/src/boxes-and-lines/renderer.ts +205 -20
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/cli.ts +1 -1
- package/src/completion.ts +36 -30
- package/src/cycle/renderer.ts +14 -1
- package/src/d3.ts +20 -6
- package/src/editor/highlight-api.ts +4 -0
- package/src/editor/keywords.ts +16 -16
- 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/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/types.ts +34 -0
- package/src/map/data/water-bodies.json +1 -0
- package/src/map/data/world-coarse.json +1 -1
- package/src/map/data/world-detail.json +1 -1
- package/src/map/dimensions.ts +117 -0
- package/src/map/geo-query.ts +21 -3
- package/src/map/geo.ts +47 -1
- package/src/map/layout.ts +1408 -266
- package/src/map/load-data.ts +10 -2
- package/src/map/parser.ts +42 -116
- package/src/map/renderer.ts +604 -14
- package/src/map/resolved-types.ts +16 -2
- package/src/map/resolver.ts +208 -59
- package/src/map/types.ts +30 -32
- 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 +8 -3
- 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 {
|
|
@@ -28,12 +28,17 @@ export interface MapData {
|
|
|
28
28
|
/** North-America-clipped 10m major lakes (Great Lakes etc.), used in place of
|
|
29
29
|
* the coarse `lakes` under the albers-usa US view. Optional. */
|
|
30
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;
|
|
31
35
|
gazetteer: Gazetteer;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export type ProjectionFamily =
|
|
35
|
-
| '
|
|
39
|
+
| 'equal-earth'
|
|
36
40
|
| 'natural-earth'
|
|
41
|
+
| 'equirectangular'
|
|
37
42
|
| 'albers-usa'
|
|
38
43
|
| 'mercator';
|
|
39
44
|
|
|
@@ -116,6 +121,9 @@ export type GeoExtent = [[number, number], [number, number]];
|
|
|
116
121
|
|
|
117
122
|
export interface ResolvedMap {
|
|
118
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. */
|
|
119
127
|
readonly subtitle?: string;
|
|
120
128
|
readonly caption?: string;
|
|
121
129
|
readonly tagGroups: readonly TagGroup[];
|
|
@@ -127,6 +135,12 @@ export interface ResolvedMap {
|
|
|
127
135
|
readonly routes: readonly ResolvedRoute[];
|
|
128
136
|
readonly extent: GeoExtent;
|
|
129
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[];
|
|
130
144
|
readonly diagnostics: readonly DgmoError[];
|
|
131
145
|
readonly error: string | null;
|
|
132
146
|
}
|
package/src/map/resolver.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
featureBboxPrimary,
|
|
25
25
|
unionExtent,
|
|
26
26
|
fold,
|
|
27
|
+
decodeFeatures,
|
|
28
|
+
regionAt,
|
|
27
29
|
} from './geo';
|
|
28
30
|
|
|
29
31
|
/** Discriminated result of a gazetteer name lookup (#5): `defer` is "ambiguous,
|
|
@@ -38,17 +40,34 @@ type LookupResult =
|
|
|
38
40
|
const WORLD_SPAN = 90;
|
|
39
41
|
// Mercator is used for everything sub-world (tight clusters AND single-continent
|
|
40
42
|
// regional views — a mid-latitude continent reads with its familiar conventional
|
|
41
|
-
// shape
|
|
42
|
-
//
|
|
43
|
-
// reaches into polar latitudes (> MERCATOR_MAX_LAT) where Mercator's
|
|
44
|
-
// blow-up turns gross. Europe (≈71°N) and East Asia stay
|
|
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.
|
|
45
47
|
const MERCATOR_MAX_LAT = 80;
|
|
46
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;
|
|
47
55
|
// Latitude band for a snapped world view — Tierra del Fuego (≈ −55°) to northern
|
|
48
56
|
// Russia/Canada (≈ +78°). Excludes most of Antarctica + the high Arctic so the
|
|
49
57
|
// populated continents fill the frame rather than waste it on ice.
|
|
50
58
|
const WORLD_LAT_SOUTH = -58;
|
|
51
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;
|
|
52
71
|
|
|
53
72
|
// Long-form (or common-alias) country name → the folded Natural-Earth display
|
|
54
73
|
// name actually shipped in world-coarse (#6). The NE coarse layer abbreviates a
|
|
@@ -155,6 +174,30 @@ function looksUS(lat: number, lon: number): boolean {
|
|
|
155
174
|
return (lon >= -180 && lon <= -64) || lon >= 172;
|
|
156
175
|
}
|
|
157
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
|
+
|
|
158
201
|
export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
159
202
|
const diagnostics: DgmoError[] = [...parsed.diagnostics]; // seed with parse diags (R14)
|
|
160
203
|
const err = (line: number, message: string, code?: string): void => {
|
|
@@ -166,9 +209,6 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
166
209
|
|
|
167
210
|
const result: Writable<ResolvedMap> = {
|
|
168
211
|
title: parsed.title,
|
|
169
|
-
...(parsed.directives.subtitle !== undefined && {
|
|
170
|
-
subtitle: parsed.directives.subtitle,
|
|
171
|
-
}),
|
|
172
212
|
...(parsed.directives.caption !== undefined && {
|
|
173
213
|
caption: parsed.directives.caption,
|
|
174
214
|
}),
|
|
@@ -178,7 +218,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
178
218
|
// renderer's job (step 4) — the resolver only carries `tags` + `tagGroups`
|
|
179
219
|
// through; it never resolves a tag value to a palette color (#10).
|
|
180
220
|
directives: { ...parsed.directives },
|
|
181
|
-
basemaps: { world: '
|
|
221
|
+
basemaps: { world: 'detail', subdivisions: [] },
|
|
182
222
|
regions: [],
|
|
183
223
|
pois: [],
|
|
184
224
|
edges: [],
|
|
@@ -187,13 +227,18 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
187
227
|
[-180, -85],
|
|
188
228
|
[180, 85],
|
|
189
229
|
] as GeoExtent,
|
|
190
|
-
projection: '
|
|
230
|
+
projection: 'equirectangular',
|
|
231
|
+
poiFrameContainers: [],
|
|
191
232
|
diagnostics,
|
|
192
233
|
error: parsed.error,
|
|
193
234
|
};
|
|
194
235
|
|
|
195
236
|
// Per-layer indexes (never merged — R12; coarse is the authoritative name
|
|
196
|
-
// 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.
|
|
197
242
|
const countryIndex = featureIndex(data.worldCoarse);
|
|
198
243
|
const usStateIndex = featureIndex(data.usStates);
|
|
199
244
|
const allNames = [
|
|
@@ -201,10 +246,15 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
201
246
|
...[...usStateIndex.values()].map((v) => v.name),
|
|
202
247
|
];
|
|
203
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
|
+
|
|
204
255
|
// ── US-scope signal (drives the country-vs-state collision, R2) ──
|
|
205
256
|
const usScoped =
|
|
206
|
-
|
|
207
|
-
parsed.directives.defaultCountry?.toUpperCase() === 'US' ||
|
|
257
|
+
localeCountry === 'US' ||
|
|
208
258
|
parsed.regions.some((r) => {
|
|
209
259
|
const f = fold(r.name);
|
|
210
260
|
return usStateIndex.has(f) && !countryIndex.has(f);
|
|
@@ -267,7 +317,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
267
317
|
}
|
|
268
318
|
} else if (inCountry && inState) {
|
|
269
319
|
if (usScoped) {
|
|
270
|
-
// A US scope (e.g. `
|
|
320
|
+
// A US scope (e.g. `locale US`) makes the state the unambiguous
|
|
271
321
|
// intent — resolve silently, no disambiguation warning needed.
|
|
272
322
|
chosen = { ...inState, layer: 'us-state' };
|
|
273
323
|
} else {
|
|
@@ -404,7 +454,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
404
454
|
if (!scope)
|
|
405
455
|
warn(
|
|
406
456
|
line,
|
|
407
|
-
`"${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.`,
|
|
408
458
|
'W_MAP_AMBIGUOUS_NAME'
|
|
409
459
|
);
|
|
410
460
|
}
|
|
@@ -422,11 +472,16 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
422
472
|
// projection test (#13). Named POIs contribute their gazetteer ISO; bare-coord
|
|
423
473
|
// POIs contribute a rough US-or-not classification (no reverse-geocode).
|
|
424
474
|
const poiCountries: string[] = [];
|
|
425
|
-
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;
|
|
426
480
|
const noteCountry = (iso: string | undefined): void => {
|
|
427
481
|
if (iso) {
|
|
428
482
|
poiCountries.push(iso);
|
|
429
|
-
if (iso
|
|
483
|
+
if (iso === 'US') anyUsPoi = true;
|
|
484
|
+
if (iso !== 'US' && iso !== 'CA' && iso !== 'MX') anyNonNaPoi = true;
|
|
430
485
|
}
|
|
431
486
|
};
|
|
432
487
|
|
|
@@ -434,7 +489,9 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
434
489
|
const deferred: (typeof parsed.pois)[number][] = [];
|
|
435
490
|
for (const p of parsed.pois) {
|
|
436
491
|
if (p.pos.kind === 'coords') {
|
|
437
|
-
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;
|
|
438
495
|
addResolvedPoi(p.pos.lat, p.pos.lon, p);
|
|
439
496
|
continue;
|
|
440
497
|
}
|
|
@@ -458,18 +515,19 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
458
515
|
// resolved regions AND Pass-A POIs (#3 — POIs were previously voided, so a
|
|
459
516
|
// POI-only US map never inferred US).
|
|
460
517
|
const inferredCountry =
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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;
|
|
464
522
|
|
|
465
|
-
// Pass B: ambiguous bare names, scoped by inferred
|
|
523
|
+
// Pass B: ambiguous bare names, scoped by the inferred locale.
|
|
466
524
|
for (const p of deferred) {
|
|
467
525
|
if (p.pos.kind !== 'name') continue;
|
|
468
526
|
const got = lookupName(
|
|
469
527
|
p.pos.name,
|
|
470
528
|
p.pos.scope,
|
|
471
529
|
p.lineNumber,
|
|
472
|
-
|
|
530
|
+
inferredScope,
|
|
473
531
|
true
|
|
474
532
|
);
|
|
475
533
|
if (got.kind === 'ok') {
|
|
@@ -566,7 +624,8 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
566
624
|
sizeValue !== undefined ? { value: sizeValue } : {};
|
|
567
625
|
if (pos.kind === 'coords') {
|
|
568
626
|
const id = alias ? fold(alias) : `@${pos.lat},${pos.lon}`;
|
|
569
|
-
if (
|
|
627
|
+
if (looksUS(pos.lat, pos.lon)) anyUsPoi = true;
|
|
628
|
+
else if (!looksNorthAmericaNeighbor(pos.lat, pos.lon)) anyNonNaPoi = true;
|
|
570
629
|
if (!registry.has(id)) {
|
|
571
630
|
registerPoi(
|
|
572
631
|
id,
|
|
@@ -590,7 +649,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
590
649
|
if (registry.has(f)) return f;
|
|
591
650
|
const aliased = declaredByName.get(f);
|
|
592
651
|
if (aliased) return aliased;
|
|
593
|
-
const got = lookupName(pos.name, pos.scope, line,
|
|
652
|
+
const got = lookupName(pos.name, pos.scope, line, inferredScope, true);
|
|
594
653
|
if (got.kind !== 'ok') return null;
|
|
595
654
|
noteCountry(got.iso);
|
|
596
655
|
registerPoi(
|
|
@@ -649,9 +708,37 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
649
708
|
}
|
|
650
709
|
|
|
651
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
|
+
|
|
652
729
|
const subdivisions: Array<'us-states'> = [];
|
|
653
|
-
|
|
654
|
-
|
|
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');
|
|
655
742
|
|
|
656
743
|
// ── Extent + projection (R5/R10) ──
|
|
657
744
|
const regionBoxes: GeoExtent[] = [];
|
|
@@ -674,48 +761,101 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
674
761
|
[-180, -85],
|
|
675
762
|
[180, 85],
|
|
676
763
|
];
|
|
677
|
-
|
|
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
|
+
}
|
|
678
826
|
|
|
679
827
|
const lonSpan = extent[1][0] - extent[0][0];
|
|
680
828
|
const latSpan = extent[1][1] - extent[0][1];
|
|
681
829
|
const span = Math.max(lonSpan, latSpan);
|
|
682
830
|
const maxAbsLat = Math.max(Math.abs(extent[0][1]), Math.abs(extent[1][1]));
|
|
683
|
-
// albers-usa
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
// Caribbean, Canada) still draws.
|
|
692
|
-
const usDominant =
|
|
693
|
-
(subdivisions.includes('us-states') ||
|
|
694
|
-
regions.some((r) => r.layer === 'us-state')) &&
|
|
695
|
-
!regions.some((r) => r.layer === 'country' && r.iso !== 'US') &&
|
|
696
|
-
!anyNonUsPoi;
|
|
697
|
-
|
|
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.)
|
|
698
839
|
let projection: ProjectionFamily;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
} 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) {
|
|
708
848
|
projection = 'albers-usa';
|
|
709
849
|
} else if (span > WORLD_SPAN || maxAbsLat > MERCATOR_MAX_LAT) {
|
|
710
|
-
// World/multi-continent scale (or a polar-reaching frame)
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
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.)
|
|
715
855
|
projection = 'equirectangular';
|
|
716
856
|
} else {
|
|
717
857
|
// Tight clusters AND single-continent regional views: Mercator gives every
|
|
718
|
-
// mid-latitude landmass its familiar conventional shape (
|
|
858
|
+
// mid-latitude landmass its familiar conventional shape (a world projection
|
|
719
859
|
// squashes a continent like Europe horizontally).
|
|
720
860
|
projection = 'mercator';
|
|
721
861
|
}
|
|
@@ -732,7 +872,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
732
872
|
// would slice off South Africa, southern Argentina, northern Russia, …). The
|
|
733
873
|
// ≥180° gate leaves regional spreads tight — `region` continents (Europe
|
|
734
874
|
// ≈70°, Asia ≈155°) and antimeridian clusters (mercator anyway) untouched.
|
|
735
|
-
// Applies to
|
|
875
|
+
// Applies to the equirectangular world projection (data + reference alike).
|
|
736
876
|
if (lonSpan >= 180) {
|
|
737
877
|
extent = [
|
|
738
878
|
[-180, Math.min(extent[0][1], WORLD_LAT_SOUTH)],
|
|
@@ -745,11 +885,20 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
745
885
|
result.edges = edges;
|
|
746
886
|
result.routes = routes;
|
|
747
887
|
result.basemaps = {
|
|
748
|
-
|
|
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',
|
|
749
897
|
subdivisions,
|
|
750
898
|
};
|
|
751
899
|
result.extent = extent;
|
|
752
900
|
result.projection = projection;
|
|
901
|
+
result.poiFrameContainers = containerRegionIds;
|
|
753
902
|
result.error = parsed.error ?? firstError(diagnostics);
|
|
754
903
|
// `Writable` widens the GeoExtent tuple to an array; the runtime value is a
|
|
755
904
|
// correct GeoExtent, so cast back on return (through unknown — tuple vs array).
|
package/src/map/types.ts
CHANGED
|
@@ -10,17 +10,13 @@ export type PoiPos =
|
|
|
10
10
|
| { readonly kind: 'coords'; readonly lat: number; readonly lon: number }
|
|
11
11
|
| { readonly kind: 'name'; readonly name: string; readonly scope?: string };
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** One-shot directives (§24B.2/.7). Values are raw strings unless typed. */
|
|
13
|
+
/** One-shot directives (§24B.2/.7). Values are raw strings unless typed.
|
|
14
|
+
*
|
|
15
|
+
* COSMETIC DEFAULTS ARE ON. Every basemap feature renders by default; the only
|
|
16
|
+
* control is a bare `no-*` opt-out flag that sets the matching `noXxx` boolean.
|
|
17
|
+
* Absent (undefined) = feature ON — so render gates test `!== true`, never
|
|
18
|
+
* `=== true`. There are NO positive opt-in cosmetic flags (§24B.2). */
|
|
21
19
|
export interface MapDirectives {
|
|
22
|
-
region?: string;
|
|
23
|
-
projection?: string;
|
|
24
20
|
/** Legend label for the region value ramp (`region-metric <label>`). */
|
|
25
21
|
regionMetric?: string;
|
|
26
22
|
/** Recognized color NAME for the choropleth ramp hue, peeled off the
|
|
@@ -30,30 +26,32 @@ export interface MapDirectives {
|
|
|
30
26
|
poiMetric?: string;
|
|
31
27
|
/** Legend label for the edge/leg value (thickness) channel (`flow-metric`). */
|
|
32
28
|
flowMetric?: string;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
/** Default ISO scope for bare-name resolution (§24B.8): a 3166-1 country
|
|
30
|
+
* (`locale US`) or 3166-2 subdivision (`locale US-GA`). The country part
|
|
31
|
+
* biases ambiguous bare cities to that nation; the subdivision part further
|
|
32
|
+
* prefers that state. Inferred from content; explicit only to steer a guess. */
|
|
33
|
+
locale?: string;
|
|
38
34
|
activeTag?: string;
|
|
39
|
-
noLegend?: boolean;
|
|
40
|
-
/** Suppress the Alaska & Hawaii inset boxes drawn under the `albers-usa`
|
|
41
|
-
* projection (bare flag `no-insets`). Only meaningful for the US states
|
|
42
|
-
* basemap; silently ignored under any other projection. */
|
|
43
|
-
noInsets?: boolean;
|
|
44
|
-
subtitle?: string;
|
|
45
35
|
caption?: string;
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*
|
|
56
|
-
|
|
36
|
+
/** `no-legend` — suppress the legend (default-on). */
|
|
37
|
+
noLegend?: boolean;
|
|
38
|
+
/** `no-coastline` — suppress the faint nautical-chart water-lines along
|
|
39
|
+
* coasts/shorelines (default-on; geometry derived from drawn region paths). */
|
|
40
|
+
noCoastline?: boolean;
|
|
41
|
+
/** `no-relief` — suppress mountain-range relief hachures. Relief is default-on
|
|
42
|
+
* but auto-gated to dataless reference maps at continent/world zoom (§24B.2). */
|
|
43
|
+
noRelief?: boolean;
|
|
44
|
+
/** `no-context-labels` — suppress the orientation backdrop (water-body names +
|
|
45
|
+
* unreferenced notable country names), distinct from `region-labels`. */
|
|
46
|
+
noContextLabels?: boolean;
|
|
47
|
+
/** `no-region-labels` — suppress region labels (default-on, full→abbrev→hide). */
|
|
48
|
+
noRegionLabels?: boolean;
|
|
49
|
+
/** `no-poi-labels` — suppress POI labels (default-on, collision-managed auto). */
|
|
50
|
+
noPoiLabels?: boolean;
|
|
51
|
+
/** `no-colorize` — force the plain green-land reference dress even when regions
|
|
52
|
+
* are referenced (regions are auto-coloured by default; §24B colorize). A
|
|
53
|
+
* no-op under data — the basemap is already gray there. */
|
|
54
|
+
noColorize?: boolean;
|
|
57
55
|
}
|
|
58
56
|
|
|
59
57
|
/** A region-fill: a subdivision name with an optional score and/or tag values
|
package/src/mindmap/renderer.ts
CHANGED
|
@@ -99,6 +99,9 @@ export function renderMindmap(
|
|
|
99
99
|
controlsExpanded?: boolean;
|
|
100
100
|
onToggleControlsExpand?: () => void;
|
|
101
101
|
exportMode?: boolean;
|
|
102
|
+
/** When 'app', controls (Descriptions / Depth Colors) are hosted by the app
|
|
103
|
+
* overlay strip — inline gear suppressed, controls row + anchor reserved. */
|
|
104
|
+
controlsHost?: 'app' | 'inline';
|
|
102
105
|
}
|
|
103
106
|
): void {
|
|
104
107
|
const isExport = !!exportDims;
|
|
@@ -119,9 +122,12 @@ export function renderMindmap(
|
|
|
119
122
|
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
120
123
|
.style('font-family', FONT_FAMILY);
|
|
121
124
|
|
|
125
|
+
const appHosted = options?.controlsHost === 'app';
|
|
126
|
+
// App-hosted: Descriptions / Depth Colors move to the app overlay, so a
|
|
127
|
+
// controls-only legend (no tag groups) has nothing left to render.
|
|
122
128
|
const hasControls =
|
|
123
129
|
!!options?.onToggleColorByDepth || !!options?.onToggleDescriptions;
|
|
124
|
-
const hasLegend = parsed.tagGroups.length > 0 || hasControls;
|
|
130
|
+
const hasLegend = parsed.tagGroups.length > 0 || (hasControls && !appHosted);
|
|
125
131
|
const fixedLegend = !isExport && hasLegend;
|
|
126
132
|
const legendReserve = fixedLegend
|
|
127
133
|
? getMaxLegendReservedHeight(
|
|
@@ -262,6 +268,9 @@ export function renderMindmap(
|
|
|
262
268
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
263
269
|
mode: options?.exportMode ? 'export' : 'preview',
|
|
264
270
|
...(controlsToggles !== undefined && { controlsGroup: controlsToggles }),
|
|
271
|
+
...(options?.controlsHost !== undefined && {
|
|
272
|
+
controlsHost: options.controlsHost,
|
|
273
|
+
}),
|
|
265
274
|
};
|
|
266
275
|
const legendState: LegendState = {
|
|
267
276
|
activeGroup: options?.colorByDepth
|