@diagrammo/dgmo 0.26.0 → 0.28.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 +3 -3
- package/dist/advanced.cjs +5651 -3193
- package/dist/advanced.d.cts +272 -58
- package/dist/advanced.d.ts +272 -58
- package/dist/advanced.js +5650 -3186
- package/dist/auto.cjs +5511 -3070
- package/dist/auto.js +116 -137
- package/dist/auto.mjs +5510 -3069
- package/dist/cli.cjs +168 -189
- package/dist/editor.cjs +4 -0
- package/dist/editor.js +4 -0
- package/dist/highlight.cjs +4 -0
- package/dist/highlight.js +4 -0
- package/dist/index.cjs +5536 -3072
- package/dist/index.d.cts +33 -8
- package/dist/index.d.ts +33 -8
- package/dist/index.js +5535 -3071
- package/dist/internal.cjs +5651 -3193
- package/dist/internal.d.cts +272 -58
- package/dist/internal.d.ts +272 -58
- package/dist/internal.js +5650 -3186
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/airport-collisions.json +1 -0
- package/dist/map-data/airports.json +1 -0
- package/docs/language-reference.md +68 -18
- package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
- package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
- package/gallery/fixtures/map-region-values.dgmo +13 -0
- package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
- package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
- package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
- package/package.json +7 -3
- package/src/advanced.ts +1 -6
- package/src/auto/index.ts +1 -1
- package/src/boxes-and-lines/layout-layered.ts +722 -0
- package/src/boxes-and-lines/layout-search.ts +1200 -0
- package/src/boxes-and-lines/layout.ts +202 -571
- package/src/boxes-and-lines/parser.ts +43 -8
- package/src/boxes-and-lines/renderer.ts +223 -96
- package/src/boxes-and-lines/types.ts +9 -2
- package/src/c4/layout.ts +14 -32
- package/src/c4/parser.ts +9 -5
- package/src/c4/renderer.ts +34 -39
- package/src/class/layout.ts +118 -18
- package/src/class/parser.ts +35 -0
- package/src/class/renderer.ts +58 -2
- package/src/class/types.ts +3 -0
- package/src/cli.ts +4 -4
- package/src/completion.ts +26 -12
- package/src/cycle/layout.ts +55 -72
- package/src/cycle/renderer.ts +11 -6
- package/src/d3.ts +78 -117
- package/src/diagnostics.ts +16 -0
- package/src/echarts.ts +46 -33
- package/src/editor/keywords.ts +4 -0
- package/src/er/layout.ts +114 -22
- package/src/er/parser.ts +28 -0
- package/src/er/renderer.ts +55 -2
- package/src/er/types.ts +3 -0
- package/src/gantt/renderer.ts +46 -38
- package/src/gantt/resolver.ts +9 -2
- package/src/graph/edge-spline.ts +29 -0
- package/src/graph/flowchart-parser.ts +34 -1
- package/src/graph/flowchart-renderer.ts +78 -64
- package/src/graph/layout.ts +206 -23
- package/src/graph/notes.ts +21 -0
- package/src/graph/state-parser.ts +26 -1
- package/src/graph/state-renderer.ts +78 -64
- package/src/graph/types.ts +13 -0
- package/src/index.ts +1 -1
- package/src/infra/layout.ts +46 -26
- package/src/infra/renderer.ts +16 -7
- package/src/journey-map/layout.ts +38 -49
- package/src/journey-map/renderer.ts +22 -45
- package/src/kanban/renderer.ts +15 -6
- package/src/label-layout.ts +3 -3
- package/src/map/completion.ts +77 -22
- package/src/map/context-labels.ts +101 -25
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/airport-collisions.json +1 -0
- package/src/map/data/airports.json +1 -0
- package/src/map/data/types.ts +19 -0
- package/src/map/layout.ts +1212 -96
- package/src/map/legend-band.ts +2 -2
- package/src/map/load-data.ts +10 -1
- package/src/map/parser.ts +61 -32
- package/src/map/renderer.ts +284 -12
- package/src/map/resolved-types.ts +15 -1
- package/src/map/resolver.ts +132 -12
- package/src/map/types.ts +28 -8
- package/src/migrate/embedded.ts +9 -7
- package/src/mindmap/text-wrap.ts +13 -14
- package/src/org/layout.ts +19 -17
- package/src/org/renderer.ts +11 -4
- package/src/palettes/color-utils.ts +82 -21
- package/src/palettes/index.ts +0 -19
- package/src/palettes/registry.ts +1 -1
- package/src/palettes/types.ts +2 -2
- package/src/pert/layout.ts +48 -40
- package/src/pert/renderer.ts +30 -43
- package/src/pyramid/renderer.ts +4 -5
- package/src/raci/renderer.ts +34 -68
- package/src/render.ts +1 -1
- package/src/ring/renderer.ts +1 -2
- package/src/sequence/parser.ts +100 -22
- package/src/sequence/renderer.ts +75 -50
- package/src/sitemap/layout.ts +27 -19
- package/src/sitemap/renderer.ts +12 -5
- package/src/tech-radar/renderer.ts +11 -35
- package/src/utils/arrow-markers.ts +51 -0
- package/src/utils/fit-canvas.ts +64 -0
- package/src/utils/legend-constants.ts +8 -54
- package/src/utils/legend-d3.ts +10 -7
- package/src/utils/legend-layout.ts +7 -4
- package/src/utils/legend-types.ts +10 -4
- package/src/utils/note-box/constants.ts +25 -0
- package/src/utils/note-box/index.ts +11 -0
- package/src/utils/note-box/metrics.ts +90 -0
- package/src/utils/note-box/svg.ts +331 -0
- package/src/utils/notes/bounds.ts +30 -0
- package/src/utils/notes/build.ts +131 -0
- package/src/utils/notes/index.ts +18 -0
- package/src/utils/notes/model.ts +19 -0
- package/src/utils/notes/parse.ts +131 -0
- package/src/utils/notes/place.ts +177 -0
- package/src/utils/notes/resolve.ts +88 -0
- package/src/utils/number-format.ts +36 -0
- package/src/utils/parsing.ts +41 -0
- package/src/utils/reserved-key-registry.ts +4 -0
- package/src/utils/text-measure.ts +122 -0
- package/src/wireframe/layout.ts +4 -2
- package/src/wireframe/renderer.ts +8 -6
- package/src/palettes/dracula.ts +0 -68
- package/src/palettes/gruvbox.ts +0 -85
- package/src/palettes/monokai.ts +0 -68
- package/src/palettes/one-dark.ts +0 -70
- package/src/palettes/rose-pine.ts +0 -84
- package/src/palettes/solarized.ts +0 -77
package/src/map/layout.ts
CHANGED
|
@@ -23,8 +23,9 @@ import {
|
|
|
23
23
|
contrastRatio,
|
|
24
24
|
relativeLuminance,
|
|
25
25
|
politicalTints,
|
|
26
|
+
valueRampColor,
|
|
26
27
|
} from '../palettes/color-utils';
|
|
27
|
-
import { buildAdjacency } from './geo';
|
|
28
|
+
import { buildAdjacency, featureBboxPrimary } from './geo';
|
|
28
29
|
import { assignColors } from './colorize';
|
|
29
30
|
import { resolveColor } from '../colors';
|
|
30
31
|
import type { PaletteColors } from '../palettes/types';
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
} from '../label-layout';
|
|
36
37
|
import type { LabelRect, PointCircle } from '../label-layout';
|
|
37
38
|
import { measureLegendText } from '../utils/legend-constants';
|
|
39
|
+
import { compactNumber } from '../utils/number-format';
|
|
38
40
|
import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
|
|
39
41
|
import type { LegendMode } from '../utils/legend-types';
|
|
40
42
|
import { mapLegendBand } from './legend-band';
|
|
@@ -47,6 +49,7 @@ import type {
|
|
|
47
49
|
ResolvedPoi,
|
|
48
50
|
ResolvedEdge,
|
|
49
51
|
ProjectionFamily,
|
|
52
|
+
GeoExtent,
|
|
50
53
|
} from './resolved-types';
|
|
51
54
|
import { placeContextLabels } from './context-labels';
|
|
52
55
|
import type { CountryCandidate } from './context-labels';
|
|
@@ -65,10 +68,85 @@ interface GeoFC {
|
|
|
65
68
|
|
|
66
69
|
// -- Tunable constants (deterministic; no magic at call sites) --
|
|
67
70
|
const FIT_PAD = 24; // px padding inside the viewport
|
|
71
|
+
// Fractional digits for projected path `d` coordinates. d3-geo defaults to 3
|
|
72
|
+
// (sub-micropixel at our canvas scale) — full-world detail geometry then emits
|
|
73
|
+
// multi-MB SVGs that bloat the page and overflow downstream HTML reparsers.
|
|
74
|
+
// One decimal is 0.1px: visually identical, ~half the coordinate bytes.
|
|
75
|
+
const PATH_DIGITS = 1;
|
|
76
|
+
|
|
77
|
+
// Screen-space vertex tolerance for thinning (px). Projected points within this
|
|
78
|
+
// distance of the previously kept point are dropped. Sub-pixel, so invisible.
|
|
79
|
+
const THIN_TOL = 0.6;
|
|
80
|
+
|
|
81
|
+
interface ThinStream {
|
|
82
|
+
stream: {
|
|
83
|
+
point(x: number, y: number): void;
|
|
84
|
+
lineStart(): void;
|
|
85
|
+
lineEnd(): void;
|
|
86
|
+
};
|
|
87
|
+
_has?: boolean;
|
|
88
|
+
_pending?: boolean;
|
|
89
|
+
_ex?: number;
|
|
90
|
+
_ey?: number;
|
|
91
|
+
_lx?: number;
|
|
92
|
+
_ly?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A geoTransform that thins projected vertices in screen space: it forwards a
|
|
97
|
+
* point only when it lies more than THIN_TOL px from the last forwarded point.
|
|
98
|
+
* Inserted just before the path serializer so it sees final screen coordinates,
|
|
99
|
+
* it is scale-aware by construction — at world scale the dense 50m coastline
|
|
100
|
+
* collapses to a few px-spaced vertices (the multi-MB bloat that overflows the
|
|
101
|
+
* SSG HTML reparse), while a regional zoom spreads the same coastline over many
|
|
102
|
+
* px so almost nothing is dropped (full detail preserved). The last vertex of
|
|
103
|
+
* every ring is always emitted so polygon fills stay gap-free.
|
|
104
|
+
*/
|
|
105
|
+
function geoThin(): ReturnType<typeof geoTransform> {
|
|
106
|
+
const tol2 = THIN_TOL * THIN_TOL;
|
|
107
|
+
return geoTransform({
|
|
108
|
+
lineStart(this: unknown) {
|
|
109
|
+
const t = this as ThinStream;
|
|
110
|
+
t._has = false;
|
|
111
|
+
t._pending = false;
|
|
112
|
+
t.stream.lineStart();
|
|
113
|
+
},
|
|
114
|
+
point(this: unknown, x: number, y: number) {
|
|
115
|
+
const t = this as ThinStream;
|
|
116
|
+
t._lx = x;
|
|
117
|
+
t._ly = y;
|
|
118
|
+
if (t._has) {
|
|
119
|
+
const dx = x - (t._ex as number);
|
|
120
|
+
const dy = y - (t._ey as number);
|
|
121
|
+
if (dx * dx + dy * dy < tol2) {
|
|
122
|
+
t._pending = true;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
t.stream.point(x, y);
|
|
127
|
+
t._ex = x;
|
|
128
|
+
t._ey = y;
|
|
129
|
+
t._has = true;
|
|
130
|
+
t._pending = false;
|
|
131
|
+
},
|
|
132
|
+
lineEnd(this: unknown) {
|
|
133
|
+
const t = this as ThinStream;
|
|
134
|
+
if (t._pending) t.stream.point(t._lx as number, t._ly as number);
|
|
135
|
+
t._pending = false;
|
|
136
|
+
t.stream.lineEnd();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
68
140
|
const RAMP_FLOOR = 15; // % tint floor so min still reads as "low, present" (24B.3)
|
|
69
141
|
const R_DEFAULT = 6; // POI radius without size:
|
|
70
142
|
const R_MIN = 4;
|
|
71
143
|
const R_MAX = 22;
|
|
144
|
+
// Larger POIs fade their FILL so big bubbles read as light/airy instead of heavy
|
|
145
|
+
// solid slabs (overlaps stay legible); the stroke stays fully opaque so every
|
|
146
|
+
// marker keeps a crisp edge regardless of size. Gentle so the largest (= most
|
|
147
|
+
// important) marker still reads. Linear in radius over [R_MIN, R_MAX].
|
|
148
|
+
const POI_FILL_OPACITY_MAX = 0.92; // at R_MIN (smallest)
|
|
149
|
+
const POI_FILL_OPACITY_MIN = 0.55; // at R_MAX (largest)
|
|
72
150
|
const W_MIN = 1.25; // edge stroke width
|
|
73
151
|
const W_MAX = 8;
|
|
74
152
|
const FONT = 11; // on-map label font px
|
|
@@ -81,6 +159,13 @@ const FONT = 11; // on-map label font px
|
|
|
81
159
|
// previously used for hover) mistook the wrapped sliver for half the shape.
|
|
82
160
|
const WORLD_LABEL_ANCHORS: Record<string, [number, number]> = {
|
|
83
161
|
US: [-98.5, 39.5], // CONUS geographic centre (near Lebanon, Kansas)
|
|
162
|
+
// Russia crosses the antimeridian (Chukotka at ~170°W), so on a non-global
|
|
163
|
+
// (e.g. Europe) projection its geometry smears across the whole frame and the
|
|
164
|
+
// area-weighted centroid lands mid-map (over Europe) — useless as a label
|
|
165
|
+
// anchor. Pin it to European Russia (~Volga) so a Europe view labels visible
|
|
166
|
+
// western Russia on its eastern margin; on a world view this still sits over
|
|
167
|
+
// Russian land. (See the curated-anchor smear-gate bypass in context-labels.)
|
|
168
|
+
RU: [45, 58],
|
|
84
169
|
};
|
|
85
170
|
// POI-cluster hover-only gate (Decision #1). A ≥2-member cluster's callout
|
|
86
171
|
// column falls back to hover-only labels when it would sprawl or overflow:
|
|
@@ -263,6 +348,9 @@ export interface MapLayoutPoi {
|
|
|
263
348
|
readonly cy: number;
|
|
264
349
|
readonly r: number;
|
|
265
350
|
readonly fill: string;
|
|
351
|
+
/** Fill opacity scaled by radius — larger bubbles fade so they read as light
|
|
352
|
+
* rather than heavy. Stroke stays fully opaque (crisp edge at every size). */
|
|
353
|
+
readonly fillOpacity: number;
|
|
266
354
|
readonly stroke: string;
|
|
267
355
|
readonly lineNumber: number;
|
|
268
356
|
readonly implicit: boolean;
|
|
@@ -311,6 +399,15 @@ export interface MapLayoutLeg {
|
|
|
311
399
|
readonly width: number;
|
|
312
400
|
readonly color: string;
|
|
313
401
|
readonly arrow: boolean;
|
|
402
|
+
/** Endpoint POI ids (resolved `fromId`/`toId`), emitted as `data-from-id` /
|
|
403
|
+
* `data-to-id`. Lets an interactive preview co-highlight a leg's two endpoint
|
|
404
|
+
* POIs when the leg is focused (§17 sync). */
|
|
405
|
+
readonly fromId: string;
|
|
406
|
+
readonly toId: string;
|
|
407
|
+
/** Tag values (keyed by lowercased group name) — emitted as `data-tag-*`, like
|
|
408
|
+
* POI markers, so a legend-entry hover spotlights only the matching lines
|
|
409
|
+
* (§24B.6). Omitted when the leg carries no tag. */
|
|
410
|
+
readonly tags?: Readonly<Record<string, string>>;
|
|
314
411
|
readonly label?: string;
|
|
315
412
|
readonly labelX?: number;
|
|
316
413
|
readonly labelY?: number;
|
|
@@ -345,6 +442,11 @@ export interface PlacedLabel {
|
|
|
345
442
|
/** The POI this label belongs to (POI labels only) — emitted as `data-poi` on
|
|
346
443
|
* the label + leader so the app can spotlight the dot on label hover. */
|
|
347
444
|
readonly poiId?: string;
|
|
445
|
+
/** Per-label font size in px. Set on context COUNTRY labels, which scale up with
|
|
446
|
+
* their projected footprint (a big country reads as a faded backdrop name, a
|
|
447
|
+
* small one stays at the base label font). Absent ⇒ the renderer's default
|
|
448
|
+
* LABEL_FONT, so every other label type renders byte-identically. */
|
|
449
|
+
readonly fontSize?: number;
|
|
348
450
|
/** Cartographic italic (context-label water names, §24B). Default upright. */
|
|
349
451
|
readonly italic?: boolean;
|
|
350
452
|
/** Cartographic letter-spacing in px (context-label water names). Default 0. */
|
|
@@ -363,6 +465,16 @@ export interface PlacedLabel {
|
|
|
363
465
|
* visible (export + expanded view) but tagged `data-cluster-member` so the app
|
|
364
466
|
* hides it when the stack is collapsed to its badge. */
|
|
365
467
|
readonly clusterMember?: string;
|
|
468
|
+
/** A choropleth region's metric VALUE (already compact-formatted, e.g. `39.5M`),
|
|
469
|
+
* drawn as a smaller, dimmer second line UNDER `text` (the region name). Set
|
|
470
|
+
* only on region labels of a `region-metric` map when `no-region-value` is off.
|
|
471
|
+
* The renderer stacks it as a sub-line; absent ⇒ single name line. */
|
|
472
|
+
readonly valueLine?: string;
|
|
473
|
+
/** A region too small to carry its name+value stack in place gets a leader-lined
|
|
474
|
+
* callout in a margin column; this marks the region's true centroid so the
|
|
475
|
+
* renderer draws a small anchor dot there (the leader runs dot → chip). The
|
|
476
|
+
* colour is the region's fill, tying the dot/leader/chip together. */
|
|
477
|
+
readonly calloutDot?: { x: number; y: number; color: string };
|
|
366
478
|
readonly lineNumber: number;
|
|
367
479
|
}
|
|
368
480
|
|
|
@@ -372,6 +484,15 @@ export interface PlacedLabel {
|
|
|
372
484
|
// value-imports mapLegendBand from ./legend-band).
|
|
373
485
|
export type { MapLayoutLegend };
|
|
374
486
|
|
|
487
|
+
/** A subtle gazetteer city dot for basemap orientation (§24B `no-cities`). Just
|
|
488
|
+
* a position + radius; the renderer paints it muted/low-opacity. No label, no
|
|
489
|
+
* interactivity — purely decorative context. */
|
|
490
|
+
export interface MapLayoutCityDot {
|
|
491
|
+
readonly cx: number;
|
|
492
|
+
readonly cy: number;
|
|
493
|
+
readonly r: number;
|
|
494
|
+
}
|
|
495
|
+
|
|
375
496
|
/** A drawn river centerline — an open stroked path (no fill). */
|
|
376
497
|
export interface MapLayoutRiver {
|
|
377
498
|
readonly d: string;
|
|
@@ -439,6 +560,9 @@ export interface MapLayout {
|
|
|
439
560
|
readonly coastlineStyle: MapLayoutCoastlineStyle | null;
|
|
440
561
|
readonly legs: readonly MapLayoutLeg[];
|
|
441
562
|
readonly pois: readonly MapLayoutPoi[];
|
|
563
|
+
/** Subtle gazetteer city dots for orientation (empty when `no-cities` or no
|
|
564
|
+
* cities fall on-canvas). Drawn over the basemap, under connectors/POIs. */
|
|
565
|
+
readonly cityDots: readonly MapLayoutCityDot[];
|
|
442
566
|
/** Coincident POI stacks (spiderfy). Empty when no ≥2-member overlap exists.
|
|
443
567
|
* The renderer draws a collapsed badge per stack; the app collapses/expands. */
|
|
444
568
|
readonly clusters: readonly MapLayoutCluster[];
|
|
@@ -479,6 +603,28 @@ export interface LayoutOptions {
|
|
|
479
603
|
* `'preview'` keeps inactive pills. Used to size the reserved legend band so
|
|
480
604
|
* the projected land starts below the legend. Defaults to `'preview'`. */
|
|
481
605
|
readonly legendMode?: LegendMode;
|
|
606
|
+
/** INTERNAL (set by layoutMap's own second pass — do not pass in). When tiny
|
|
607
|
+
* valued regions need margin callouts, the first pass measures them and
|
|
608
|
+
* re-runs with reserved bands: the projection fits into the canvas MINUS these
|
|
609
|
+
* bands so the data shrinks/shifts inward, opening label room. A cluster on
|
|
610
|
+
* EACH side reserves its own band (px), so tiny regions on both coasts each get
|
|
611
|
+
* a column. An absent side reserves nothing there. Also carries the POI
|
|
612
|
+
* edge-clearance bands (any of the four sides) measured by the POI-label pass
|
|
613
|
+
* (same fit-box mechanism). Region callouts only ever set left/right. */
|
|
614
|
+
readonly _calloutReserve?: {
|
|
615
|
+
left?: number;
|
|
616
|
+
right?: number;
|
|
617
|
+
top?: number;
|
|
618
|
+
bottom?: number;
|
|
619
|
+
};
|
|
620
|
+
/** INTERNAL (set by layoutMap's own POI-clearance pass — do not pass in). After
|
|
621
|
+
* POI-label placement, any POI dot/label crossing the edge-clearance band
|
|
622
|
+
* triggers a re-fit that ADDS the residual intrusion to the reserved band on
|
|
623
|
+
* that side, sliding the data inward. Re-measured each pass and accumulated
|
|
624
|
+
* until nothing intrudes (or the pass cap), so a tight cluster on a small canvas
|
|
625
|
+
* converges instead of giving up after one under-shoot. This counts the passes
|
|
626
|
+
* taken to bound the recursion. */
|
|
627
|
+
readonly _poiClearancePass?: number;
|
|
482
628
|
}
|
|
483
629
|
|
|
484
630
|
interface Size {
|
|
@@ -559,10 +705,23 @@ const alaskaProjection = (): GeoProjection =>
|
|
|
559
705
|
geoConicEqualArea().rotate([154, 0]).center([-2, 58.5]).parallels([55, 65]);
|
|
560
706
|
const hawaiiProjection = (): GeoProjection => geoMercator();
|
|
561
707
|
|
|
562
|
-
function projectionFor(
|
|
708
|
+
function projectionFor(
|
|
709
|
+
family: ProjectionFamily,
|
|
710
|
+
extent: GeoExtent
|
|
711
|
+
): GeoProjection {
|
|
563
712
|
switch (family) {
|
|
564
713
|
case 'albers-usa':
|
|
565
714
|
return usConusProjection();
|
|
715
|
+
case 'conic-equal-area': {
|
|
716
|
+
// Albers for a single continent: standard parallels at 1/6 and 5/6 of the
|
|
717
|
+
// extent's latitude band (distortion-minimizing), centered on the band's
|
|
718
|
+
// mid-latitude. Longitude centering is handled by the shared .rotate below.
|
|
719
|
+
const s = extent[0][1];
|
|
720
|
+
const n = extent[1][1];
|
|
721
|
+
return geoConicEqualArea()
|
|
722
|
+
.parallels([s + (n - s) / 6, s + ((n - s) * 5) / 6])
|
|
723
|
+
.center([0, (s + n) / 2]);
|
|
724
|
+
}
|
|
566
725
|
case 'mercator':
|
|
567
726
|
return geoMercator();
|
|
568
727
|
case 'equal-earth':
|
|
@@ -700,8 +859,9 @@ export function buildMapProjection(
|
|
|
700
859
|
// 50m/110m — visibly coarser than the 10m states. When the NA-clipped 10m
|
|
701
860
|
// assets are present, swap them in so neighbours (Canada/Mexico) and the Great
|
|
702
861
|
// Lakes match the states' resolution. Falls back to the world tiers otherwise.
|
|
703
|
-
// Crisp NA assets apply to BOTH the national albers-usa view AND a regional
|
|
704
|
-
//
|
|
862
|
+
// Crisp NA assets apply to BOTH the national albers-usa view AND a regional US
|
|
863
|
+
// mercator view (POI-only region framing — e.g. a single state — OR a compact
|
|
864
|
+
// region/choropleth that auto-zooms; map-us-subnational-zoom, both mercator). A
|
|
705
865
|
// US-oriented mercator frame is sub-world and entirely within North America by
|
|
706
866
|
// construction, so the NA-clipped 10m land/lakes fit it; the bbox guard below
|
|
707
867
|
// still keeps non-NA countries on world geometry. Excludes equirectangular
|
|
@@ -795,7 +955,7 @@ export function buildMapProjection(
|
|
|
795
955
|
}
|
|
796
956
|
const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
|
|
797
957
|
|
|
798
|
-
const projection = projectionFor(resolved.projection);
|
|
958
|
+
const projection = projectionFor(resolved.projection, resolved.extent);
|
|
799
959
|
// mercator / natural-earth: rotate to the extent's center longitude BEFORE
|
|
800
960
|
// fitting (rotate changes the bounds fitExtent measures). albers-usa is a
|
|
801
961
|
// US-only composite with NO .rotate -- never call it (AR2).
|
|
@@ -936,11 +1096,14 @@ export function layoutMap(
|
|
|
936
1096
|
const usContext = usLayer !== null;
|
|
937
1097
|
// Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
|
|
938
1098
|
// colouring dimension is active — defined below, once `activeGroup` is known.
|
|
939
|
-
// Region borders: a
|
|
940
|
-
//
|
|
941
|
-
//
|
|
1099
|
+
// Region borders. Light theme: a near-text dark outline (a dark hairline
|
|
1100
|
+
// reads well over the pale ground). Dark theme: a near-bg dark outline
|
|
1101
|
+
// vanishes against the deep ground, so instead lean on the palette's
|
|
1102
|
+
// dedicated `border` grid-line token (tuned to pop against that ground) and
|
|
1103
|
+
// nudge it toward `text` for a touch more lift — a visible boundary that
|
|
1104
|
+
// still reads as a line, not a glaring white seam over the land fills.
|
|
942
1105
|
const regionStroke = isDark
|
|
943
|
-
? mix(palette.
|
|
1106
|
+
? mix(palette.border, palette.text, 65) // dark theme: lifted grid-line
|
|
944
1107
|
: mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
|
|
945
1108
|
// Lake shoreline. Lakes are painted as water OVER the land and the region
|
|
946
1109
|
// borders, so without an edge they read as a featureless patch that simply
|
|
@@ -955,13 +1118,14 @@ export function layoutMap(
|
|
|
955
1118
|
const values = resolved.regions
|
|
956
1119
|
.filter((r) => r.value !== undefined)
|
|
957
1120
|
.map((r) => r.value!);
|
|
958
|
-
// Ramp auto-fits (the `scale` directive is gone)
|
|
959
|
-
// low end anchors at
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
|
|
964
|
-
|
|
1121
|
+
// Ramp auto-fits (the `scale` directive is gone) to data-min→data-max — the
|
|
1122
|
+
// low end anchors at the lowest value, not 0. This maximises within-map
|
|
1123
|
+
// dynamic range and matches the size/thickness metric ramps (poi-metric,
|
|
1124
|
+
// flow-metric), which already floor at their data minimum. Cross-map low-end
|
|
1125
|
+
// comparability (the old 0-anchor, "decision C") is intentionally dropped: a
|
|
1126
|
+
// shared baseline only helped side-by-side maps and flattened single-map
|
|
1127
|
+
// contrast. Equal-value data (rampMin === rampMax) falls back to t = 1 below.
|
|
1128
|
+
const rampMin = values.length > 0 ? Math.min(...values) : 0;
|
|
965
1129
|
const rampMax = Math.max(...values);
|
|
966
1130
|
// Value ramp defaults to red so valued regions stand out against the blue
|
|
967
1131
|
// water (palette.primary is a blue in most palettes and would blend in). A
|
|
@@ -969,6 +1133,13 @@ export function layoutMap(
|
|
|
969
1133
|
const rampHue =
|
|
970
1134
|
resolveColor(resolved.directives.regionMetricColor ?? '', palette) ??
|
|
971
1135
|
palette.colors.red;
|
|
1136
|
+
// Explicit LOW endpoint (`region-metric Sales green red`). Only the 11
|
|
1137
|
+
// recognized names peel, so resolveColor always succeeds when a name is
|
|
1138
|
+
// present; absent ⇒ single-colour behaviour (neutral low). §24B.3.
|
|
1139
|
+
const rampLow = resolved.directives.regionMetricLowColor
|
|
1140
|
+
? (resolveColor(resolved.directives.regionMetricLowColor, palette) ??
|
|
1141
|
+
undefined)
|
|
1142
|
+
: undefined;
|
|
972
1143
|
const hasRamp = values.length > 0;
|
|
973
1144
|
|
|
974
1145
|
// Colouring dimension (AR4, bivariate): the value ramp and each tag group are
|
|
@@ -987,6 +1158,21 @@ export function layoutMap(
|
|
|
987
1158
|
const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
|
|
988
1159
|
return tg ? tg.name : v; // unknown name passes through → renders neutral
|
|
989
1160
|
};
|
|
1161
|
+
// A tag group is a "fill group" only if its alias actually lands on a region
|
|
1162
|
+
// or a POI. A group used solely on connector lines (§24B.6) colours edges,
|
|
1163
|
+
// never the basemap — so it must not drive the region/active-tag dress or
|
|
1164
|
+
// suppress colorize.
|
|
1165
|
+
const fillGroupNames = new Set<string>();
|
|
1166
|
+
for (const g of resolved.tagGroups) {
|
|
1167
|
+
const k = g.name.toLowerCase();
|
|
1168
|
+
if (
|
|
1169
|
+
resolved.regions.some((r) => r.tags[k]) ||
|
|
1170
|
+
resolved.pois.some((p) => p.tags[k])
|
|
1171
|
+
)
|
|
1172
|
+
fillGroupNames.add(g.name);
|
|
1173
|
+
}
|
|
1174
|
+
const firstFillGroup =
|
|
1175
|
+
resolved.tagGroups.find((g) => fillGroupNames.has(g.name))?.name ?? null;
|
|
990
1176
|
const override = opts.activeGroup; // string | null | undefined
|
|
991
1177
|
let activeGroup: string | null;
|
|
992
1178
|
if (override !== undefined) {
|
|
@@ -995,21 +1181,25 @@ export function layoutMap(
|
|
|
995
1181
|
activeGroup = matchColorGroup(resolved.directives.activeTag);
|
|
996
1182
|
} else {
|
|
997
1183
|
// Default: colour by the value ramp when values exist, else the first
|
|
998
|
-
// declared tag group.
|
|
1184
|
+
// declared tag group that fills a region/POI. When the only groups are
|
|
1185
|
+
// edge/leg groups (no fill group), fall back to the first declared group so
|
|
1186
|
+
// the legend still renders it as a line-colour KEY — but it won't mute the
|
|
1187
|
+
// basemap (see mutedBasemap below) since it fills no region.
|
|
999
1188
|
activeGroup =
|
|
1000
|
-
VALUE_NAME ??
|
|
1001
|
-
(resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
|
|
1189
|
+
VALUE_NAME ?? firstFillGroup ?? resolved.tagGroups[0]?.name ?? null;
|
|
1002
1190
|
}
|
|
1003
1191
|
const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
|
|
1004
1192
|
|
|
1005
1193
|
// Basemap dress (fixed automatic aesthetic — no directive). Subject water +
|
|
1006
1194
|
// land always wear the SAME faded blue/green dress (subtle enough that
|
|
1007
1195
|
// saturated tag/score tints never blend into it), so every map looks
|
|
1008
|
-
// consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a
|
|
1009
|
-
// dimension is active the surrounding world recedes to a paler gray so
|
|
1010
|
-
// subject + its data fills dominate; a plain reference map
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1196
|
+
// consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a REGION-
|
|
1197
|
+
// filling dimension is active the surrounding world recedes to a paler gray so
|
|
1198
|
+
// the subject + its data fills dominate; a plain reference map — or one whose
|
|
1199
|
+
// only tag group colours connector LINES (§24B.6), not regions — keeps
|
|
1200
|
+
// neighbour land at the fuller gray.
|
|
1201
|
+
const mutedBasemap =
|
|
1202
|
+
activeIsScore || (activeGroup !== null && fillGroupNames.has(activeGroup));
|
|
1013
1203
|
const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
|
|
1014
1204
|
const water = mapBackgroundColor(palette, isDark, mutedBasemap);
|
|
1015
1205
|
const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
|
|
@@ -1043,7 +1233,7 @@ export function layoutMap(
|
|
|
1043
1233
|
resolved.directives.noColorize !== true &&
|
|
1044
1234
|
!hasRamp &&
|
|
1045
1235
|
!hasDirectColor &&
|
|
1046
|
-
|
|
1236
|
+
fillGroupNames.size === 0;
|
|
1047
1237
|
// Hue per ISO over ONE UNIFIED graph spanning every drawn topology, so no two
|
|
1048
1238
|
// bordering regions share a hue — INCLUDING across the international seam. The
|
|
1049
1239
|
// world and us-states topologies share no TopoJSON arcs, so neighbors() is blind
|
|
@@ -1100,8 +1290,16 @@ export function layoutMap(
|
|
|
1100
1290
|
// off the near-black surface so the lowest scores read as a clear muted red
|
|
1101
1291
|
// rather than sinking to maroon-black.
|
|
1102
1292
|
const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
|
|
1293
|
+
// Floored neutral the single-colour ramp blends up from — also the LOW
|
|
1294
|
+
// endpoint the legend shows when no explicit low colour was given.
|
|
1295
|
+
const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
|
|
1103
1296
|
const fillForValue = (s: number): string => {
|
|
1104
1297
|
const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
|
|
1298
|
+
// Two-colour ramp: shared low→high interpolation (direct or via midpoint).
|
|
1299
|
+
if (rampLow !== undefined)
|
|
1300
|
+
return valueRampColor(rampLow, rampHue, t, { isDark });
|
|
1301
|
+
// Single/zero-colour ramp: byte-identical to pre-change output — feed `mix`
|
|
1302
|
+
// the SAME numeric pct (NO float round-trip, which could drift a channel).
|
|
1105
1303
|
const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
|
|
1106
1304
|
return mix(rampHue, rampBase, pct);
|
|
1107
1305
|
};
|
|
@@ -1190,8 +1388,8 @@ export function layoutMap(
|
|
|
1190
1388
|
}),
|
|
1191
1389
|
min: rampMin,
|
|
1192
1390
|
max: rampMax,
|
|
1193
|
-
|
|
1194
|
-
|
|
1391
|
+
low: rampLow ?? rampLowFloor,
|
|
1392
|
+
high: rampHue,
|
|
1195
1393
|
},
|
|
1196
1394
|
}),
|
|
1197
1395
|
};
|
|
@@ -1228,15 +1426,70 @@ export function layoutMap(
|
|
|
1228
1426
|
hasSubtitle: Boolean(resolved.subtitle),
|
|
1229
1427
|
});
|
|
1230
1428
|
if (legendBand > topPad) topPad = legendBand;
|
|
1429
|
+
// Reserve a side band for margin callouts (second pass only): the projection
|
|
1430
|
+
// fits into the canvas MINUS this band, so the data shrinks and slides away
|
|
1431
|
+
// from that edge, opening room for the callout chips + leaders.
|
|
1432
|
+
const reserve = opts._calloutReserve;
|
|
1433
|
+
const fitLeft = FIT_PAD + (reserve?.left ?? 0);
|
|
1434
|
+
const fitRight = width - FIT_PAD - (reserve?.right ?? 0);
|
|
1435
|
+
const fitTop = topPad + (reserve?.top ?? 0);
|
|
1436
|
+
const fitBottom = height - FIT_PAD - (reserve?.bottom ?? 0);
|
|
1231
1437
|
const fitBox: [[number, number], [number, number]] = [
|
|
1232
|
-
[
|
|
1233
|
-
[
|
|
1234
|
-
Math.max(FIT_PAD + 1, width - FIT_PAD),
|
|
1235
|
-
Math.max(topPad + 1, height - FIT_PAD),
|
|
1236
|
-
],
|
|
1438
|
+
[fitLeft, fitTop],
|
|
1439
|
+
[Math.max(fitLeft + 1, fitRight), Math.max(fitTop + 1, fitBottom)],
|
|
1237
1440
|
];
|
|
1238
1441
|
projection.fitExtent(fitBox, fitTarget as never);
|
|
1239
1442
|
|
|
1443
|
+
// Data-centered vertical fit (regional region-maps only). `fitExtent` centers
|
|
1444
|
+
// the EXTENT rectangle in the box; when a choropleth's data clusters away from
|
|
1445
|
+
// that rectangle's vertical center it lands off-center — e.g. a Europe map's
|
|
1446
|
+
// colored countries are mostly central/southern, but Sweden drags the extent's
|
|
1447
|
+
// north edge into empty Arctic, so the data sits low under a band of ocean.
|
|
1448
|
+
// Shift the projection vertically so the data's vertical SPAN is centered in the
|
|
1449
|
+
// fit box, CLAMPED so the data still fits inside the box (we never push a colored
|
|
1450
|
+
// region off-frame). The span comes from each region's PRIMARY landmass bbox
|
|
1451
|
+
// (featureBboxPrimary) — NOT the full feature, whose detached overseas
|
|
1452
|
+
// territories (French Guiana, the Canaries, the Dutch Caribbean) would project
|
|
1453
|
+
// far off-frame and wreck the bounds. POI-only regional frames are already
|
|
1454
|
+
// cluster-centered (container + zoom floor) and the albers-usa composite frames
|
|
1455
|
+
// the nation itself — both skip this.
|
|
1456
|
+
if (
|
|
1457
|
+
!fitIsGlobal &&
|
|
1458
|
+
resolved.projection !== 'albers-usa' &&
|
|
1459
|
+
resolved.regions.length > 0
|
|
1460
|
+
) {
|
|
1461
|
+
let yMin = Infinity;
|
|
1462
|
+
let yMax = -Infinity;
|
|
1463
|
+
for (const r of resolved.regions) {
|
|
1464
|
+
const bb = r.iso ? featureBboxPrimary(data.worldCoarse, r.iso) : null;
|
|
1465
|
+
if (!bb) continue;
|
|
1466
|
+
for (const lon of [bb[0][0], bb[1][0]]) {
|
|
1467
|
+
for (const lat of [bb[0][1], bb[1][1]]) {
|
|
1468
|
+
const p = projection([lon, lat]);
|
|
1469
|
+
if (p && Number.isFinite(p[1])) {
|
|
1470
|
+
if (p[1] < yMin) yMin = p[1];
|
|
1471
|
+
if (p[1] > yMax) yMax = p[1];
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
if (yMin < yMax) {
|
|
1477
|
+
const boxTop = fitTop;
|
|
1478
|
+
const boxBottom = fitBottom;
|
|
1479
|
+
// Center the data's vertical span; the bbox midpoint balances the northern
|
|
1480
|
+
// and southern extremes evenly (an area-weighted centroid would skew toward
|
|
1481
|
+
// the larger landmasses and over-shoot the frame).
|
|
1482
|
+
let dy = (boxTop + boxBottom) / 2 - (yMin + yMax) / 2;
|
|
1483
|
+
// Clamp so the data span stays within [boxTop, boxBottom]; if it is taller
|
|
1484
|
+
// than the box, the midpoint target already gives symmetric overflow.
|
|
1485
|
+
const minDy = boxTop - yMin;
|
|
1486
|
+
const maxDy = boxBottom - yMax;
|
|
1487
|
+
if (minDy <= maxDy) dy = Math.max(minDy, Math.min(maxDy, dy));
|
|
1488
|
+
const [tx, ty] = projection.translate();
|
|
1489
|
+
projection.translate([tx, ty + dy]);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1240
1493
|
// Global views stretch-fill the canvas. A whole-world map is ~2:1 but the
|
|
1241
1494
|
// preview pane is often near-square, so the honest contain-fit letterboxes it
|
|
1242
1495
|
// with large water bands. For GLOBAL extents we stretch the PROJECTED geometry
|
|
@@ -1292,12 +1545,15 @@ export function layoutMap(
|
|
|
1292
1545
|
).stream.point(px, py);
|
|
1293
1546
|
},
|
|
1294
1547
|
});
|
|
1548
|
+
const thin = geoThin();
|
|
1295
1549
|
path = geoPath({
|
|
1296
1550
|
stream: (s: never) =>
|
|
1297
1551
|
baseProjection.stream(
|
|
1298
|
-
(tx as unknown as { stream: (d: never) => never }).stream(
|
|
1552
|
+
(tx as unknown as { stream: (d: never) => never }).stream(
|
|
1553
|
+
(thin as unknown as { stream: (d: never) => never }).stream(s)
|
|
1554
|
+
)
|
|
1299
1555
|
),
|
|
1300
|
-
} as never);
|
|
1556
|
+
} as never).digits(PATH_DIGITS);
|
|
1301
1557
|
project = (lon, lat) => {
|
|
1302
1558
|
const p = baseProjection([lon, lat]);
|
|
1303
1559
|
return p ? stretch(p[0], p[1]) : null;
|
|
@@ -1316,7 +1572,13 @@ export function layoutMap(
|
|
|
1316
1572
|
[0, 0],
|
|
1317
1573
|
[width, height],
|
|
1318
1574
|
]);
|
|
1319
|
-
|
|
1575
|
+
const thin = geoThin();
|
|
1576
|
+
path = geoPath({
|
|
1577
|
+
stream: (s: never) =>
|
|
1578
|
+
projection.stream(
|
|
1579
|
+
(thin as unknown as { stream: (d: never) => never }).stream(s)
|
|
1580
|
+
),
|
|
1581
|
+
} as never).digits(PATH_DIGITS);
|
|
1320
1582
|
project = (lon, lat) => projection([lon, lat]) ?? null;
|
|
1321
1583
|
}
|
|
1322
1584
|
|
|
@@ -1431,7 +1693,8 @@ export function layoutMap(
|
|
|
1431
1693
|
],
|
|
1432
1694
|
f as never
|
|
1433
1695
|
);
|
|
1434
|
-
const
|
|
1696
|
+
const insetPath = geoPath(proj).digits(PATH_DIGITS);
|
|
1697
|
+
const d = insetPath(f as never) ?? '';
|
|
1435
1698
|
if (!d) return xr;
|
|
1436
1699
|
// Neighbour land projected with this same fitted projection, clipped to the
|
|
1437
1700
|
// box. Alaska's only land neighbour is Canada; drawing it behind AK turns
|
|
@@ -1440,7 +1703,7 @@ export function layoutMap(
|
|
|
1440
1703
|
let contextLand: { d: string; fill: string } | undefined;
|
|
1441
1704
|
if (iso === 'US-AK') {
|
|
1442
1705
|
const can = worldLayer.get('CA');
|
|
1443
|
-
const cd = can ? (
|
|
1706
|
+
const cd = can ? (insetPath(can as never) ?? '') : '';
|
|
1444
1707
|
if (cd)
|
|
1445
1708
|
contextLand = {
|
|
1446
1709
|
d: cd,
|
|
@@ -2003,6 +2266,13 @@ export function layoutMap(
|
|
|
2003
2266
|
: 1;
|
|
2004
2267
|
return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
|
|
2005
2268
|
};
|
|
2269
|
+
// Fade the fill as the bubble grows (stroke handled separately at render).
|
|
2270
|
+
const fillOpacityFor = (r: number): number => {
|
|
2271
|
+
const t = Math.max(0, Math.min(1, (r - R_MIN) / (R_MAX - R_MIN)));
|
|
2272
|
+
return (
|
|
2273
|
+
POI_FILL_OPACITY_MAX - t * (POI_FILL_OPACITY_MAX - POI_FILL_OPACITY_MIN)
|
|
2274
|
+
);
|
|
2275
|
+
};
|
|
2006
2276
|
|
|
2007
2277
|
// POI fill precedence (§24B.5): a direct §1.5 trailing color wins, then the
|
|
2008
2278
|
// FIRST declared tag group for which the POI has a value (AR4), then orange.
|
|
@@ -2028,6 +2298,21 @@ export function layoutMap(
|
|
|
2028
2298
|
};
|
|
2029
2299
|
};
|
|
2030
2300
|
|
|
2301
|
+
// Connector colour (§24B.6): a tag on the edge/leg LINE colours the line. Walk
|
|
2302
|
+
// the declared tag groups (first match wins, like poiFill) and return its hex,
|
|
2303
|
+
// or null → caller falls back to the neutral connector mix.
|
|
2304
|
+
const lineColor = (tags: Readonly<Record<string, string>>): string | null => {
|
|
2305
|
+
for (const group of resolved.tagGroups) {
|
|
2306
|
+
const val = tags[group.name.toLowerCase()];
|
|
2307
|
+
if (!val) continue;
|
|
2308
|
+
const entry = group.entries.find(
|
|
2309
|
+
(e) => e.value.toLowerCase() === val.toLowerCase()
|
|
2310
|
+
);
|
|
2311
|
+
if (entry?.color) return entry.color; // already hex (parser-resolved)
|
|
2312
|
+
}
|
|
2313
|
+
return null;
|
|
2314
|
+
};
|
|
2315
|
+
|
|
2031
2316
|
// Route metadata first so POIs know origin/number.
|
|
2032
2317
|
const routeNumberById = new Map<string, number>();
|
|
2033
2318
|
const originIds = new Set<string>();
|
|
@@ -2060,14 +2345,16 @@ export function layoutMap(
|
|
|
2060
2345
|
clusterId?: string
|
|
2061
2346
|
): void => {
|
|
2062
2347
|
const { fill, stroke } = poiFill(e.p);
|
|
2063
|
-
|
|
2348
|
+
const r = radiusFor(e.p);
|
|
2349
|
+
poiScreen.set(e.p.id, { cx, cy, r });
|
|
2064
2350
|
const num = routeNumberById.get(e.p.id);
|
|
2065
2351
|
pois.push({
|
|
2066
2352
|
id: e.p.id,
|
|
2067
2353
|
cx,
|
|
2068
2354
|
cy,
|
|
2069
|
-
r
|
|
2355
|
+
r,
|
|
2070
2356
|
fill,
|
|
2357
|
+
fillOpacity: fillOpacityFor(r),
|
|
2071
2358
|
stroke,
|
|
2072
2359
|
lineNumber: e.p.lineNumber,
|
|
2073
2360
|
implicit: !!e.p.implicit,
|
|
@@ -2264,8 +2551,11 @@ export function layoutMap(
|
|
|
2264
2551
|
legs.push({
|
|
2265
2552
|
d: legPath(a, b, bow.curved, bow.offset),
|
|
2266
2553
|
width: routeWidthFor(Number(leg.value)),
|
|
2267
|
-
color: mix(palette.text, palette.bg, 72),
|
|
2554
|
+
color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
|
|
2268
2555
|
arrow: true,
|
|
2556
|
+
fromId: leg.fromId,
|
|
2557
|
+
toId: leg.toId,
|
|
2558
|
+
...(Object.keys(leg.tags).length > 0 && { tags: leg.tags }),
|
|
2269
2559
|
lineNumber: leg.lineNumber,
|
|
2270
2560
|
...(leg.label !== undefined && {
|
|
2271
2561
|
label: leg.label,
|
|
@@ -2321,8 +2611,11 @@ export function layoutMap(
|
|
|
2321
2611
|
legs.push({
|
|
2322
2612
|
d: legPath(a, b, bow.curved, bow.offset),
|
|
2323
2613
|
width: widthFor(e),
|
|
2324
|
-
color: mix(palette.text, palette.bg, 66),
|
|
2614
|
+
color: lineColor(e.tags) ?? mix(palette.text, palette.bg, 66),
|
|
2325
2615
|
arrow: e.directed,
|
|
2616
|
+
fromId: e.fromId,
|
|
2617
|
+
toId: e.toId,
|
|
2618
|
+
...(Object.keys(e.tags).length > 0 && { tags: e.tags }),
|
|
2326
2619
|
lineNumber: e.lineNumber,
|
|
2327
2620
|
...(e.label !== undefined && {
|
|
2328
2621
|
label: e.label,
|
|
@@ -2339,6 +2632,11 @@ export function layoutMap(
|
|
|
2339
2632
|
// -- Labels: regions + POIs with escalation (AR5) --
|
|
2340
2633
|
const labels: PlacedLabel[] = [];
|
|
2341
2634
|
const obstacles: LabelRect[] = [];
|
|
2635
|
+
// Region/orientation labels are the frame; POI labels are the subject. The
|
|
2636
|
+
// region pass runs first (can't yet see where POI labels land), so each region
|
|
2637
|
+
// label registers a guard here; after POI placement any guard a POI label
|
|
2638
|
+
// overlaps yields — the region label is removed rather than crammed.
|
|
2639
|
+
const regionLabelGuards: Array<{ label: PlacedLabel; rect: LabelRect }> = [];
|
|
2342
2640
|
const markers: PointCircle[] = pois.map((p) => ({
|
|
2343
2641
|
cx: p.cx,
|
|
2344
2642
|
cy: p.cy,
|
|
@@ -2392,18 +2690,91 @@ export function layoutMap(
|
|
|
2392
2690
|
// ocean. At the compact breakpoint (decision D2) the abbreviation is preferred
|
|
2393
2691
|
// FIRST for US states.
|
|
2394
2692
|
const showRegionLabels = resolved.directives.noRegionLabels !== true;
|
|
2693
|
+
// Metric value shown UNDER each data region's name (`no-region-value` opts out).
|
|
2694
|
+
// The value line is rendered smaller + dimmer than the name; see the renderer.
|
|
2695
|
+
// Scoped to a `region-metric` choropleth: only when the SCORE ramp is the active
|
|
2696
|
+
// colouring dimension (not a tag-coloured / categorical map) is the numeric
|
|
2697
|
+
// value the data on display, so that's the only case it's surfaced.
|
|
2698
|
+
const showRegionValues =
|
|
2699
|
+
resolved.directives.noRegionValue !== true && activeIsScore;
|
|
2700
|
+
// Compact value string for a region, or undefined when there's nothing to show
|
|
2701
|
+
// (no value, or the feature is off). Shared formatter so it matches the legend.
|
|
2702
|
+
const regionValueStr = (value: number | undefined): string | undefined =>
|
|
2703
|
+
showRegionValues && value !== undefined ? compactNumber(value) : undefined;
|
|
2395
2704
|
const isCompact = width < COMPACT_WIDTH_PX;
|
|
2705
|
+
// Zoomed sub-national US choropleth (map-us-subnational-zoom): a US-states
|
|
2706
|
+
// mercator view with the score ramp active. Here a cramped state (NH, RI, CT,
|
|
2707
|
+
// NJ, DE) should NOT degrade to its 2-letter abbreviation — the user reads the
|
|
2708
|
+
// abbreviation poorly and a stray hover-name then steps on it. Instead it keeps
|
|
2709
|
+
// its FULL name and, if that won't fit in place, takes a leader-lined margin
|
|
2710
|
+
// callout (full name + value). Only a handful of states are in frame at this
|
|
2711
|
+
// zoom, so the callout column stays short. National (albers) maps keep the
|
|
2712
|
+
// abbreviation cascade — 50 full-name callouts would be unreadable.
|
|
2713
|
+
const usChoroplethZoom =
|
|
2714
|
+
resolved.projection === 'mercator' &&
|
|
2715
|
+
resolved.basemaps.subdivisions.includes('us-states') &&
|
|
2716
|
+
activeIsScore;
|
|
2396
2717
|
const LABEL_PADX = 6;
|
|
2397
2718
|
const LABEL_PADY = 3;
|
|
2398
|
-
|
|
2399
|
-
|
|
2719
|
+
// The value line is ~0.82× the name size; a hair of vertical gap separates them.
|
|
2720
|
+
const VALUE_FONT = Math.round(FONT * 0.82);
|
|
2721
|
+
const VALUE_GAP = 1;
|
|
2722
|
+
const labelW = (text: string, font: number = FONT): number =>
|
|
2723
|
+
measureLegendText(text, font) + 2 * LABEL_PADX;
|
|
2400
2724
|
const labelH = FONT + 2 * LABEL_PADY;
|
|
2725
|
+
// Footprint of a name (+optional value) stack used for the box-fit cascade.
|
|
2726
|
+
// `font` defaults to the base size (every existing call is byte-identical);
|
|
2727
|
+
// the post-placement growth pass passes a larger size to test an upscaled fit.
|
|
2728
|
+
const stackW = (
|
|
2729
|
+
text: string,
|
|
2730
|
+
valueText?: string,
|
|
2731
|
+
font: number = FONT
|
|
2732
|
+
): number =>
|
|
2733
|
+
Math.max(
|
|
2734
|
+
labelW(text, font),
|
|
2735
|
+
valueText
|
|
2736
|
+
? measureLegendText(valueText, Math.round(font * 0.82)) + 2 * LABEL_PADX
|
|
2737
|
+
: 0
|
|
2738
|
+
);
|
|
2739
|
+
const stackH = (hasValue: boolean, font: number = FONT): number => {
|
|
2740
|
+
const lh = font + 2 * LABEL_PADY;
|
|
2741
|
+
return hasValue ? lh + VALUE_GAP + Math.round(font * 0.82) : lh;
|
|
2742
|
+
};
|
|
2743
|
+
// Footprint-driven label growth (size-up + fade), gradual + resolution-free.
|
|
2744
|
+
// Applies to ORIENTATION backdrop names ONLY (neighbour land / frame
|
|
2745
|
+
// containers with no data value): a big one reads as a large, gently-faded
|
|
2746
|
+
// backdrop, a small one stays at the base font. DATA labels are deliberately
|
|
2747
|
+
// EXCLUDED — fading a choropleth value washes it lighter than its own fill and
|
|
2748
|
+
// a loose bbox overran irregular regions. Size scales with the region's
|
|
2749
|
+
// projected footprint as a fraction of the canvas's linear extent. Growth runs
|
|
2750
|
+
// AFTER the base-font fit cascade picks the text+anchor, and only while the
|
|
2751
|
+
// larger glyphs still fit the box, clear neighbours/POIs, and stay inside the
|
|
2752
|
+
// region's own fill.
|
|
2753
|
+
const REGION_FONT_MAX_ORIENT = 22; // px ceiling, backdrop names
|
|
2754
|
+
const REGION_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
|
|
2755
|
+
const REGION_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
|
|
2756
|
+
const REGION_FADE_ORIENT = 45; // % toward bg at max size, backdrop
|
|
2757
|
+
const canvasLinear = Math.sqrt(Math.max(1, width * height));
|
|
2758
|
+
const sizeT = (boxW: number, boxH: number): number => {
|
|
2759
|
+
const frac = Math.sqrt(Math.max(0, boxW * boxH)) / canvasLinear;
|
|
2760
|
+
return Math.min(
|
|
2761
|
+
1,
|
|
2762
|
+
Math.max(
|
|
2763
|
+
0,
|
|
2764
|
+
(frac - REGION_SIZE_FRAC_MIN) /
|
|
2765
|
+
(REGION_SIZE_FRAC_MAX - REGION_SIZE_FRAC_MIN)
|
|
2766
|
+
)
|
|
2767
|
+
);
|
|
2768
|
+
};
|
|
2401
2769
|
const pushRegionLabel = (
|
|
2402
2770
|
x: number,
|
|
2403
2771
|
y: number,
|
|
2404
2772
|
text: string,
|
|
2405
2773
|
fill: string,
|
|
2406
|
-
lineNumber: number
|
|
2774
|
+
lineNumber: number,
|
|
2775
|
+
valueLine?: string,
|
|
2776
|
+
fontSize: number = FONT,
|
|
2777
|
+
fade: number = 0
|
|
2407
2778
|
): void => {
|
|
2408
2779
|
// Colour is contrast-picked against the region's own fill (see labelOnFill).
|
|
2409
2780
|
// The halo, though, is gated by CONTAINMENT — not fill tone. A label that
|
|
@@ -2415,9 +2786,20 @@ export function layoutMap(
|
|
|
2415
2786
|
// to stay legible. Sample the label's screen footprint against the drawn
|
|
2416
2787
|
// fills: if any extreme lands on a fill other than the region's own, the
|
|
2417
2788
|
// label overflows and earns a halo.
|
|
2418
|
-
const { color, haloColor } = labelOnFill(fill);
|
|
2419
|
-
|
|
2420
|
-
|
|
2789
|
+
const { color: baseColor, haloColor } = labelOnFill(fill);
|
|
2790
|
+
// Subdue a grown label toward the background — gentle on data (value stays
|
|
2791
|
+
// readable), stronger on orientation backdrop. Zero fade ⇒ exact base color.
|
|
2792
|
+
const color = fade > 0 ? mix(baseColor, palette.bg, fade) : baseColor;
|
|
2793
|
+
// Widest of name / value drives the overflow sample (the value line can be
|
|
2794
|
+
// the wider of the two, e.g. a short name over a long number). Scales with
|
|
2795
|
+
// the actual (possibly grown) font so the halo gate matches what's drawn.
|
|
2796
|
+
const vf = Math.round(fontSize * 0.82);
|
|
2797
|
+
const halfW =
|
|
2798
|
+
Math.max(
|
|
2799
|
+
measureLegendText(text, fontSize),
|
|
2800
|
+
valueLine ? measureLegendText(valueLine, vf) : 0
|
|
2801
|
+
) / 2;
|
|
2802
|
+
const overflows = [y - fontSize * 0.55, y - fontSize * 0.1].some(
|
|
2421
2803
|
(sy) => fillAt(x - halfW, sy) !== fill || fillAt(x + halfW, sy) !== fill
|
|
2422
2804
|
);
|
|
2423
2805
|
labels.push({
|
|
@@ -2428,15 +2810,31 @@ export function layoutMap(
|
|
|
2428
2810
|
color,
|
|
2429
2811
|
halo: overflows,
|
|
2430
2812
|
haloColor,
|
|
2813
|
+
...(fontSize !== FONT && { fontSize }),
|
|
2814
|
+
...(valueLine !== undefined && { valueLine }),
|
|
2431
2815
|
lineNumber,
|
|
2432
2816
|
});
|
|
2433
2817
|
};
|
|
2434
2818
|
// A region label's screen footprint, middle-anchored on its centroid, used to
|
|
2435
2819
|
// keep two region labels from overlapping (a small gap adds breathing room).
|
|
2820
|
+
// With a value line the box grows to the taller two-line stack.
|
|
2436
2821
|
const REGION_LABEL_GAP = 2;
|
|
2437
|
-
const regionLabelRect = (
|
|
2438
|
-
|
|
2439
|
-
|
|
2822
|
+
const regionLabelRect = (
|
|
2823
|
+
cx: number,
|
|
2824
|
+
cy: number,
|
|
2825
|
+
text: string,
|
|
2826
|
+
valueText?: string,
|
|
2827
|
+
font: number = FONT
|
|
2828
|
+
): LabelRect => {
|
|
2829
|
+
const vf = Math.round(font * 0.82);
|
|
2830
|
+
const w =
|
|
2831
|
+
Math.max(
|
|
2832
|
+
measureLegendText(text, font),
|
|
2833
|
+
valueText ? measureLegendText(valueText, vf) : 0
|
|
2834
|
+
) +
|
|
2835
|
+
2 * REGION_LABEL_GAP;
|
|
2836
|
+
const h = valueText ? font + VALUE_GAP + vf : font;
|
|
2837
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
2440
2838
|
};
|
|
2441
2839
|
if (showRegionLabels) {
|
|
2442
2840
|
// Gather the placeable region labels, then commit them largest-footprint
|
|
@@ -2463,13 +2861,23 @@ export function layoutMap(
|
|
|
2463
2861
|
const boxW = x1 - x0;
|
|
2464
2862
|
const boxH = y1 - y0;
|
|
2465
2863
|
// full → abbrev → hide. Abbrev exists only for US states; at the compact
|
|
2466
|
-
// breakpoint abbrev is tried first.
|
|
2467
|
-
|
|
2864
|
+
// breakpoint abbrev is tried first. A POI-frame CONTAINER (e.g. the
|
|
2865
|
+
// "California" framing a US cloud-regions map) never degrades to the
|
|
2866
|
+
// 2-letter code to squeeze past its own POIs — it stays full or yields
|
|
2867
|
+
// entirely (the post-POI guard below hides it on collision).
|
|
2868
|
+
// On a zoomed US choropleth, drop the abbreviation entirely (full name or
|
|
2869
|
+
// a leader callout — never "NH"). Elsewhere the full → abbrev → hide
|
|
2870
|
+
// cascade stands (compact tries abbrev first; a POI container never
|
|
2871
|
+
// abbreviates).
|
|
2872
|
+
const abbrev =
|
|
2873
|
+
isUsState && !usChoroplethZoom ? r.id.replace(/^US-/, '') : undefined;
|
|
2468
2874
|
const candidates =
|
|
2469
2875
|
abbrev !== undefined
|
|
2470
2876
|
? isCompact
|
|
2471
2877
|
? [abbrev, r.label]
|
|
2472
|
-
:
|
|
2878
|
+
: isContainer
|
|
2879
|
+
? [r.label]
|
|
2880
|
+
: [r.label, abbrev]
|
|
2473
2881
|
: [r.label];
|
|
2474
2882
|
const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : undefined;
|
|
2475
2883
|
const c = anchor
|
|
@@ -2481,6 +2889,18 @@ export function layoutMap(
|
|
|
2481
2889
|
.filter((e): e is NonNullable<typeof e> => e !== null)
|
|
2482
2890
|
.sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
|
|
2483
2891
|
const placedRegionRects: LabelRect[] = [];
|
|
2892
|
+
// Valued regions too small to carry their name+value stack in place — gathered
|
|
2893
|
+
// here and laid out as a margin callout column after the in-place pass.
|
|
2894
|
+
const regionCallouts: Array<{
|
|
2895
|
+
name: string;
|
|
2896
|
+
value: string;
|
|
2897
|
+
cx: number;
|
|
2898
|
+
cy: number;
|
|
2899
|
+
bw: number;
|
|
2900
|
+
bh: number;
|
|
2901
|
+
fill: string;
|
|
2902
|
+
lineNumber: number;
|
|
2903
|
+
}> = [];
|
|
2484
2904
|
// POI markers are obstacles for region labels: a region whose centroid sits on
|
|
2485
2905
|
// a POI (e.g. Colorado's centroid under the "Core POP" dot in Denver) must NOT
|
|
2486
2906
|
// stamp its name there — the POI's own label owns that spot, and two names by
|
|
@@ -2496,35 +2916,394 @@ export function layoutMap(
|
|
|
2496
2916
|
w: 2 * (p.r + POI_LABEL_PAD),
|
|
2497
2917
|
h: 2 * (p.r + POI_LABEL_PAD),
|
|
2498
2918
|
}));
|
|
2919
|
+
// Ocean side of the frame (zoomed US choropleth callouts column there). Sample
|
|
2920
|
+
// a vertical strip just inside each side edge; the side with more open water
|
|
2921
|
+
// hosts the callout column, so leaders run over sea, not across the interior.
|
|
2922
|
+
const waterSideOf = (): 'left' | 'right' => {
|
|
2923
|
+
let leftHits = 0;
|
|
2924
|
+
let rightHits = 0;
|
|
2925
|
+
const lx = width * 0.06;
|
|
2926
|
+
const rx = width * 0.94;
|
|
2927
|
+
for (let i = 1; i < 12; i++) {
|
|
2928
|
+
const y = topPad + ((height - topPad) * i) / 12;
|
|
2929
|
+
if (fillAt(lx, y) === water) leftHits++;
|
|
2930
|
+
if (fillAt(rx, y) === water) rightHits++;
|
|
2931
|
+
}
|
|
2932
|
+
return rightHits >= leftHits ? 'right' : 'left';
|
|
2933
|
+
};
|
|
2934
|
+
const calloutSide = usChoroplethZoom ? waterSideOf() : undefined;
|
|
2499
2935
|
for (const { r, c, boxW, boxH, candidates } of entries) {
|
|
2936
|
+
const valStr = regionValueStr(r.value);
|
|
2937
|
+
// A region hugs a canvas edge if it sits within a short leader's reach of
|
|
2938
|
+
// it — only such a region may use a margin callout column, so the leader is
|
|
2939
|
+
// always SHORT (no cross-map lines for a centred region).
|
|
2940
|
+
const maxLeader = Math.min(width * 0.26, 300);
|
|
2941
|
+
// "Near an edge" is measured against the LAND-facing edge of each reserved
|
|
2942
|
+
// band when a reserve is active (second pass) — the map has shrunk away from
|
|
2943
|
+
// that side, so the cluster now sits at the band's inner edge, not the raw
|
|
2944
|
+
// canvas edge. Without a reserve (first pass) this is just the canvas edge.
|
|
2945
|
+
const rsv = opts._calloutReserve;
|
|
2946
|
+
const rEdge = rsv?.right ? width - rsv.right : width;
|
|
2947
|
+
const lEdge = rsv?.left ?? 0;
|
|
2948
|
+
// On a zoomed US choropleth a cramped state always takes a margin callout (a
|
|
2949
|
+
// full-name + value chip in the ocean-side column, leader from its centroid)
|
|
2950
|
+
// rather than degrading to an abbreviation — only a handful of states are in
|
|
2951
|
+
// frame, so the column stays short. Otherwise a callout is reserved for
|
|
2952
|
+
// edge-hugging regions so no leader runs across a wide view.
|
|
2953
|
+
const nearEdge =
|
|
2954
|
+
usChoroplethZoom ||
|
|
2955
|
+
c[0] >= rEdge - maxLeader ||
|
|
2956
|
+
c[0] <= lEdge + maxLeader;
|
|
2957
|
+
// A tiny region hugging a canvas edge — one whose FULL name won't fit its
|
|
2958
|
+
// own box (RI/CT/NH/MA on a US map) — goes straight to a clean margin
|
|
2959
|
+
// column: a tidy full-name list reads far better than crammed 2-letter
|
|
2960
|
+
// abbreviations piled on the cluster, and the edge keeps the leader short. A
|
|
2961
|
+
// region whose full name DOES fit labels in place as usual; an interior tiny
|
|
2962
|
+
// region (a centred world-map country) is handled by the on-land overflow
|
|
2963
|
+
// below — never a long cross-map leader.
|
|
2964
|
+
if (
|
|
2965
|
+
valStr &&
|
|
2966
|
+
nearEdge &&
|
|
2967
|
+
r.label !== undefined &&
|
|
2968
|
+
(labelW(r.label) > boxW || labelH > boxH)
|
|
2969
|
+
) {
|
|
2970
|
+
regionCallouts.push({
|
|
2971
|
+
name: r.label,
|
|
2972
|
+
value: valStr,
|
|
2973
|
+
cx: c[0],
|
|
2974
|
+
cy: c[1],
|
|
2975
|
+
bw: boxW,
|
|
2976
|
+
bh: boxH,
|
|
2977
|
+
fill: r.fill,
|
|
2978
|
+
lineNumber: r.lineNumber,
|
|
2979
|
+
});
|
|
2980
|
+
continue;
|
|
2981
|
+
}
|
|
2500
2982
|
// The first candidate that BOTH fits its own footprint AND clears every
|
|
2501
2983
|
// already-placed region label AND every POI marker wins; none qualifies →
|
|
2502
2984
|
// the label is hidden (a country has no abbrev, so it degrades full → hide;
|
|
2503
2985
|
// a US state may fall back to its 2-letter code before hiding).
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2986
|
+
// When the region carries a metric value, the name+value STACK is tried
|
|
2987
|
+
// first; if the stack won't fit (a smaller state), it degrades to the bare
|
|
2988
|
+
// name (today's behaviour) so adding values never costs an existing label.
|
|
2989
|
+
//
|
|
2990
|
+
// Two collision tests, deliberately different footprints:
|
|
2991
|
+
// - vs other REGION labels: use the FULL stack rect (two stacks must not
|
|
2992
|
+
// overlap).
|
|
2993
|
+
// - vs POI obstacles: use only the NAME rect. A POI obstacle exists to keep
|
|
2994
|
+
// the region NAME off a POI's dot/label; the (shorter, dimmer) value line
|
|
2995
|
+
// hanging below a name that already clears the dot is fine. Testing the
|
|
2996
|
+
// taller stack here made a region with a nearby POI (Texas under the big
|
|
2997
|
+
// Dallas marker) silently drop its value even though the name fit.
|
|
2998
|
+
const fitsRegions = (rect: LabelRect): boolean =>
|
|
2999
|
+
!placedRegionRects.some((p) => rectsOverlap(rect, p));
|
|
3000
|
+
const fitsPois = (rect: LabelRect): boolean =>
|
|
3001
|
+
!poiObstacles.some((o) => rectsOverlap(rect, o));
|
|
3002
|
+
// Try the centroid first (existing placement — unchanged when it fits),
|
|
3003
|
+
// then a ring of offsets WITHIN the region's box so a label blocked at the
|
|
3004
|
+
// centroid (typically a POI marker sitting on it — Dallas on Texas) is
|
|
3005
|
+
// re-seated on open land of the SAME region rather than exiled to a far
|
|
3006
|
+
// callout column. Off-centroid anchors are kept on the region's own fill
|
|
3007
|
+
// (fillAt) so the label never drifts onto a neighbour or the sea.
|
|
3008
|
+
// Centroid is always tried. The off-centroid re-seat ring is added ONLY for
|
|
3009
|
+
// a region that carries a value — the point of seeking is to not lose the
|
|
3010
|
+
// region's VALUE to a POI on its centroid. A valueless frame container
|
|
3011
|
+
// (e.g. the state hosting a POI hub) keeps the old behaviour: it yields the
|
|
3012
|
+
// spot to the POI rather than sprouting a re-seated name near the hub.
|
|
3013
|
+
const seekAnchors: Array<{ x: number; y: number; guard: boolean }> = [
|
|
3014
|
+
{ x: c[0], y: c[1], guard: false },
|
|
3015
|
+
];
|
|
3016
|
+
if (valStr) {
|
|
3017
|
+
seekAnchors.push(
|
|
3018
|
+
{ x: c[0], y: c[1] + boxH * 0.26, guard: true },
|
|
3019
|
+
{ x: c[0], y: c[1] - boxH * 0.26, guard: true },
|
|
3020
|
+
{ x: c[0] + boxW * 0.26, y: c[1], guard: true },
|
|
3021
|
+
{ x: c[0] - boxW * 0.26, y: c[1], guard: true },
|
|
3022
|
+
{ x: c[0] + boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
|
|
3023
|
+
{ x: c[0] - boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
|
|
3024
|
+
{ x: c[0] + boxW * 0.22, y: c[1] - boxH * 0.22, guard: true },
|
|
3025
|
+
{ x: c[0] - boxW * 0.22, y: c[1] - boxH * 0.22, guard: true }
|
|
2510
3026
|
);
|
|
3027
|
+
}
|
|
3028
|
+
let chosen:
|
|
3029
|
+
| { text: string; valueLine?: string; ax: number; ay: number }
|
|
3030
|
+
| undefined;
|
|
3031
|
+
for (const a of seekAnchors) {
|
|
3032
|
+
if (a.guard && fillAt(a.x, a.y) !== r.fill) continue;
|
|
3033
|
+
for (const t of candidates) {
|
|
3034
|
+
const nameRect = regionLabelRect(a.x, a.y, t);
|
|
3035
|
+
if (valStr && stackW(t, valStr) <= boxW && stackH(true) <= boxH) {
|
|
3036
|
+
const stackRect = regionLabelRect(a.x, a.y, t, valStr);
|
|
3037
|
+
if (fitsRegions(stackRect) && fitsPois(nameRect)) {
|
|
3038
|
+
chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
|
|
3039
|
+
break;
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
if (labelW(t) <= boxW && labelH <= boxH) {
|
|
3043
|
+
if (fitsRegions(nameRect) && fitsPois(nameRect)) {
|
|
3044
|
+
chosen = { text: t, ax: a.x, ay: a.y };
|
|
3045
|
+
break;
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
if (chosen) break;
|
|
3050
|
+
}
|
|
3051
|
+
if (chosen === undefined && valStr) {
|
|
3052
|
+
// A VALUED region not placed in-box, and not an edge-hugging tiny region
|
|
3053
|
+
// (those columned above). Label it ON its own land, letting the name
|
|
3054
|
+
// OVERFLOW its small box onto neighbours/ocean (the halo keeps it legible),
|
|
3055
|
+
// as long as it clears already-placed labels + POIs. This keeps a country
|
|
3056
|
+
// on a world choropleth (Germany, France) labelled in place instead of
|
|
3057
|
+
// exiled to a far margin. If even that collides, the label simply drops —
|
|
3058
|
+
// never a long cross-map leader. Gated to valued regions so a valueless
|
|
3059
|
+
// POI-frame container keeps its old behaviour (yield rather than overflow).
|
|
3060
|
+
for (const a of seekAnchors) {
|
|
3061
|
+
if (fillAt(a.x, a.y) !== r.fill) continue;
|
|
3062
|
+
for (const t of candidates) {
|
|
3063
|
+
const nameRect = regionLabelRect(a.x, a.y, t);
|
|
3064
|
+
if (
|
|
3065
|
+
valStr &&
|
|
3066
|
+
fitsRegions(regionLabelRect(a.x, a.y, t, valStr)) &&
|
|
3067
|
+
fitsPois(nameRect)
|
|
3068
|
+
) {
|
|
3069
|
+
chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
|
|
3070
|
+
break;
|
|
3071
|
+
}
|
|
3072
|
+
if (fitsRegions(nameRect) && fitsPois(nameRect)) {
|
|
3073
|
+
chosen = { text: t, ax: a.x, ay: a.y };
|
|
3074
|
+
break;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
if (chosen) break;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
// Nothing placed (a valueless region that didn't fit, or a valued region
|
|
3081
|
+
// whose overflow also collided) → drop, leaving the map clean.
|
|
3082
|
+
if (chosen === undefined) continue;
|
|
3083
|
+
// Footprint-driven growth applies ONLY to orientation backdrop names — a
|
|
3084
|
+
// data-less neighbour/frame region (Canada framing a POI, foreign land).
|
|
3085
|
+
// DATA labels (a choropleth value) keep the base font + full contrast and
|
|
3086
|
+
// the existing fit-inside cascade UNCHANGED: fading a value washed it
|
|
3087
|
+
// lighter than its own region fill, and a loose bbox let a wide name
|
|
3088
|
+
// ("United States of America") spill past its region. Orientation names sit
|
|
3089
|
+
// on neutral basemap land where a larger, gently-faded backdrop reads well.
|
|
3090
|
+
const isOrient = r.value === undefined && r.layer === 'base';
|
|
3091
|
+
let font = FONT;
|
|
3092
|
+
let fade = 0;
|
|
3093
|
+
if (isOrient) {
|
|
3094
|
+
const growT = sizeT(boxW, boxH);
|
|
3095
|
+
const desiredFont = Math.round(
|
|
3096
|
+
FONT + growT * (REGION_FONT_MAX_ORIENT - FONT)
|
|
3097
|
+
);
|
|
3098
|
+
const hasVal = chosen.valueLine !== undefined;
|
|
3099
|
+
for (let f = desiredFont; f > FONT; f--) {
|
|
3100
|
+
// Fit the footprint box, clear neighbours/POIs, AND — the real guard —
|
|
3101
|
+
// stay INSIDE the region's own fill at the bigger size. The bbox is far
|
|
3102
|
+
// too loose for an irregular shape (Alaska blows up the US bbox), so
|
|
3103
|
+
// sample the grown name's horizontal extremes against `fillAt`: if
|
|
3104
|
+
// either leaves this region's fill, don't grow that far.
|
|
3105
|
+
if (
|
|
3106
|
+
stackW(chosen.text, chosen.valueLine, f) > boxW ||
|
|
3107
|
+
stackH(hasVal, f) > boxH
|
|
3108
|
+
)
|
|
3109
|
+
continue;
|
|
3110
|
+
const gRect = regionLabelRect(
|
|
3111
|
+
chosen.ax,
|
|
3112
|
+
chosen.ay,
|
|
3113
|
+
chosen.text,
|
|
3114
|
+
chosen.valueLine,
|
|
3115
|
+
f
|
|
3116
|
+
);
|
|
3117
|
+
const gName = regionLabelRect(
|
|
3118
|
+
chosen.ax,
|
|
3119
|
+
chosen.ay,
|
|
3120
|
+
chosen.text,
|
|
3121
|
+
undefined,
|
|
3122
|
+
f
|
|
3123
|
+
);
|
|
3124
|
+
if (!fitsRegions(gRect) || !fitsPois(gName)) continue;
|
|
3125
|
+
const halfW = measureLegendText(chosen.text, f) / 2;
|
|
3126
|
+
if (
|
|
3127
|
+
fillAt(chosen.ax - halfW, chosen.ay) !== r.fill ||
|
|
3128
|
+
fillAt(chosen.ax + halfW, chosen.ay) !== r.fill
|
|
3129
|
+
)
|
|
3130
|
+
continue;
|
|
3131
|
+
font = f;
|
|
3132
|
+
break;
|
|
3133
|
+
}
|
|
3134
|
+
fade = font > FONT ? Math.round(growT * REGION_FADE_ORIENT) : 0;
|
|
3135
|
+
}
|
|
3136
|
+
const rRect = regionLabelRect(
|
|
3137
|
+
chosen.ax,
|
|
3138
|
+
chosen.ay,
|
|
3139
|
+
chosen.text,
|
|
3140
|
+
chosen.valueLine,
|
|
3141
|
+
font
|
|
3142
|
+
);
|
|
3143
|
+
placedRegionRects.push(rRect);
|
|
3144
|
+
pushRegionLabel(
|
|
3145
|
+
chosen.ax,
|
|
3146
|
+
chosen.ay,
|
|
3147
|
+
chosen.text,
|
|
3148
|
+
r.fill,
|
|
3149
|
+
r.lineNumber,
|
|
3150
|
+
chosen.valueLine,
|
|
3151
|
+
font,
|
|
3152
|
+
fade
|
|
3153
|
+
);
|
|
3154
|
+
// Guard so a POI label landing here later makes this label yield (below).
|
|
3155
|
+
regionLabelGuards.push({
|
|
3156
|
+
label: labels[labels.length - 1]!,
|
|
3157
|
+
rect: rRect,
|
|
2511
3158
|
});
|
|
2512
|
-
if (text === undefined) continue;
|
|
2513
|
-
placedRegionRects.push(regionLabelRect(c[0], c[1], text));
|
|
2514
|
-
pushRegionLabel(c[0], c[1], text, r.fill, r.lineNumber);
|
|
2515
3159
|
}
|
|
2516
3160
|
// AK/HI labels live in their insets (own projection centroids). Insets are
|
|
2517
3161
|
// tiny, so prefer the abbreviation when the canvas is compact.
|
|
2518
3162
|
for (const seed of insetLabelSeeds) {
|
|
2519
3163
|
const text = isCompact ? seed.iso.replace(/^US-/, '') : seed.name;
|
|
2520
3164
|
const src = regionById.get(seed.iso);
|
|
3165
|
+
const valStr = regionValueStr(src?.value);
|
|
2521
3166
|
pushRegionLabel(
|
|
2522
3167
|
seed.x,
|
|
2523
3168
|
seed.y,
|
|
2524
3169
|
text,
|
|
2525
3170
|
src ? regionFill(src) : neutralFill,
|
|
2526
|
-
seed.lineNumber
|
|
3171
|
+
seed.lineNumber,
|
|
3172
|
+
valStr
|
|
2527
3173
|
);
|
|
3174
|
+
regionLabelGuards.push({
|
|
3175
|
+
label: labels[labels.length - 1]!,
|
|
3176
|
+
rect: regionLabelRect(seed.x, seed.y, text, valStr),
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
// Zoom-out reserve (first pass → re-run): tiny valued regions need margin
|
|
3181
|
+
// callouts, and the map currently fills the canvas with no room for them.
|
|
3182
|
+
// Measure them, reserve a band on the side the cluster leans toward, and
|
|
3183
|
+
// re-run the whole layout fitted into the canvas MINUS that band — the map
|
|
3184
|
+
// shrinks/shifts away from that edge and the callouts get real room. Guarded
|
|
3185
|
+
// by `_calloutReserve` so it recurses exactly once.
|
|
3186
|
+
if (regionCallouts.length > 0 && !opts._calloutReserve) {
|
|
3187
|
+
// Split the callouts by the side of the canvas they fall on — a cluster on
|
|
3188
|
+
// each coast gets its own reserved band + column. Band = widest chip in the
|
|
3189
|
+
// group + a leader run + edge padding, clamped so one stray callout never
|
|
3190
|
+
// over-shrinks the map nor a long name starves it.
|
|
3191
|
+
const bandFor = (group: typeof regionCallouts): number | undefined => {
|
|
3192
|
+
if (group.length === 0) return undefined;
|
|
3193
|
+
const maxChipW = group.reduce(
|
|
3194
|
+
(m, rc) =>
|
|
3195
|
+
Math.max(
|
|
3196
|
+
m,
|
|
3197
|
+
measureLegendText(rc.name, FONT),
|
|
3198
|
+
measureLegendText(rc.value, VALUE_FONT)
|
|
3199
|
+
),
|
|
3200
|
+
0
|
|
3201
|
+
);
|
|
3202
|
+
return Math.max(130, Math.min(maxChipW + 96, Math.floor(width * 0.3)));
|
|
3203
|
+
};
|
|
3204
|
+
// On a zoomed US choropleth all callouts share the ocean-side column (leaders
|
|
3205
|
+
// over sea, never across the interior); elsewhere split by the side each
|
|
3206
|
+
// region leans toward.
|
|
3207
|
+
const right =
|
|
3208
|
+
calloutSide === 'right'
|
|
3209
|
+
? regionCallouts
|
|
3210
|
+
: calloutSide === 'left'
|
|
3211
|
+
? []
|
|
3212
|
+
: regionCallouts.filter((rc) => rc.cx >= width / 2);
|
|
3213
|
+
const left =
|
|
3214
|
+
calloutSide === 'left'
|
|
3215
|
+
? regionCallouts
|
|
3216
|
+
: calloutSide === 'right'
|
|
3217
|
+
? []
|
|
3218
|
+
: regionCallouts.filter((rc) => rc.cx < width / 2);
|
|
3219
|
+
const leftPx = bandFor(left);
|
|
3220
|
+
const rightPx = bandFor(right);
|
|
3221
|
+
return layoutMap(resolved, data, size, {
|
|
3222
|
+
...opts,
|
|
3223
|
+
_calloutReserve: {
|
|
3224
|
+
...(leftPx !== undefined && { left: leftPx }),
|
|
3225
|
+
...(rightPx !== undefined && { right: rightPx }),
|
|
3226
|
+
},
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// ── Radial callouts for valued regions too small to label in place ──
|
|
3231
|
+
// Each gathered region gets a leader-lined chip (its name over the metric
|
|
3232
|
+
// value, same stack as an in-place label) placed in the OPEN SPACE around the
|
|
3233
|
+
// cluster: the chip marches OUTWARD from the cluster centre along its own
|
|
3234
|
+
// angle (so a dense cluster fans its labels out in all directions — east into
|
|
3235
|
+
// the ocean, north over Canada, etc.) until it clears the data regions, the
|
|
3236
|
+
// in-place labels, and the other chips. A small dot marks the region's true
|
|
3237
|
+
// centroid; the leader runs dot → chip. Chips may overlay unvalued base land
|
|
3238
|
+
// (e.g. Canada) but never a VALUED region's fill (keep the choropleth clean).
|
|
3239
|
+
if (regionCallouts.length > 0) {
|
|
3240
|
+
// Tidy callout column(s) in the reserved margin(s) the zoom-out pass opened.
|
|
3241
|
+
// Each chip is a name+value stack anchored just inside the band; a leader
|
|
3242
|
+
// runs from the region's centroid dot to the chip's inner edge. Rows are
|
|
3243
|
+
// ordered top→bottom by screen latitude so the column reads geographically
|
|
3244
|
+
// and the leaders stay short and roughly parallel. A cluster on each side of
|
|
3245
|
+
// the canvas gets its own column in its own reserved band.
|
|
3246
|
+
const reserveInfo = opts._calloutReserve;
|
|
3247
|
+
const EDGE = 28;
|
|
3248
|
+
const COL_GAP = 16; // chip inset from the land-facing edge of the band
|
|
3249
|
+
const chipH = FONT + VALUE_GAP + VALUE_FONT;
|
|
3250
|
+
const ROW = chipH + 10;
|
|
3251
|
+
const placeColumn = (
|
|
3252
|
+
group: typeof regionCallouts,
|
|
3253
|
+
side: 'left' | 'right',
|
|
3254
|
+
bandPx: number
|
|
3255
|
+
): void => {
|
|
3256
|
+
if (group.length === 0) return;
|
|
3257
|
+
const anchor: PlacedLabel['anchor'] =
|
|
3258
|
+
side === 'right' ? 'start' : 'end';
|
|
3259
|
+
const colX =
|
|
3260
|
+
side === 'right' ? width - bandPx + COL_GAP : bandPx - COL_GAP;
|
|
3261
|
+
const rows = [...group].sort((a, b) => a.cy - b.cy);
|
|
3262
|
+
const meanCy = rows.reduce((s, rc) => s + rc.cy, 0) / rows.length;
|
|
3263
|
+
const totalH = rows.length * ROW;
|
|
3264
|
+
const minTop = topPad + 6 + ROW / 2;
|
|
3265
|
+
const maxTop = Math.max(minTop, height - EDGE - totalH + ROW / 2);
|
|
3266
|
+
const startY = Math.max(
|
|
3267
|
+
minTop,
|
|
3268
|
+
Math.min(meanCy - totalH / 2 + ROW / 2, maxTop)
|
|
3269
|
+
);
|
|
3270
|
+
rows.forEach((rc, i) => {
|
|
3271
|
+
const ry = startY + i * ROW;
|
|
3272
|
+
const innerX = side === 'right' ? colX - 4 : colX + 4;
|
|
3273
|
+
// Darken the region's hue toward the text colour for leader/dot contrast
|
|
3274
|
+
// (a pale low-value fill on its own is near-invisible) while still tying
|
|
3275
|
+
// the line to its region by colour.
|
|
3276
|
+
const dark = mix(rc.fill, palette.text, 60);
|
|
3277
|
+
labels.push({
|
|
3278
|
+
x: colX,
|
|
3279
|
+
y: ry,
|
|
3280
|
+
text: rc.name,
|
|
3281
|
+
anchor,
|
|
3282
|
+
color: palette.text,
|
|
3283
|
+
halo: true,
|
|
3284
|
+
haloColor: palette.bg,
|
|
3285
|
+
valueLine: rc.value,
|
|
3286
|
+
leader: { x1: rc.cx, y1: rc.cy, x2: innerX, y2: ry },
|
|
3287
|
+
leaderColor: dark,
|
|
3288
|
+
calloutDot: { x: rc.cx, y: rc.cy, color: dark },
|
|
3289
|
+
lineNumber: rc.lineNumber,
|
|
3290
|
+
});
|
|
3291
|
+
});
|
|
3292
|
+
};
|
|
3293
|
+
const right =
|
|
3294
|
+
calloutSide === 'right'
|
|
3295
|
+
? regionCallouts
|
|
3296
|
+
: calloutSide === 'left'
|
|
3297
|
+
? []
|
|
3298
|
+
: regionCallouts.filter((rc) => rc.cx >= width / 2);
|
|
3299
|
+
const left =
|
|
3300
|
+
calloutSide === 'left'
|
|
3301
|
+
? regionCallouts
|
|
3302
|
+
: calloutSide === 'right'
|
|
3303
|
+
? []
|
|
3304
|
+
: regionCallouts.filter((rc) => rc.cx < width / 2);
|
|
3305
|
+
placeColumn(right, 'right', reserveInfo?.right ?? 150);
|
|
3306
|
+
placeColumn(left, 'left', reserveInfo?.left ?? 150);
|
|
2528
3307
|
}
|
|
2529
3308
|
}
|
|
2530
3309
|
|
|
@@ -2551,6 +3330,16 @@ export function layoutMap(
|
|
|
2551
3330
|
// from the east AND west — Boulder in the route-cluster gauntlet).
|
|
2552
3331
|
type Side = 'right' | 'left' | 'above' | 'below';
|
|
2553
3332
|
const GAP = 3;
|
|
3333
|
+
// Comfort buffer between any dot/label and the canvas edge — canvas-proportional
|
|
3334
|
+
// (≈3% of the shorter axis, floored) so a big preview pane breathes more than a
|
|
3335
|
+
// thumbnail. Used BOTH by the leader-column clamp (so a column never seats hard
|
|
3336
|
+
// against the frame) and by the edge-clearance re-fit below (dots + inline
|
|
3337
|
+
// labels). Keeping the two in sync is what stops the re-fit from fighting a
|
|
3338
|
+
// column that would otherwise re-clamp to the edge each pass.
|
|
3339
|
+
const POI_EDGE_CLEAR = Math.max(
|
|
3340
|
+
20,
|
|
3341
|
+
Math.round(Math.min(width, height) * 0.03)
|
|
3342
|
+
);
|
|
2554
3343
|
// Coincident-stack members (spiderfy) are labelled via a tidy leader-lined
|
|
2555
3344
|
// COLUMN beside the cluster (see the cluster-column pass after the column
|
|
2556
3345
|
// helpers below) — NOT radial inline labels, which pile up unreadably when
|
|
@@ -2589,7 +3378,8 @@ export function layoutMap(
|
|
|
2589
3378
|
p: MapLayoutPoi,
|
|
2590
3379
|
text: string,
|
|
2591
3380
|
w: number,
|
|
2592
|
-
side: Side
|
|
3381
|
+
side: Side,
|
|
3382
|
+
clusterId?: string
|
|
2593
3383
|
): void => {
|
|
2594
3384
|
const rect = inlineRect(p, w, side);
|
|
2595
3385
|
obstacles.push(rect);
|
|
@@ -2606,6 +3396,7 @@ export function layoutMap(
|
|
|
2606
3396
|
haloColor: palette.bg,
|
|
2607
3397
|
poiId: p.id,
|
|
2608
3398
|
lineNumber: p.lineNumber,
|
|
3399
|
+
...(clusterId !== undefined && { clusterMember: clusterId }),
|
|
2609
3400
|
});
|
|
2610
3401
|
};
|
|
2611
3402
|
const inlineFits = (p: MapLayoutPoi, w: number, side: Side): boolean => {
|
|
@@ -2664,11 +3455,14 @@ export function layoutMap(
|
|
|
2664
3455
|
// colX; a left column anchors its end at colX (text spans colX-maxW..colX).
|
|
2665
3456
|
const colX =
|
|
2666
3457
|
side === 'right'
|
|
2667
|
-
? Math.min(right + COL_GAP, width -
|
|
2668
|
-
: Math.max(left - COL_GAP,
|
|
3458
|
+
? Math.min(right + COL_GAP, width - POI_EDGE_CLEAR - maxW)
|
|
3459
|
+
: Math.max(left - COL_GAP, POI_EDGE_CLEAR + maxW);
|
|
2669
3460
|
const totalH = items.length * step;
|
|
2670
3461
|
let startY = cyMid - totalH / 2;
|
|
2671
|
-
startY = Math.max(
|
|
3462
|
+
startY = Math.max(
|
|
3463
|
+
POI_EDGE_CLEAR,
|
|
3464
|
+
Math.min(startY, height - totalH - POI_EDGE_CLEAR)
|
|
3465
|
+
);
|
|
2672
3466
|
return items.map((o, i) => {
|
|
2673
3467
|
const rowCy = startY + i * step + step / 2;
|
|
2674
3468
|
return {
|
|
@@ -2698,12 +3492,50 @@ export function layoutMap(
|
|
|
2698
3492
|
rect.y + rect.h <= height &&
|
|
2699
3493
|
!collides(rect)
|
|
2700
3494
|
);
|
|
2701
|
-
//
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
3495
|
+
// Open-space score for a candidate label rect (higher = better). Cartographic
|
|
3496
|
+
// convention: a coastal point throws its label out over the water, never back
|
|
3497
|
+
// across the land it sits on. So a side whose label footprint lands over open
|
|
3498
|
+
// water dominates; among equally-wet (or equally-dry) sides, the one with more
|
|
3499
|
+
// clearance to the canvas edge wins. Sampled at a fixed 3×2 grid → deterministic.
|
|
3500
|
+
const WATER_PREF = 1000; // a water-facing side beats any land-facing side
|
|
3501
|
+
const openness = (rect: LabelRect): number => {
|
|
3502
|
+
const xs = [
|
|
3503
|
+
rect.x + rect.w * 0.15,
|
|
3504
|
+
rect.x + rect.w * 0.5,
|
|
3505
|
+
rect.x + rect.w * 0.85,
|
|
3506
|
+
];
|
|
3507
|
+
const ys = [rect.y + rect.h * 0.25, rect.y + rect.h * 0.75];
|
|
3508
|
+
let waterHits = 0;
|
|
3509
|
+
for (const x of xs)
|
|
3510
|
+
for (const y of ys) if (fillAt(x, y) === water) waterHits++;
|
|
3511
|
+
const waterFrac = waterHits / (xs.length * ys.length);
|
|
3512
|
+
const edgeClear = Math.max(
|
|
3513
|
+
0,
|
|
3514
|
+
Math.min(
|
|
3515
|
+
rect.x,
|
|
3516
|
+
width - (rect.x + rect.w),
|
|
3517
|
+
rect.y,
|
|
3518
|
+
height - (rect.y + rect.h)
|
|
3519
|
+
)
|
|
3520
|
+
);
|
|
3521
|
+
// edgeClear scaled to ~0..30 so it only breaks ties, never overrides water.
|
|
3522
|
+
return WATER_PREF * waterFrac + edgeClear * 0.1;
|
|
2706
3523
|
};
|
|
3524
|
+
// A column side's openness = mean openness over its rows' label rects.
|
|
3525
|
+
const columnSideScore = (
|
|
3526
|
+
items: ColItem[],
|
|
3527
|
+
side: 'right' | 'left'
|
|
3528
|
+
): number => {
|
|
3529
|
+
const rows = columnRows(items, side);
|
|
3530
|
+
if (rows.length === 0) return -Infinity;
|
|
3531
|
+
return rows.reduce((s, { rect }) => s + openness(rect), 0) / rows.length;
|
|
3532
|
+
};
|
|
3533
|
+
// Side heuristic for ungated callouts: prefer the more open (water-facing,
|
|
3534
|
+
// then roomier) flank rather than blindly seating the column on the right.
|
|
3535
|
+
const defaultColumnSide = (items: ColItem[]): 'right' | 'left' =>
|
|
3536
|
+
columnSideScore(items, 'right') >= columnSideScore(items, 'left')
|
|
3537
|
+
? 'right'
|
|
3538
|
+
: 'left';
|
|
2707
3539
|
// Commit a visible callout column on the GIVEN side (no re-deriving the
|
|
2708
3540
|
// side — the caller has already validated it). When `clusterId` is set the
|
|
2709
3541
|
// rows are tagged `clusterMember` so the app shows/hides them (text AND
|
|
@@ -2763,22 +3595,83 @@ export function layoutMap(
|
|
|
2763
3595
|
});
|
|
2764
3596
|
};
|
|
2765
3597
|
|
|
2766
|
-
//
|
|
2767
|
-
// the
|
|
2768
|
-
//
|
|
2769
|
-
//
|
|
2770
|
-
//
|
|
3598
|
+
// A small coincident stack reads best with each member's label hugging its
|
|
3599
|
+
// OWN fanned dot on the side it fans toward — the fan already seats the dots
|
|
3600
|
+
// radially (member 0 due North, the next due South for a pair, …), so a top
|
|
3601
|
+
// dot takes its label ABOVE and a bottom dot takes it BELOW. Compact and
|
|
3602
|
+
// symmetric, and — unlike a one-sided leader column — it never overruns the
|
|
3603
|
+
// frame when the stack sits hard against a coast (the San Jose case). The
|
|
3604
|
+
// labels carry `clusterMember` so the app still toggles them with the badge.
|
|
3605
|
+
const STACK_RADIAL_MAX = 4; // above/below/left/right — one slot per member
|
|
3606
|
+
const radialSide = (p: MapLayoutPoi, cx: number, cy: number): Side => {
|
|
3607
|
+
const dx = p.cx - cx;
|
|
3608
|
+
const dy = p.cy - cy;
|
|
3609
|
+
return Math.abs(dy) >= Math.abs(dx)
|
|
3610
|
+
? dy <= 0
|
|
3611
|
+
? 'above'
|
|
3612
|
+
: 'below'
|
|
3613
|
+
: dx < 0
|
|
3614
|
+
? 'left'
|
|
3615
|
+
: 'right';
|
|
3616
|
+
};
|
|
3617
|
+
// Seat every member radially (preferred side first, then the rest), each new
|
|
3618
|
+
// label blocking the next. All-or-nothing: if any member can't seat on-canvas
|
|
3619
|
+
// and clean, bail so the caller falls back to the leader-lined column.
|
|
3620
|
+
const tryStackRadial = (items: ColItem[], clusterId: string): boolean => {
|
|
3621
|
+
const cluster = clusters.find((c) => c.id === clusterId);
|
|
3622
|
+
if (!cluster || items.length > STACK_RADIAL_MAX) return false;
|
|
3623
|
+
const temp: LabelRect[] = [];
|
|
3624
|
+
const seated: Array<{
|
|
3625
|
+
p: MapLayoutPoi;
|
|
3626
|
+
text: string;
|
|
3627
|
+
w: number;
|
|
3628
|
+
side: Side;
|
|
3629
|
+
}> = [];
|
|
3630
|
+
for (const { p, text, w } of items) {
|
|
3631
|
+
const pref = radialSide(p, cluster.cx, cluster.cy);
|
|
3632
|
+
const order: Side[] = [
|
|
3633
|
+
pref,
|
|
3634
|
+
...(['above', 'below', 'right', 'left'] as Side[]).filter(
|
|
3635
|
+
(s) => s !== pref
|
|
3636
|
+
),
|
|
3637
|
+
];
|
|
3638
|
+
const side = order.find((s) => {
|
|
3639
|
+
const rect = inlineRect(p, w, s);
|
|
3640
|
+
return (
|
|
3641
|
+
rect.x >= 0 &&
|
|
3642
|
+
rect.x + rect.w <= width &&
|
|
3643
|
+
rect.y >= 0 &&
|
|
3644
|
+
rect.y + rect.h <= height &&
|
|
3645
|
+
!collides(rect) &&
|
|
3646
|
+
!temp.some((t) => rectsOverlap(t, rect))
|
|
3647
|
+
);
|
|
3648
|
+
});
|
|
3649
|
+
if (side === undefined) return false;
|
|
3650
|
+
temp.push(inlineRect(p, w, side));
|
|
3651
|
+
seated.push({ p, text, w, side });
|
|
3652
|
+
}
|
|
3653
|
+
for (const { p, text, w, side } of seated)
|
|
3654
|
+
pushInline(p, text, w, side, clusterId);
|
|
3655
|
+
return true;
|
|
3656
|
+
};
|
|
3657
|
+
// Spiderfy clusters: committed FIRST so the singleton/group passes route
|
|
3658
|
+
// around them. Try the compact radial layout; only a stack too big (or too
|
|
3659
|
+
// boxed-in) for cardinal slots falls back to a tidy leader-lined column,
|
|
3660
|
+
// thrown to the cleaner/seaward flank.
|
|
2771
3661
|
for (const [clusterId, members] of clusterMembersById) {
|
|
2772
3662
|
if (members.length === 0) continue;
|
|
2773
3663
|
const items = makeItems(members);
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
const side =
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
3664
|
+
if (tryStackRadial(items, clusterId)) continue;
|
|
3665
|
+
const cleanR = wouldColumnBeClean(items, 'right');
|
|
3666
|
+
const cleanL = wouldColumnBeClean(items, 'left');
|
|
3667
|
+
const side =
|
|
3668
|
+
cleanR && cleanL
|
|
3669
|
+
? defaultColumnSide(items)
|
|
3670
|
+
: cleanR
|
|
3671
|
+
? 'right'
|
|
3672
|
+
: cleanL
|
|
3673
|
+
? 'left'
|
|
3674
|
+
: defaultColumnSide(items);
|
|
2782
3675
|
commitColumn(items, side, clusterId);
|
|
2783
3676
|
}
|
|
2784
3677
|
|
|
@@ -2795,11 +3688,25 @@ export function layoutMap(
|
|
|
2795
3688
|
// Singleton: inline if it fits, else today's single-row callout —
|
|
2796
3689
|
// always placed, never hover-only (Decision #2 / AC9).
|
|
2797
3690
|
const { p, text, w } = items[0]!;
|
|
2798
|
-
const
|
|
2799
|
-
inlineFits(p, w, s)
|
|
3691
|
+
const fits = (['right', 'left', 'above', 'below'] as const).filter(
|
|
3692
|
+
(s) => inlineFits(p, w, s)
|
|
2800
3693
|
);
|
|
2801
|
-
if (
|
|
2802
|
-
|
|
3694
|
+
if (fits.length === 0) {
|
|
3695
|
+
commitColumn(items, defaultColumnSide(items));
|
|
3696
|
+
continue;
|
|
3697
|
+
}
|
|
3698
|
+
// Horizontal sides read best; fall to vertical only if neither flank
|
|
3699
|
+
// fits. Among the pool, divert to a water-facing side when one exists
|
|
3700
|
+
// (seaward coastal label); otherwise keep the right-first reading order.
|
|
3701
|
+
const horiz = fits.filter((s) => s === 'right' || s === 'left');
|
|
3702
|
+
const pool = horiz.length > 0 ? horiz : fits;
|
|
3703
|
+
const score = (s: Side): number => openness(inlineRect(p, w, s));
|
|
3704
|
+
const wet = pool.filter((s) => score(s) >= WATER_PREF * 0.5);
|
|
3705
|
+
const side =
|
|
3706
|
+
wet.length > 0
|
|
3707
|
+
? wet.reduce((b, s) => (score(s) > score(b) ? s : b))
|
|
3708
|
+
: pool[0]!;
|
|
3709
|
+
pushInline(p, text, w, side);
|
|
2803
3710
|
continue;
|
|
2804
3711
|
}
|
|
2805
3712
|
// Gate (a): bounding-box diagonal over marker extents — a sprawling chain
|
|
@@ -2820,12 +3727,154 @@ export function layoutMap(
|
|
|
2820
3727
|
// or left-side column places fully clean; commit on that exact side, else
|
|
2821
3728
|
// the whole cluster goes hover-only.
|
|
2822
3729
|
for (const items of clusterPending) {
|
|
2823
|
-
const
|
|
3730
|
+
const cleanSides = (['right', 'left'] as const).filter((s) =>
|
|
2824
3731
|
wouldColumnBeClean(items, s)
|
|
2825
3732
|
);
|
|
3733
|
+
const side =
|
|
3734
|
+
cleanSides.length > 1
|
|
3735
|
+
? defaultColumnSide(items) // both clean → most open flank
|
|
3736
|
+
: cleanSides[0];
|
|
2826
3737
|
if (side) commitColumn(items, side);
|
|
2827
3738
|
else items.forEach((o) => pushHidden(o.p));
|
|
2828
3739
|
}
|
|
3740
|
+
|
|
3741
|
+
// ── Edge clearance (re-fit, first pass → re-run) ──
|
|
3742
|
+
// The tight fit (FIT_PAD = 24px) can seat a POI — or its label (inline OR
|
|
3743
|
+
// leader column) — hard against a side, off-canvas, or demoted to hover-only.
|
|
3744
|
+
// Measure how far every POI dot AND every POI label crosses a comfort
|
|
3745
|
+
// clearance line on each of the four sides, reserve the deepest intrusion per
|
|
3746
|
+
// side as a band, and re-fit the whole map into the canvas MINUS those bands —
|
|
3747
|
+
// the data (dots and labels together) slides inward so nothing hugs the frame.
|
|
3748
|
+
// The clearance scales with the canvas (≈3% of the shorter axis, floored) so a
|
|
3749
|
+
// big preview pane gets proportionally more breathing room than a thumbnail.
|
|
3750
|
+
// Asymmetric and "just enough": only the crowded sides zoom out, the rest stay
|
|
3751
|
+
// tight. A committed label's box is reconstructed from its baseline/anchor; a
|
|
3752
|
+
// still-hidden (hover-only) label is measured at its IDEAL seaward position
|
|
3753
|
+
// (its stored rect is clamped on-canvas and would read as no intrusion).
|
|
3754
|
+
// Re-measured and accumulated each pass until nothing intrudes, capped at
|
|
3755
|
+
// `MAX_CLEARANCE_PASSES` so a pathologically small canvas can't loop forever.
|
|
3756
|
+
const clearancePass = opts._poiClearancePass ?? 0;
|
|
3757
|
+
const MAX_CLEARANCE_PASSES = 4;
|
|
3758
|
+
if (clearancePass < MAX_CLEARANCE_PASSES && pois.length > 0) {
|
|
3759
|
+
const EDGE_CLEAR = POI_EDGE_CLEAR; // shared with the leader-column clamp
|
|
3760
|
+
const capH = Math.floor(width * 0.3); // never starve the map for one wide name
|
|
3761
|
+
const capV = Math.floor(height * 0.3);
|
|
3762
|
+
const poiById2 = new Map(pois.map((p) => [p.id, p]));
|
|
3763
|
+
let needLeft = 0;
|
|
3764
|
+
let needRight = 0;
|
|
3765
|
+
let needTop = 0;
|
|
3766
|
+
let needBottom = 0;
|
|
3767
|
+
// Dots first: a marker itself must clear every edge by the buffer, so a
|
|
3768
|
+
// corner cluster is pulled bodily inward (its labels ride along).
|
|
3769
|
+
// Top is measured against the canvas edge (y=0), NOT topPad: the title band
|
|
3770
|
+
// (topPad) already separates content from the top, so a dot/label just under
|
|
3771
|
+
// it is not "hugging the edge" — referencing topPad would shove every POI map
|
|
3772
|
+
// down by the buffer for no reason.
|
|
3773
|
+
for (const p of pois) {
|
|
3774
|
+
needLeft = Math.max(needLeft, EDGE_CLEAR - (p.cx - p.r));
|
|
3775
|
+
needRight = Math.max(needRight, p.cx + p.r + EDGE_CLEAR - width);
|
|
3776
|
+
needTop = Math.max(needTop, EDGE_CLEAR - (p.cy - p.r));
|
|
3777
|
+
needBottom = Math.max(needBottom, p.cy + p.r + EDGE_CLEAR - height);
|
|
3778
|
+
}
|
|
3779
|
+
for (const l of labels) {
|
|
3780
|
+
if (l.poiId === undefined) continue;
|
|
3781
|
+
const p = poiById2.get(l.poiId);
|
|
3782
|
+
if (!p) continue;
|
|
3783
|
+
// A leader-lined COLUMN (visible) or a hover-only HIDDEN label both want a
|
|
3784
|
+
// seaward column beside the dot. Measuring their CLAMPED rect is useless —
|
|
3785
|
+
// a column self-clamps to the edge (so it reads as no intrusion yet sits on
|
|
3786
|
+
// the dots), and a hidden label's stored rect is clamped too. Instead
|
|
3787
|
+
// reserve from the DOT so the column fits at its NATURAL seat (dot edge +
|
|
3788
|
+
// COL_GAP + label width + buffer). This is dot-based, so it CONVERGES as
|
|
3789
|
+
// the data slides in — unlike measuring the self-clamped label, which would
|
|
3790
|
+
// never move off the edge. The column then seats beside the dots (no clamp,
|
|
3791
|
+
// no overlap) and shows. COL_GAP matches the column layout's own gap.
|
|
3792
|
+
if (l.hidden || l.leader) {
|
|
3793
|
+
const lw = l.hidden
|
|
3794
|
+
? labelInfo(p).w
|
|
3795
|
+
: measureLegendText(l.text, FONT);
|
|
3796
|
+
const reach = p.r + COL_GAP + lw + EDGE_CLEAR;
|
|
3797
|
+
if (p.cx >= width / 2)
|
|
3798
|
+
needRight = Math.max(needRight, p.cx + reach - width);
|
|
3799
|
+
else needLeft = Math.max(needLeft, reach - p.cx);
|
|
3800
|
+
continue;
|
|
3801
|
+
}
|
|
3802
|
+
// Visible inline label: reconstruct its box from baseline + anchor and
|
|
3803
|
+
// measure how far it crosses each clearance line (negative = inside).
|
|
3804
|
+
const w = measureLegendText(l.text, FONT);
|
|
3805
|
+
const boxLeft =
|
|
3806
|
+
l.anchor === 'start'
|
|
3807
|
+
? l.x
|
|
3808
|
+
: l.anchor === 'end'
|
|
3809
|
+
? l.x - w
|
|
3810
|
+
: l.x - w / 2;
|
|
3811
|
+
const boxTop = l.y - FONT / 3 - poiLabH / 2;
|
|
3812
|
+
const boxRight = boxLeft + w;
|
|
3813
|
+
const boxBottom = boxTop + poiLabH;
|
|
3814
|
+
needLeft = Math.max(needLeft, EDGE_CLEAR - boxLeft);
|
|
3815
|
+
needRight = Math.max(needRight, boxRight + EDGE_CLEAR - width);
|
|
3816
|
+
needTop = Math.max(needTop, EDGE_CLEAR - boxTop);
|
|
3817
|
+
needBottom = Math.max(needBottom, boxBottom + EDGE_CLEAR - height);
|
|
3818
|
+
}
|
|
3819
|
+
needLeft = Math.min(Math.max(0, Math.ceil(needLeft)), capH);
|
|
3820
|
+
needRight = Math.min(Math.max(0, Math.ceil(needRight)), capH);
|
|
3821
|
+
needTop = Math.min(Math.max(0, Math.ceil(needTop)), capV);
|
|
3822
|
+
needBottom = Math.min(Math.max(0, Math.ceil(needBottom)), capV);
|
|
3823
|
+
if (needLeft >= 1 || needRight >= 1 || needTop >= 1 || needBottom >= 1) {
|
|
3824
|
+
// ADD the residual intrusion to the band already reserved (the measured
|
|
3825
|
+
// positions already reflect prior bands, so `need` is what's still over the
|
|
3826
|
+
// line) and re-fit. Accumulating — not max — is what makes a too-tight
|
|
3827
|
+
// first shift converge on the next pass instead of stalling.
|
|
3828
|
+
const prev = opts._calloutReserve;
|
|
3829
|
+
const left = Math.min((prev?.left ?? 0) + needLeft, capH);
|
|
3830
|
+
const right = Math.min((prev?.right ?? 0) + needRight, capH);
|
|
3831
|
+
const top = Math.min((prev?.top ?? 0) + needTop, capV);
|
|
3832
|
+
const bottom = Math.min((prev?.bottom ?? 0) + needBottom, capV);
|
|
3833
|
+
return layoutMap(resolved, data, size, {
|
|
3834
|
+
...opts,
|
|
3835
|
+
_poiClearancePass: clearancePass + 1,
|
|
3836
|
+
_calloutReserve: {
|
|
3837
|
+
...(left > 0 && { left }),
|
|
3838
|
+
...(right > 0 && { right }),
|
|
3839
|
+
...(top > 0 && { top }),
|
|
3840
|
+
...(bottom > 0 && { bottom }),
|
|
3841
|
+
},
|
|
3842
|
+
});
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
// Region/orientation labels yield to POI labels (the subject). A region label
|
|
3848
|
+
// whose footprint a visible POI label now overlaps is removed — the POI data
|
|
3849
|
+
// owns that spot, and the region label is orientation that reads fine absent
|
|
3850
|
+
// here (vs. crammed atop a dot). Done after POI placement because the region
|
|
3851
|
+
// pass runs first and couldn't see where the POI labels would land. POI label
|
|
3852
|
+
// rects are padded a touch so a near-touch also triggers the yield.
|
|
3853
|
+
if (regionLabelGuards.length > 0) {
|
|
3854
|
+
const PAD = 2;
|
|
3855
|
+
const poiRects = labels
|
|
3856
|
+
.filter((l) => l.poiId !== undefined && l.hidden !== true)
|
|
3857
|
+
.map((l) => {
|
|
3858
|
+
const w = measureLegendText(l.text, FONT);
|
|
3859
|
+
const x =
|
|
3860
|
+
l.anchor === 'start'
|
|
3861
|
+
? l.x
|
|
3862
|
+
: l.anchor === 'end'
|
|
3863
|
+
? l.x - w
|
|
3864
|
+
: l.x - w / 2;
|
|
3865
|
+
return {
|
|
3866
|
+
x: x - PAD,
|
|
3867
|
+
y: l.y - FONT,
|
|
3868
|
+
w: w + 2 * PAD,
|
|
3869
|
+
h: FONT * 1.4 + 2 * PAD,
|
|
3870
|
+
};
|
|
3871
|
+
});
|
|
3872
|
+
for (const g of regionLabelGuards) {
|
|
3873
|
+
if (poiRects.some((pr) => rectsOverlap(pr, g.rect))) {
|
|
3874
|
+
const i = labels.indexOf(g.label);
|
|
3875
|
+
if (i >= 0) labels.splice(i, 1);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
2829
3878
|
}
|
|
2830
3879
|
|
|
2831
3880
|
// -- Context labels (orientation backdrop, §24B). Placed DEAD LAST so they
|
|
@@ -2883,22 +3932,25 @@ export function layoutMap(
|
|
|
2883
3932
|
name: (f.properties as { name?: string } | undefined)?.name ?? iso,
|
|
2884
3933
|
bbox: [x0, y0, x1, y1],
|
|
2885
3934
|
anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
|
|
3935
|
+
curatedAnchor: !!anchorLngLat,
|
|
2886
3936
|
});
|
|
2887
3937
|
}
|
|
2888
|
-
//
|
|
2889
|
-
//
|
|
2890
|
-
// (Nevada, Oregon, Arizona…) in the muted context
|
|
2891
|
-
//
|
|
3938
|
+
// Framed US states (POI-only region framing): when the frame is snapped to a
|
|
3939
|
+
// US-state container (e.g. California), label the focus state AND the
|
|
3940
|
+
// surrounding in-frame states (Nevada, Oregon, Arizona…) in the muted context
|
|
3941
|
+
// style for orientation. None are data (the region-label pass skipped them).
|
|
2892
3942
|
// Anchor each to the centroid of its VISIBLE (culled) geometry so a state only
|
|
2893
3943
|
// partly in frame (a sliver of Oregon at the top) still anchors on-screen
|
|
2894
3944
|
// rather than at an off-frame centroid that `insideViewport` would reject.
|
|
3945
|
+
// The focus container IS included (gives the map its headline name) — only a
|
|
3946
|
+
// data-referenced state is skipped, to avoid double-labelling what
|
|
3947
|
+
// region-labels already named.
|
|
2895
3948
|
const framedStateContainers = (resolved.poiFrameContainers ?? []).some(
|
|
2896
3949
|
(id) => id.startsWith('US-')
|
|
2897
3950
|
);
|
|
2898
3951
|
if (usLayer && framedStateContainers) {
|
|
2899
|
-
const containerSet = new Set(resolved.poiFrameContainers);
|
|
2900
3952
|
for (const [iso, f] of usLayer) {
|
|
2901
|
-
if (
|
|
3953
|
+
if (regionById.has(iso)) continue;
|
|
2902
3954
|
const viewF = cullFeatureToView(f);
|
|
2903
3955
|
if (!viewF) continue; // not in frame
|
|
2904
3956
|
const b = path.bounds(viewF as never) as [
|
|
@@ -2935,6 +3987,69 @@ export function layoutMap(
|
|
|
2935
3987
|
labels.push(...contextLabels);
|
|
2936
3988
|
}
|
|
2937
3989
|
|
|
3990
|
+
// ── Subtle city dots (basemap orientation, §24B `no-cities`) ──
|
|
3991
|
+
// A faint scatter of gazetteer cities for geographic context. Population-ranked
|
|
3992
|
+
// and spacing-thinned: the min-pixel gap makes density adapt to zoom for free —
|
|
3993
|
+
// at world scale only the biggest of a dense cluster (Europe) survive; zoomed
|
|
3994
|
+
// into one country the same cities spread apart and more local ones fill in.
|
|
3995
|
+
// Explicit POIs always win — a city dot never sits under a referenced marker.
|
|
3996
|
+
//
|
|
3997
|
+
// The ON-CANVAS projected-pixel test is the ONLY cull — NOT a lon/lat extent
|
|
3998
|
+
// box. `resolved.extent` wraps the antimeridian for albers-usa whenever AK/HI
|
|
3999
|
+
// are referenced (west lon > east lon), which a naive `lon<w||lon>e` box reads
|
|
4000
|
+
// as "reject every mainland city" → an all-blank US map. The pixel test is
|
|
4001
|
+
// projection-agnostic and antimeridian-safe, and it naturally includes the
|
|
4002
|
+
// near-border neighbour cities the viewport actually shows.
|
|
4003
|
+
const cityDots: MapLayoutCityDot[] = [];
|
|
4004
|
+
if (resolved.directives.noCities !== true) {
|
|
4005
|
+
const CITY_DOT_SPACING = 12; // min px between two dots (and dot↔POI)
|
|
4006
|
+
const CITY_DOT_CAP = 220;
|
|
4007
|
+
const SPACING_SQ = CITY_DOT_SPACING * CITY_DOT_SPACING;
|
|
4008
|
+
// Radius scales with population on a log axis (pop spans ~50k → 37M, so a
|
|
4009
|
+
// linear map would collapse everything but the megacities to one size). A
|
|
4010
|
+
// metropolis reads as a slightly fatter dot; a small town stays a faint
|
|
4011
|
+
// speck. Still decorative — the range is deliberately tight so the layer
|
|
4012
|
+
// never competes with POIs.
|
|
4013
|
+
const CITY_DOT_R_MIN = 0.7;
|
|
4014
|
+
const CITY_DOT_R_MAX = 2.6;
|
|
4015
|
+
const CITY_POP_MIN = 50_000; // ≤ this → R_MIN
|
|
4016
|
+
const CITY_POP_MAX = 15_000_000; // ≥ this → R_MAX
|
|
4017
|
+
const LOG_MIN = Math.log10(CITY_POP_MIN);
|
|
4018
|
+
const LOG_SPAN = Math.log10(CITY_POP_MAX) - LOG_MIN;
|
|
4019
|
+
const cityDotRadius = (pop: number): number => {
|
|
4020
|
+
if (!(pop > CITY_POP_MIN)) return CITY_DOT_R_MIN;
|
|
4021
|
+
const t = Math.min(1, (Math.log10(pop) - LOG_MIN) / LOG_SPAN);
|
|
4022
|
+
return CITY_DOT_R_MIN + t * (CITY_DOT_R_MAX - CITY_DOT_R_MIN);
|
|
4023
|
+
};
|
|
4024
|
+
// Seed the occupancy set with explicit POI positions so dots dodge markers.
|
|
4025
|
+
const placed: { x: number; y: number }[] = pois.map((p) => ({
|
|
4026
|
+
x: p.cx,
|
|
4027
|
+
y: p.cy,
|
|
4028
|
+
}));
|
|
4029
|
+
const sorted = [...data.gazetteer.cities].sort((a, b) => b[3] - a[3]);
|
|
4030
|
+
for (const c of sorted) {
|
|
4031
|
+
if (cityDots.length >= CITY_DOT_CAP) break;
|
|
4032
|
+
const lat = c[0];
|
|
4033
|
+
const lon = c[1];
|
|
4034
|
+
const p = project(lon, lat);
|
|
4035
|
+
if (!p) continue;
|
|
4036
|
+
const [px, py] = p;
|
|
4037
|
+
if (px < 0 || px > width || py < 0 || py > height) continue;
|
|
4038
|
+
let tooClose = false;
|
|
4039
|
+
for (const q of placed) {
|
|
4040
|
+
const dx = q.x - px;
|
|
4041
|
+
const dy = q.y - py;
|
|
4042
|
+
if (dx * dx + dy * dy < SPACING_SQ) {
|
|
4043
|
+
tooClose = true;
|
|
4044
|
+
break;
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
if (tooClose) continue;
|
|
4048
|
+
placed.push({ x: px, y: py });
|
|
4049
|
+
cityDots.push({ cx: px, cy: py, r: cityDotRadius(c[3]) });
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
|
|
2938
4053
|
return {
|
|
2939
4054
|
width,
|
|
2940
4055
|
height,
|
|
@@ -2949,6 +4064,7 @@ export function layoutMap(
|
|
|
2949
4064
|
coastlineStyle,
|
|
2950
4065
|
legs,
|
|
2951
4066
|
pois,
|
|
4067
|
+
cityDots,
|
|
2952
4068
|
clusters,
|
|
2953
4069
|
labels,
|
|
2954
4070
|
legend,
|