@diagrammo/dgmo 0.25.5 → 0.27.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 +4255 -2756
- package/dist/advanced.d.cts +285 -59
- package/dist/advanced.d.ts +285 -59
- package/dist/advanced.js +4253 -2750
- package/dist/auto.cjs +4051 -2589
- package/dist/auto.js +124 -122
- package/dist/auto.mjs +4051 -2589
- package/dist/cli.cjs +172 -170
- 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 +4076 -2591
- package/dist/index.d.cts +33 -8
- package/dist/index.d.ts +33 -8
- package/dist/index.js +4076 -2591
- package/dist/internal.cjs +4255 -2756
- package/dist/internal.d.cts +285 -59
- package/dist/internal.d.ts +285 -59
- package/dist/internal.js +4253 -2750
- 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 +1 -1
- package/src/advanced.ts +3 -6
- package/src/auto/index.ts +1 -1
- package/src/boxes-and-lines/layout.ts +146 -26
- 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 -1
- package/src/class/renderer.ts +58 -2
- package/src/class/types.ts +3 -0
- package/src/cli.ts +4 -4
- package/src/completion-types.ts +0 -1
- package/src/completion.ts +106 -51
- 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 -1
- 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 +35 -2
- package/src/graph/flowchart-renderer.ts +80 -52
- 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 +80 -52
- package/src/graph/types.ts +13 -0
- package/src/index.ts +1 -1
- package/src/infra/layout.ts +46 -26
- package/src/infra/parser.ts +1 -1
- 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 +57 -12
- 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 +1196 -90
- 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/parser.ts +0 -14
- package/src/pert/renderer.ts +30 -43
- package/src/pyramid/renderer.ts +4 -5
- package/src/raci/renderer.ts +42 -70
- 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
|
|
@@ -263,6 +341,9 @@ export interface MapLayoutPoi {
|
|
|
263
341
|
readonly cy: number;
|
|
264
342
|
readonly r: number;
|
|
265
343
|
readonly fill: string;
|
|
344
|
+
/** Fill opacity scaled by radius — larger bubbles fade so they read as light
|
|
345
|
+
* rather than heavy. Stroke stays fully opaque (crisp edge at every size). */
|
|
346
|
+
readonly fillOpacity: number;
|
|
266
347
|
readonly stroke: string;
|
|
267
348
|
readonly lineNumber: number;
|
|
268
349
|
readonly implicit: boolean;
|
|
@@ -311,6 +392,15 @@ export interface MapLayoutLeg {
|
|
|
311
392
|
readonly width: number;
|
|
312
393
|
readonly color: string;
|
|
313
394
|
readonly arrow: boolean;
|
|
395
|
+
/** Endpoint POI ids (resolved `fromId`/`toId`), emitted as `data-from-id` /
|
|
396
|
+
* `data-to-id`. Lets an interactive preview co-highlight a leg's two endpoint
|
|
397
|
+
* POIs when the leg is focused (§17 sync). */
|
|
398
|
+
readonly fromId: string;
|
|
399
|
+
readonly toId: string;
|
|
400
|
+
/** Tag values (keyed by lowercased group name) — emitted as `data-tag-*`, like
|
|
401
|
+
* POI markers, so a legend-entry hover spotlights only the matching lines
|
|
402
|
+
* (§24B.6). Omitted when the leg carries no tag. */
|
|
403
|
+
readonly tags?: Readonly<Record<string, string>>;
|
|
314
404
|
readonly label?: string;
|
|
315
405
|
readonly labelX?: number;
|
|
316
406
|
readonly labelY?: number;
|
|
@@ -345,6 +435,11 @@ export interface PlacedLabel {
|
|
|
345
435
|
/** The POI this label belongs to (POI labels only) — emitted as `data-poi` on
|
|
346
436
|
* the label + leader so the app can spotlight the dot on label hover. */
|
|
347
437
|
readonly poiId?: string;
|
|
438
|
+
/** Per-label font size in px. Set on context COUNTRY labels, which scale up with
|
|
439
|
+
* their projected footprint (a big country reads as a faded backdrop name, a
|
|
440
|
+
* small one stays at the base label font). Absent ⇒ the renderer's default
|
|
441
|
+
* LABEL_FONT, so every other label type renders byte-identically. */
|
|
442
|
+
readonly fontSize?: number;
|
|
348
443
|
/** Cartographic italic (context-label water names, §24B). Default upright. */
|
|
349
444
|
readonly italic?: boolean;
|
|
350
445
|
/** Cartographic letter-spacing in px (context-label water names). Default 0. */
|
|
@@ -363,6 +458,16 @@ export interface PlacedLabel {
|
|
|
363
458
|
* visible (export + expanded view) but tagged `data-cluster-member` so the app
|
|
364
459
|
* hides it when the stack is collapsed to its badge. */
|
|
365
460
|
readonly clusterMember?: string;
|
|
461
|
+
/** A choropleth region's metric VALUE (already compact-formatted, e.g. `39.5M`),
|
|
462
|
+
* drawn as a smaller, dimmer second line UNDER `text` (the region name). Set
|
|
463
|
+
* only on region labels of a `region-metric` map when `no-region-value` is off.
|
|
464
|
+
* The renderer stacks it as a sub-line; absent ⇒ single name line. */
|
|
465
|
+
readonly valueLine?: string;
|
|
466
|
+
/** A region too small to carry its name+value stack in place gets a leader-lined
|
|
467
|
+
* callout in a margin column; this marks the region's true centroid so the
|
|
468
|
+
* renderer draws a small anchor dot there (the leader runs dot → chip). The
|
|
469
|
+
* colour is the region's fill, tying the dot/leader/chip together. */
|
|
470
|
+
readonly calloutDot?: { x: number; y: number; color: string };
|
|
366
471
|
readonly lineNumber: number;
|
|
367
472
|
}
|
|
368
473
|
|
|
@@ -372,6 +477,15 @@ export interface PlacedLabel {
|
|
|
372
477
|
// value-imports mapLegendBand from ./legend-band).
|
|
373
478
|
export type { MapLayoutLegend };
|
|
374
479
|
|
|
480
|
+
/** A subtle gazetteer city dot for basemap orientation (§24B `no-cities`). Just
|
|
481
|
+
* a position + radius; the renderer paints it muted/low-opacity. No label, no
|
|
482
|
+
* interactivity — purely decorative context. */
|
|
483
|
+
export interface MapLayoutCityDot {
|
|
484
|
+
readonly cx: number;
|
|
485
|
+
readonly cy: number;
|
|
486
|
+
readonly r: number;
|
|
487
|
+
}
|
|
488
|
+
|
|
375
489
|
/** A drawn river centerline — an open stroked path (no fill). */
|
|
376
490
|
export interface MapLayoutRiver {
|
|
377
491
|
readonly d: string;
|
|
@@ -439,6 +553,9 @@ export interface MapLayout {
|
|
|
439
553
|
readonly coastlineStyle: MapLayoutCoastlineStyle | null;
|
|
440
554
|
readonly legs: readonly MapLayoutLeg[];
|
|
441
555
|
readonly pois: readonly MapLayoutPoi[];
|
|
556
|
+
/** Subtle gazetteer city dots for orientation (empty when `no-cities` or no
|
|
557
|
+
* cities fall on-canvas). Drawn over the basemap, under connectors/POIs. */
|
|
558
|
+
readonly cityDots: readonly MapLayoutCityDot[];
|
|
442
559
|
/** Coincident POI stacks (spiderfy). Empty when no ≥2-member overlap exists.
|
|
443
560
|
* The renderer draws a collapsed badge per stack; the app collapses/expands. */
|
|
444
561
|
readonly clusters: readonly MapLayoutCluster[];
|
|
@@ -479,6 +596,28 @@ export interface LayoutOptions {
|
|
|
479
596
|
* `'preview'` keeps inactive pills. Used to size the reserved legend band so
|
|
480
597
|
* the projected land starts below the legend. Defaults to `'preview'`. */
|
|
481
598
|
readonly legendMode?: LegendMode;
|
|
599
|
+
/** INTERNAL (set by layoutMap's own second pass — do not pass in). When tiny
|
|
600
|
+
* valued regions need margin callouts, the first pass measures them and
|
|
601
|
+
* re-runs with reserved bands: the projection fits into the canvas MINUS these
|
|
602
|
+
* bands so the data shrinks/shifts inward, opening label room. A cluster on
|
|
603
|
+
* EACH side reserves its own band (px), so tiny regions on both coasts each get
|
|
604
|
+
* a column. An absent side reserves nothing there. Also carries the POI
|
|
605
|
+
* edge-clearance bands (any of the four sides) measured by the POI-label pass
|
|
606
|
+
* (same fit-box mechanism). Region callouts only ever set left/right. */
|
|
607
|
+
readonly _calloutReserve?: {
|
|
608
|
+
left?: number;
|
|
609
|
+
right?: number;
|
|
610
|
+
top?: number;
|
|
611
|
+
bottom?: number;
|
|
612
|
+
};
|
|
613
|
+
/** INTERNAL (set by layoutMap's own POI-clearance pass — do not pass in). After
|
|
614
|
+
* POI-label placement, any POI dot/label crossing the edge-clearance band
|
|
615
|
+
* triggers a re-fit that ADDS the residual intrusion to the reserved band on
|
|
616
|
+
* that side, sliding the data inward. Re-measured each pass and accumulated
|
|
617
|
+
* until nothing intrudes (or the pass cap), so a tight cluster on a small canvas
|
|
618
|
+
* converges instead of giving up after one under-shoot. This counts the passes
|
|
619
|
+
* taken to bound the recursion. */
|
|
620
|
+
readonly _poiClearancePass?: number;
|
|
482
621
|
}
|
|
483
622
|
|
|
484
623
|
interface Size {
|
|
@@ -559,10 +698,23 @@ const alaskaProjection = (): GeoProjection =>
|
|
|
559
698
|
geoConicEqualArea().rotate([154, 0]).center([-2, 58.5]).parallels([55, 65]);
|
|
560
699
|
const hawaiiProjection = (): GeoProjection => geoMercator();
|
|
561
700
|
|
|
562
|
-
function projectionFor(
|
|
701
|
+
function projectionFor(
|
|
702
|
+
family: ProjectionFamily,
|
|
703
|
+
extent: GeoExtent
|
|
704
|
+
): GeoProjection {
|
|
563
705
|
switch (family) {
|
|
564
706
|
case 'albers-usa':
|
|
565
707
|
return usConusProjection();
|
|
708
|
+
case 'conic-equal-area': {
|
|
709
|
+
// Albers for a single continent: standard parallels at 1/6 and 5/6 of the
|
|
710
|
+
// extent's latitude band (distortion-minimizing), centered on the band's
|
|
711
|
+
// mid-latitude. Longitude centering is handled by the shared .rotate below.
|
|
712
|
+
const s = extent[0][1];
|
|
713
|
+
const n = extent[1][1];
|
|
714
|
+
return geoConicEqualArea()
|
|
715
|
+
.parallels([s + (n - s) / 6, s + ((n - s) * 5) / 6])
|
|
716
|
+
.center([0, (s + n) / 2]);
|
|
717
|
+
}
|
|
566
718
|
case 'mercator':
|
|
567
719
|
return geoMercator();
|
|
568
720
|
case 'equal-earth':
|
|
@@ -700,8 +852,9 @@ export function buildMapProjection(
|
|
|
700
852
|
// 50m/110m — visibly coarser than the 10m states. When the NA-clipped 10m
|
|
701
853
|
// assets are present, swap them in so neighbours (Canada/Mexico) and the Great
|
|
702
854
|
// 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
|
-
//
|
|
855
|
+
// Crisp NA assets apply to BOTH the national albers-usa view AND a regional US
|
|
856
|
+
// mercator view (POI-only region framing — e.g. a single state — OR a compact
|
|
857
|
+
// region/choropleth that auto-zooms; map-us-subnational-zoom, both mercator). A
|
|
705
858
|
// US-oriented mercator frame is sub-world and entirely within North America by
|
|
706
859
|
// construction, so the NA-clipped 10m land/lakes fit it; the bbox guard below
|
|
707
860
|
// still keeps non-NA countries on world geometry. Excludes equirectangular
|
|
@@ -795,7 +948,7 @@ export function buildMapProjection(
|
|
|
795
948
|
}
|
|
796
949
|
const fitTarget: GeoFC = { type: 'FeatureCollection', features: fitFeatures };
|
|
797
950
|
|
|
798
|
-
const projection = projectionFor(resolved.projection);
|
|
951
|
+
const projection = projectionFor(resolved.projection, resolved.extent);
|
|
799
952
|
// mercator / natural-earth: rotate to the extent's center longitude BEFORE
|
|
800
953
|
// fitting (rotate changes the bounds fitExtent measures). albers-usa is a
|
|
801
954
|
// US-only composite with NO .rotate -- never call it (AR2).
|
|
@@ -936,11 +1089,14 @@ export function layoutMap(
|
|
|
936
1089
|
const usContext = usLayer !== null;
|
|
937
1090
|
// Basemap fills (`water` / `neutralFill` / `foreignFill`) depend on whether a
|
|
938
1091
|
// colouring dimension is active — defined below, once `activeGroup` is known.
|
|
939
|
-
// Region borders: a
|
|
940
|
-
//
|
|
941
|
-
//
|
|
1092
|
+
// Region borders. Light theme: a near-text dark outline (a dark hairline
|
|
1093
|
+
// reads well over the pale ground). Dark theme: a near-bg dark outline
|
|
1094
|
+
// vanishes against the deep ground, so instead lean on the palette's
|
|
1095
|
+
// dedicated `border` grid-line token (tuned to pop against that ground) and
|
|
1096
|
+
// nudge it toward `text` for a touch more lift — a visible boundary that
|
|
1097
|
+
// still reads as a line, not a glaring white seam over the land fills.
|
|
942
1098
|
const regionStroke = isDark
|
|
943
|
-
? mix(palette.
|
|
1099
|
+
? mix(palette.border, palette.text, 65) // dark theme: lifted grid-line
|
|
944
1100
|
: mix(palette.text, palette.bg, 78); // light theme: near-text dark outline
|
|
945
1101
|
// Lake shoreline. Lakes are painted as water OVER the land and the region
|
|
946
1102
|
// borders, so without an edge they read as a featureless patch that simply
|
|
@@ -955,13 +1111,14 @@ export function layoutMap(
|
|
|
955
1111
|
const values = resolved.regions
|
|
956
1112
|
.filter((r) => r.value !== undefined)
|
|
957
1113
|
.map((r) => r.value!);
|
|
958
|
-
// Ramp auto-fits (the `scale` directive is gone)
|
|
959
|
-
// low end anchors at
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
//
|
|
963
|
-
|
|
964
|
-
|
|
1114
|
+
// Ramp auto-fits (the `scale` directive is gone) to data-min→data-max — the
|
|
1115
|
+
// low end anchors at the lowest value, not 0. This maximises within-map
|
|
1116
|
+
// dynamic range and matches the size/thickness metric ramps (poi-metric,
|
|
1117
|
+
// flow-metric), which already floor at their data minimum. Cross-map low-end
|
|
1118
|
+
// comparability (the old 0-anchor, "decision C") is intentionally dropped: a
|
|
1119
|
+
// shared baseline only helped side-by-side maps and flattened single-map
|
|
1120
|
+
// contrast. Equal-value data (rampMin === rampMax) falls back to t = 1 below.
|
|
1121
|
+
const rampMin = values.length > 0 ? Math.min(...values) : 0;
|
|
965
1122
|
const rampMax = Math.max(...values);
|
|
966
1123
|
// Value ramp defaults to red so valued regions stand out against the blue
|
|
967
1124
|
// water (palette.primary is a blue in most palettes and would blend in). A
|
|
@@ -969,6 +1126,13 @@ export function layoutMap(
|
|
|
969
1126
|
const rampHue =
|
|
970
1127
|
resolveColor(resolved.directives.regionMetricColor ?? '', palette) ??
|
|
971
1128
|
palette.colors.red;
|
|
1129
|
+
// Explicit LOW endpoint (`region-metric Sales green red`). Only the 11
|
|
1130
|
+
// recognized names peel, so resolveColor always succeeds when a name is
|
|
1131
|
+
// present; absent ⇒ single-colour behaviour (neutral low). §24B.3.
|
|
1132
|
+
const rampLow = resolved.directives.regionMetricLowColor
|
|
1133
|
+
? (resolveColor(resolved.directives.regionMetricLowColor, palette) ??
|
|
1134
|
+
undefined)
|
|
1135
|
+
: undefined;
|
|
972
1136
|
const hasRamp = values.length > 0;
|
|
973
1137
|
|
|
974
1138
|
// Colouring dimension (AR4, bivariate): the value ramp and each tag group are
|
|
@@ -987,6 +1151,21 @@ export function layoutMap(
|
|
|
987
1151
|
const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
|
|
988
1152
|
return tg ? tg.name : v; // unknown name passes through → renders neutral
|
|
989
1153
|
};
|
|
1154
|
+
// A tag group is a "fill group" only if its alias actually lands on a region
|
|
1155
|
+
// or a POI. A group used solely on connector lines (§24B.6) colours edges,
|
|
1156
|
+
// never the basemap — so it must not drive the region/active-tag dress or
|
|
1157
|
+
// suppress colorize.
|
|
1158
|
+
const fillGroupNames = new Set<string>();
|
|
1159
|
+
for (const g of resolved.tagGroups) {
|
|
1160
|
+
const k = g.name.toLowerCase();
|
|
1161
|
+
if (
|
|
1162
|
+
resolved.regions.some((r) => r.tags[k]) ||
|
|
1163
|
+
resolved.pois.some((p) => p.tags[k])
|
|
1164
|
+
)
|
|
1165
|
+
fillGroupNames.add(g.name);
|
|
1166
|
+
}
|
|
1167
|
+
const firstFillGroup =
|
|
1168
|
+
resolved.tagGroups.find((g) => fillGroupNames.has(g.name))?.name ?? null;
|
|
990
1169
|
const override = opts.activeGroup; // string | null | undefined
|
|
991
1170
|
let activeGroup: string | null;
|
|
992
1171
|
if (override !== undefined) {
|
|
@@ -995,21 +1174,25 @@ export function layoutMap(
|
|
|
995
1174
|
activeGroup = matchColorGroup(resolved.directives.activeTag);
|
|
996
1175
|
} else {
|
|
997
1176
|
// Default: colour by the value ramp when values exist, else the first
|
|
998
|
-
// declared tag group.
|
|
1177
|
+
// declared tag group that fills a region/POI. When the only groups are
|
|
1178
|
+
// edge/leg groups (no fill group), fall back to the first declared group so
|
|
1179
|
+
// the legend still renders it as a line-colour KEY — but it won't mute the
|
|
1180
|
+
// basemap (see mutedBasemap below) since it fills no region.
|
|
999
1181
|
activeGroup =
|
|
1000
|
-
VALUE_NAME ??
|
|
1001
|
-
(resolved.tagGroups.length > 0 ? resolved.tagGroups[0]!.name : null);
|
|
1182
|
+
VALUE_NAME ?? firstFillGroup ?? resolved.tagGroups[0]?.name ?? null;
|
|
1002
1183
|
}
|
|
1003
1184
|
const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
|
|
1004
1185
|
|
|
1005
1186
|
// Basemap dress (fixed automatic aesthetic — no directive). Subject water +
|
|
1006
1187
|
// land always wear the SAME faded blue/green dress (subtle enough that
|
|
1007
1188
|
// 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
|
-
|
|
1189
|
+
// consistent. `mutedBasemap` governs only the NEIGHBOUR land: when a REGION-
|
|
1190
|
+
// filling dimension is active the surrounding world recedes to a paler gray so
|
|
1191
|
+
// the subject + its data fills dominate; a plain reference map — or one whose
|
|
1192
|
+
// only tag group colours connector LINES (§24B.6), not regions — keeps
|
|
1193
|
+
// neighbour land at the fuller gray.
|
|
1194
|
+
const mutedBasemap =
|
|
1195
|
+
activeIsScore || (activeGroup !== null && fillGroupNames.has(activeGroup));
|
|
1013
1196
|
const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
|
|
1014
1197
|
const water = mapBackgroundColor(palette, isDark, mutedBasemap);
|
|
1015
1198
|
const lakeStroke = mix(regionStroke, water, 45); // soft coastline (see above)
|
|
@@ -1043,7 +1226,7 @@ export function layoutMap(
|
|
|
1043
1226
|
resolved.directives.noColorize !== true &&
|
|
1044
1227
|
!hasRamp &&
|
|
1045
1228
|
!hasDirectColor &&
|
|
1046
|
-
|
|
1229
|
+
fillGroupNames.size === 0;
|
|
1047
1230
|
// Hue per ISO over ONE UNIFIED graph spanning every drawn topology, so no two
|
|
1048
1231
|
// bordering regions share a hue — INCLUDING across the international seam. The
|
|
1049
1232
|
// world and us-states topologies share no TopoJSON arcs, so neighbors() is blind
|
|
@@ -1100,8 +1283,16 @@ export function layoutMap(
|
|
|
1100
1283
|
// off the near-black surface so the lowest scores read as a clear muted red
|
|
1101
1284
|
// rather than sinking to maroon-black.
|
|
1102
1285
|
const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
|
|
1286
|
+
// Floored neutral the single-colour ramp blends up from — also the LOW
|
|
1287
|
+
// endpoint the legend shows when no explicit low colour was given.
|
|
1288
|
+
const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
|
|
1103
1289
|
const fillForValue = (s: number): string => {
|
|
1104
1290
|
const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
|
|
1291
|
+
// Two-colour ramp: shared low→high interpolation (direct or via midpoint).
|
|
1292
|
+
if (rampLow !== undefined)
|
|
1293
|
+
return valueRampColor(rampLow, rampHue, t, { isDark });
|
|
1294
|
+
// Single/zero-colour ramp: byte-identical to pre-change output — feed `mix`
|
|
1295
|
+
// the SAME numeric pct (NO float round-trip, which could drift a channel).
|
|
1105
1296
|
const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
|
|
1106
1297
|
return mix(rampHue, rampBase, pct);
|
|
1107
1298
|
};
|
|
@@ -1190,8 +1381,8 @@ export function layoutMap(
|
|
|
1190
1381
|
}),
|
|
1191
1382
|
min: rampMin,
|
|
1192
1383
|
max: rampMax,
|
|
1193
|
-
|
|
1194
|
-
|
|
1384
|
+
low: rampLow ?? rampLowFloor,
|
|
1385
|
+
high: rampHue,
|
|
1195
1386
|
},
|
|
1196
1387
|
}),
|
|
1197
1388
|
};
|
|
@@ -1228,15 +1419,70 @@ export function layoutMap(
|
|
|
1228
1419
|
hasSubtitle: Boolean(resolved.subtitle),
|
|
1229
1420
|
});
|
|
1230
1421
|
if (legendBand > topPad) topPad = legendBand;
|
|
1422
|
+
// Reserve a side band for margin callouts (second pass only): the projection
|
|
1423
|
+
// fits into the canvas MINUS this band, so the data shrinks and slides away
|
|
1424
|
+
// from that edge, opening room for the callout chips + leaders.
|
|
1425
|
+
const reserve = opts._calloutReserve;
|
|
1426
|
+
const fitLeft = FIT_PAD + (reserve?.left ?? 0);
|
|
1427
|
+
const fitRight = width - FIT_PAD - (reserve?.right ?? 0);
|
|
1428
|
+
const fitTop = topPad + (reserve?.top ?? 0);
|
|
1429
|
+
const fitBottom = height - FIT_PAD - (reserve?.bottom ?? 0);
|
|
1231
1430
|
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
|
-
],
|
|
1431
|
+
[fitLeft, fitTop],
|
|
1432
|
+
[Math.max(fitLeft + 1, fitRight), Math.max(fitTop + 1, fitBottom)],
|
|
1237
1433
|
];
|
|
1238
1434
|
projection.fitExtent(fitBox, fitTarget as never);
|
|
1239
1435
|
|
|
1436
|
+
// Data-centered vertical fit (regional region-maps only). `fitExtent` centers
|
|
1437
|
+
// the EXTENT rectangle in the box; when a choropleth's data clusters away from
|
|
1438
|
+
// that rectangle's vertical center it lands off-center — e.g. a Europe map's
|
|
1439
|
+
// colored countries are mostly central/southern, but Sweden drags the extent's
|
|
1440
|
+
// north edge into empty Arctic, so the data sits low under a band of ocean.
|
|
1441
|
+
// Shift the projection vertically so the data's vertical SPAN is centered in the
|
|
1442
|
+
// fit box, CLAMPED so the data still fits inside the box (we never push a colored
|
|
1443
|
+
// region off-frame). The span comes from each region's PRIMARY landmass bbox
|
|
1444
|
+
// (featureBboxPrimary) — NOT the full feature, whose detached overseas
|
|
1445
|
+
// territories (French Guiana, the Canaries, the Dutch Caribbean) would project
|
|
1446
|
+
// far off-frame and wreck the bounds. POI-only regional frames are already
|
|
1447
|
+
// cluster-centered (container + zoom floor) and the albers-usa composite frames
|
|
1448
|
+
// the nation itself — both skip this.
|
|
1449
|
+
if (
|
|
1450
|
+
!fitIsGlobal &&
|
|
1451
|
+
resolved.projection !== 'albers-usa' &&
|
|
1452
|
+
resolved.regions.length > 0
|
|
1453
|
+
) {
|
|
1454
|
+
let yMin = Infinity;
|
|
1455
|
+
let yMax = -Infinity;
|
|
1456
|
+
for (const r of resolved.regions) {
|
|
1457
|
+
const bb = r.iso ? featureBboxPrimary(data.worldCoarse, r.iso) : null;
|
|
1458
|
+
if (!bb) continue;
|
|
1459
|
+
for (const lon of [bb[0][0], bb[1][0]]) {
|
|
1460
|
+
for (const lat of [bb[0][1], bb[1][1]]) {
|
|
1461
|
+
const p = projection([lon, lat]);
|
|
1462
|
+
if (p && Number.isFinite(p[1])) {
|
|
1463
|
+
if (p[1] < yMin) yMin = p[1];
|
|
1464
|
+
if (p[1] > yMax) yMax = p[1];
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (yMin < yMax) {
|
|
1470
|
+
const boxTop = fitTop;
|
|
1471
|
+
const boxBottom = fitBottom;
|
|
1472
|
+
// Center the data's vertical span; the bbox midpoint balances the northern
|
|
1473
|
+
// and southern extremes evenly (an area-weighted centroid would skew toward
|
|
1474
|
+
// the larger landmasses and over-shoot the frame).
|
|
1475
|
+
let dy = (boxTop + boxBottom) / 2 - (yMin + yMax) / 2;
|
|
1476
|
+
// Clamp so the data span stays within [boxTop, boxBottom]; if it is taller
|
|
1477
|
+
// than the box, the midpoint target already gives symmetric overflow.
|
|
1478
|
+
const minDy = boxTop - yMin;
|
|
1479
|
+
const maxDy = boxBottom - yMax;
|
|
1480
|
+
if (minDy <= maxDy) dy = Math.max(minDy, Math.min(maxDy, dy));
|
|
1481
|
+
const [tx, ty] = projection.translate();
|
|
1482
|
+
projection.translate([tx, ty + dy]);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1240
1486
|
// Global views stretch-fill the canvas. A whole-world map is ~2:1 but the
|
|
1241
1487
|
// preview pane is often near-square, so the honest contain-fit letterboxes it
|
|
1242
1488
|
// with large water bands. For GLOBAL extents we stretch the PROJECTED geometry
|
|
@@ -1292,12 +1538,15 @@ export function layoutMap(
|
|
|
1292
1538
|
).stream.point(px, py);
|
|
1293
1539
|
},
|
|
1294
1540
|
});
|
|
1541
|
+
const thin = geoThin();
|
|
1295
1542
|
path = geoPath({
|
|
1296
1543
|
stream: (s: never) =>
|
|
1297
1544
|
baseProjection.stream(
|
|
1298
|
-
(tx as unknown as { stream: (d: never) => never }).stream(
|
|
1545
|
+
(tx as unknown as { stream: (d: never) => never }).stream(
|
|
1546
|
+
(thin as unknown as { stream: (d: never) => never }).stream(s)
|
|
1547
|
+
)
|
|
1299
1548
|
),
|
|
1300
|
-
} as never);
|
|
1549
|
+
} as never).digits(PATH_DIGITS);
|
|
1301
1550
|
project = (lon, lat) => {
|
|
1302
1551
|
const p = baseProjection([lon, lat]);
|
|
1303
1552
|
return p ? stretch(p[0], p[1]) : null;
|
|
@@ -1316,7 +1565,13 @@ export function layoutMap(
|
|
|
1316
1565
|
[0, 0],
|
|
1317
1566
|
[width, height],
|
|
1318
1567
|
]);
|
|
1319
|
-
|
|
1568
|
+
const thin = geoThin();
|
|
1569
|
+
path = geoPath({
|
|
1570
|
+
stream: (s: never) =>
|
|
1571
|
+
projection.stream(
|
|
1572
|
+
(thin as unknown as { stream: (d: never) => never }).stream(s)
|
|
1573
|
+
),
|
|
1574
|
+
} as never).digits(PATH_DIGITS);
|
|
1320
1575
|
project = (lon, lat) => projection([lon, lat]) ?? null;
|
|
1321
1576
|
}
|
|
1322
1577
|
|
|
@@ -1431,7 +1686,8 @@ export function layoutMap(
|
|
|
1431
1686
|
],
|
|
1432
1687
|
f as never
|
|
1433
1688
|
);
|
|
1434
|
-
const
|
|
1689
|
+
const insetPath = geoPath(proj).digits(PATH_DIGITS);
|
|
1690
|
+
const d = insetPath(f as never) ?? '';
|
|
1435
1691
|
if (!d) return xr;
|
|
1436
1692
|
// Neighbour land projected with this same fitted projection, clipped to the
|
|
1437
1693
|
// box. Alaska's only land neighbour is Canada; drawing it behind AK turns
|
|
@@ -1440,7 +1696,7 @@ export function layoutMap(
|
|
|
1440
1696
|
let contextLand: { d: string; fill: string } | undefined;
|
|
1441
1697
|
if (iso === 'US-AK') {
|
|
1442
1698
|
const can = worldLayer.get('CA');
|
|
1443
|
-
const cd = can ? (
|
|
1699
|
+
const cd = can ? (insetPath(can as never) ?? '') : '';
|
|
1444
1700
|
if (cd)
|
|
1445
1701
|
contextLand = {
|
|
1446
1702
|
d: cd,
|
|
@@ -2003,6 +2259,13 @@ export function layoutMap(
|
|
|
2003
2259
|
: 1;
|
|
2004
2260
|
return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
|
|
2005
2261
|
};
|
|
2262
|
+
// Fade the fill as the bubble grows (stroke handled separately at render).
|
|
2263
|
+
const fillOpacityFor = (r: number): number => {
|
|
2264
|
+
const t = Math.max(0, Math.min(1, (r - R_MIN) / (R_MAX - R_MIN)));
|
|
2265
|
+
return (
|
|
2266
|
+
POI_FILL_OPACITY_MAX - t * (POI_FILL_OPACITY_MAX - POI_FILL_OPACITY_MIN)
|
|
2267
|
+
);
|
|
2268
|
+
};
|
|
2006
2269
|
|
|
2007
2270
|
// POI fill precedence (§24B.5): a direct §1.5 trailing color wins, then the
|
|
2008
2271
|
// FIRST declared tag group for which the POI has a value (AR4), then orange.
|
|
@@ -2028,6 +2291,21 @@ export function layoutMap(
|
|
|
2028
2291
|
};
|
|
2029
2292
|
};
|
|
2030
2293
|
|
|
2294
|
+
// Connector colour (§24B.6): a tag on the edge/leg LINE colours the line. Walk
|
|
2295
|
+
// the declared tag groups (first match wins, like poiFill) and return its hex,
|
|
2296
|
+
// or null → caller falls back to the neutral connector mix.
|
|
2297
|
+
const lineColor = (tags: Readonly<Record<string, string>>): string | null => {
|
|
2298
|
+
for (const group of resolved.tagGroups) {
|
|
2299
|
+
const val = tags[group.name.toLowerCase()];
|
|
2300
|
+
if (!val) continue;
|
|
2301
|
+
const entry = group.entries.find(
|
|
2302
|
+
(e) => e.value.toLowerCase() === val.toLowerCase()
|
|
2303
|
+
);
|
|
2304
|
+
if (entry?.color) return entry.color; // already hex (parser-resolved)
|
|
2305
|
+
}
|
|
2306
|
+
return null;
|
|
2307
|
+
};
|
|
2308
|
+
|
|
2031
2309
|
// Route metadata first so POIs know origin/number.
|
|
2032
2310
|
const routeNumberById = new Map<string, number>();
|
|
2033
2311
|
const originIds = new Set<string>();
|
|
@@ -2060,14 +2338,16 @@ export function layoutMap(
|
|
|
2060
2338
|
clusterId?: string
|
|
2061
2339
|
): void => {
|
|
2062
2340
|
const { fill, stroke } = poiFill(e.p);
|
|
2063
|
-
|
|
2341
|
+
const r = radiusFor(e.p);
|
|
2342
|
+
poiScreen.set(e.p.id, { cx, cy, r });
|
|
2064
2343
|
const num = routeNumberById.get(e.p.id);
|
|
2065
2344
|
pois.push({
|
|
2066
2345
|
id: e.p.id,
|
|
2067
2346
|
cx,
|
|
2068
2347
|
cy,
|
|
2069
|
-
r
|
|
2348
|
+
r,
|
|
2070
2349
|
fill,
|
|
2350
|
+
fillOpacity: fillOpacityFor(r),
|
|
2071
2351
|
stroke,
|
|
2072
2352
|
lineNumber: e.p.lineNumber,
|
|
2073
2353
|
implicit: !!e.p.implicit,
|
|
@@ -2264,8 +2544,11 @@ export function layoutMap(
|
|
|
2264
2544
|
legs.push({
|
|
2265
2545
|
d: legPath(a, b, bow.curved, bow.offset),
|
|
2266
2546
|
width: routeWidthFor(Number(leg.value)),
|
|
2267
|
-
color: mix(palette.text, palette.bg, 72),
|
|
2547
|
+
color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
|
|
2268
2548
|
arrow: true,
|
|
2549
|
+
fromId: leg.fromId,
|
|
2550
|
+
toId: leg.toId,
|
|
2551
|
+
...(Object.keys(leg.tags).length > 0 && { tags: leg.tags }),
|
|
2269
2552
|
lineNumber: leg.lineNumber,
|
|
2270
2553
|
...(leg.label !== undefined && {
|
|
2271
2554
|
label: leg.label,
|
|
@@ -2321,8 +2604,11 @@ export function layoutMap(
|
|
|
2321
2604
|
legs.push({
|
|
2322
2605
|
d: legPath(a, b, bow.curved, bow.offset),
|
|
2323
2606
|
width: widthFor(e),
|
|
2324
|
-
color: mix(palette.text, palette.bg, 66),
|
|
2607
|
+
color: lineColor(e.tags) ?? mix(palette.text, palette.bg, 66),
|
|
2325
2608
|
arrow: e.directed,
|
|
2609
|
+
fromId: e.fromId,
|
|
2610
|
+
toId: e.toId,
|
|
2611
|
+
...(Object.keys(e.tags).length > 0 && { tags: e.tags }),
|
|
2326
2612
|
lineNumber: e.lineNumber,
|
|
2327
2613
|
...(e.label !== undefined && {
|
|
2328
2614
|
label: e.label,
|
|
@@ -2339,6 +2625,11 @@ export function layoutMap(
|
|
|
2339
2625
|
// -- Labels: regions + POIs with escalation (AR5) --
|
|
2340
2626
|
const labels: PlacedLabel[] = [];
|
|
2341
2627
|
const obstacles: LabelRect[] = [];
|
|
2628
|
+
// Region/orientation labels are the frame; POI labels are the subject. The
|
|
2629
|
+
// region pass runs first (can't yet see where POI labels land), so each region
|
|
2630
|
+
// label registers a guard here; after POI placement any guard a POI label
|
|
2631
|
+
// overlaps yields — the region label is removed rather than crammed.
|
|
2632
|
+
const regionLabelGuards: Array<{ label: PlacedLabel; rect: LabelRect }> = [];
|
|
2342
2633
|
const markers: PointCircle[] = pois.map((p) => ({
|
|
2343
2634
|
cx: p.cx,
|
|
2344
2635
|
cy: p.cy,
|
|
@@ -2392,18 +2683,91 @@ export function layoutMap(
|
|
|
2392
2683
|
// ocean. At the compact breakpoint (decision D2) the abbreviation is preferred
|
|
2393
2684
|
// FIRST for US states.
|
|
2394
2685
|
const showRegionLabels = resolved.directives.noRegionLabels !== true;
|
|
2686
|
+
// Metric value shown UNDER each data region's name (`no-region-value` opts out).
|
|
2687
|
+
// The value line is rendered smaller + dimmer than the name; see the renderer.
|
|
2688
|
+
// Scoped to a `region-metric` choropleth: only when the SCORE ramp is the active
|
|
2689
|
+
// colouring dimension (not a tag-coloured / categorical map) is the numeric
|
|
2690
|
+
// value the data on display, so that's the only case it's surfaced.
|
|
2691
|
+
const showRegionValues =
|
|
2692
|
+
resolved.directives.noRegionValue !== true && activeIsScore;
|
|
2693
|
+
// Compact value string for a region, or undefined when there's nothing to show
|
|
2694
|
+
// (no value, or the feature is off). Shared formatter so it matches the legend.
|
|
2695
|
+
const regionValueStr = (value: number | undefined): string | undefined =>
|
|
2696
|
+
showRegionValues && value !== undefined ? compactNumber(value) : undefined;
|
|
2395
2697
|
const isCompact = width < COMPACT_WIDTH_PX;
|
|
2698
|
+
// Zoomed sub-national US choropleth (map-us-subnational-zoom): a US-states
|
|
2699
|
+
// mercator view with the score ramp active. Here a cramped state (NH, RI, CT,
|
|
2700
|
+
// NJ, DE) should NOT degrade to its 2-letter abbreviation — the user reads the
|
|
2701
|
+
// abbreviation poorly and a stray hover-name then steps on it. Instead it keeps
|
|
2702
|
+
// its FULL name and, if that won't fit in place, takes a leader-lined margin
|
|
2703
|
+
// callout (full name + value). Only a handful of states are in frame at this
|
|
2704
|
+
// zoom, so the callout column stays short. National (albers) maps keep the
|
|
2705
|
+
// abbreviation cascade — 50 full-name callouts would be unreadable.
|
|
2706
|
+
const usChoroplethZoom =
|
|
2707
|
+
resolved.projection === 'mercator' &&
|
|
2708
|
+
resolved.basemaps.subdivisions.includes('us-states') &&
|
|
2709
|
+
activeIsScore;
|
|
2396
2710
|
const LABEL_PADX = 6;
|
|
2397
2711
|
const LABEL_PADY = 3;
|
|
2398
|
-
|
|
2399
|
-
|
|
2712
|
+
// The value line is ~0.82× the name size; a hair of vertical gap separates them.
|
|
2713
|
+
const VALUE_FONT = Math.round(FONT * 0.82);
|
|
2714
|
+
const VALUE_GAP = 1;
|
|
2715
|
+
const labelW = (text: string, font: number = FONT): number =>
|
|
2716
|
+
measureLegendText(text, font) + 2 * LABEL_PADX;
|
|
2400
2717
|
const labelH = FONT + 2 * LABEL_PADY;
|
|
2718
|
+
// Footprint of a name (+optional value) stack used for the box-fit cascade.
|
|
2719
|
+
// `font` defaults to the base size (every existing call is byte-identical);
|
|
2720
|
+
// the post-placement growth pass passes a larger size to test an upscaled fit.
|
|
2721
|
+
const stackW = (
|
|
2722
|
+
text: string,
|
|
2723
|
+
valueText?: string,
|
|
2724
|
+
font: number = FONT
|
|
2725
|
+
): number =>
|
|
2726
|
+
Math.max(
|
|
2727
|
+
labelW(text, font),
|
|
2728
|
+
valueText
|
|
2729
|
+
? measureLegendText(valueText, Math.round(font * 0.82)) + 2 * LABEL_PADX
|
|
2730
|
+
: 0
|
|
2731
|
+
);
|
|
2732
|
+
const stackH = (hasValue: boolean, font: number = FONT): number => {
|
|
2733
|
+
const lh = font + 2 * LABEL_PADY;
|
|
2734
|
+
return hasValue ? lh + VALUE_GAP + Math.round(font * 0.82) : lh;
|
|
2735
|
+
};
|
|
2736
|
+
// Footprint-driven label growth (size-up + fade), gradual + resolution-free.
|
|
2737
|
+
// Applies to ORIENTATION backdrop names ONLY (neighbour land / frame
|
|
2738
|
+
// containers with no data value): a big one reads as a large, gently-faded
|
|
2739
|
+
// backdrop, a small one stays at the base font. DATA labels are deliberately
|
|
2740
|
+
// EXCLUDED — fading a choropleth value washes it lighter than its own fill and
|
|
2741
|
+
// a loose bbox overran irregular regions. Size scales with the region's
|
|
2742
|
+
// projected footprint as a fraction of the canvas's linear extent. Growth runs
|
|
2743
|
+
// AFTER the base-font fit cascade picks the text+anchor, and only while the
|
|
2744
|
+
// larger glyphs still fit the box, clear neighbours/POIs, and stay inside the
|
|
2745
|
+
// region's own fill.
|
|
2746
|
+
const REGION_FONT_MAX_ORIENT = 22; // px ceiling, backdrop names
|
|
2747
|
+
const REGION_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
|
|
2748
|
+
const REGION_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
|
|
2749
|
+
const REGION_FADE_ORIENT = 45; // % toward bg at max size, backdrop
|
|
2750
|
+
const canvasLinear = Math.sqrt(Math.max(1, width * height));
|
|
2751
|
+
const sizeT = (boxW: number, boxH: number): number => {
|
|
2752
|
+
const frac = Math.sqrt(Math.max(0, boxW * boxH)) / canvasLinear;
|
|
2753
|
+
return Math.min(
|
|
2754
|
+
1,
|
|
2755
|
+
Math.max(
|
|
2756
|
+
0,
|
|
2757
|
+
(frac - REGION_SIZE_FRAC_MIN) /
|
|
2758
|
+
(REGION_SIZE_FRAC_MAX - REGION_SIZE_FRAC_MIN)
|
|
2759
|
+
)
|
|
2760
|
+
);
|
|
2761
|
+
};
|
|
2401
2762
|
const pushRegionLabel = (
|
|
2402
2763
|
x: number,
|
|
2403
2764
|
y: number,
|
|
2404
2765
|
text: string,
|
|
2405
2766
|
fill: string,
|
|
2406
|
-
lineNumber: number
|
|
2767
|
+
lineNumber: number,
|
|
2768
|
+
valueLine?: string,
|
|
2769
|
+
fontSize: number = FONT,
|
|
2770
|
+
fade: number = 0
|
|
2407
2771
|
): void => {
|
|
2408
2772
|
// Colour is contrast-picked against the region's own fill (see labelOnFill).
|
|
2409
2773
|
// The halo, though, is gated by CONTAINMENT — not fill tone. A label that
|
|
@@ -2415,9 +2779,20 @@ export function layoutMap(
|
|
|
2415
2779
|
// to stay legible. Sample the label's screen footprint against the drawn
|
|
2416
2780
|
// fills: if any extreme lands on a fill other than the region's own, the
|
|
2417
2781
|
// label overflows and earns a halo.
|
|
2418
|
-
const { color, haloColor } = labelOnFill(fill);
|
|
2419
|
-
|
|
2420
|
-
|
|
2782
|
+
const { color: baseColor, haloColor } = labelOnFill(fill);
|
|
2783
|
+
// Subdue a grown label toward the background — gentle on data (value stays
|
|
2784
|
+
// readable), stronger on orientation backdrop. Zero fade ⇒ exact base color.
|
|
2785
|
+
const color = fade > 0 ? mix(baseColor, palette.bg, fade) : baseColor;
|
|
2786
|
+
// Widest of name / value drives the overflow sample (the value line can be
|
|
2787
|
+
// the wider of the two, e.g. a short name over a long number). Scales with
|
|
2788
|
+
// the actual (possibly grown) font so the halo gate matches what's drawn.
|
|
2789
|
+
const vf = Math.round(fontSize * 0.82);
|
|
2790
|
+
const halfW =
|
|
2791
|
+
Math.max(
|
|
2792
|
+
measureLegendText(text, fontSize),
|
|
2793
|
+
valueLine ? measureLegendText(valueLine, vf) : 0
|
|
2794
|
+
) / 2;
|
|
2795
|
+
const overflows = [y - fontSize * 0.55, y - fontSize * 0.1].some(
|
|
2421
2796
|
(sy) => fillAt(x - halfW, sy) !== fill || fillAt(x + halfW, sy) !== fill
|
|
2422
2797
|
);
|
|
2423
2798
|
labels.push({
|
|
@@ -2428,15 +2803,31 @@ export function layoutMap(
|
|
|
2428
2803
|
color,
|
|
2429
2804
|
halo: overflows,
|
|
2430
2805
|
haloColor,
|
|
2806
|
+
...(fontSize !== FONT && { fontSize }),
|
|
2807
|
+
...(valueLine !== undefined && { valueLine }),
|
|
2431
2808
|
lineNumber,
|
|
2432
2809
|
});
|
|
2433
2810
|
};
|
|
2434
2811
|
// A region label's screen footprint, middle-anchored on its centroid, used to
|
|
2435
2812
|
// keep two region labels from overlapping (a small gap adds breathing room).
|
|
2813
|
+
// With a value line the box grows to the taller two-line stack.
|
|
2436
2814
|
const REGION_LABEL_GAP = 2;
|
|
2437
|
-
const regionLabelRect = (
|
|
2438
|
-
|
|
2439
|
-
|
|
2815
|
+
const regionLabelRect = (
|
|
2816
|
+
cx: number,
|
|
2817
|
+
cy: number,
|
|
2818
|
+
text: string,
|
|
2819
|
+
valueText?: string,
|
|
2820
|
+
font: number = FONT
|
|
2821
|
+
): LabelRect => {
|
|
2822
|
+
const vf = Math.round(font * 0.82);
|
|
2823
|
+
const w =
|
|
2824
|
+
Math.max(
|
|
2825
|
+
measureLegendText(text, font),
|
|
2826
|
+
valueText ? measureLegendText(valueText, vf) : 0
|
|
2827
|
+
) +
|
|
2828
|
+
2 * REGION_LABEL_GAP;
|
|
2829
|
+
const h = valueText ? font + VALUE_GAP + vf : font;
|
|
2830
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
2440
2831
|
};
|
|
2441
2832
|
if (showRegionLabels) {
|
|
2442
2833
|
// Gather the placeable region labels, then commit them largest-footprint
|
|
@@ -2463,13 +2854,23 @@ export function layoutMap(
|
|
|
2463
2854
|
const boxW = x1 - x0;
|
|
2464
2855
|
const boxH = y1 - y0;
|
|
2465
2856
|
// full → abbrev → hide. Abbrev exists only for US states; at the compact
|
|
2466
|
-
// breakpoint abbrev is tried first.
|
|
2467
|
-
|
|
2857
|
+
// breakpoint abbrev is tried first. A POI-frame CONTAINER (e.g. the
|
|
2858
|
+
// "California" framing a US cloud-regions map) never degrades to the
|
|
2859
|
+
// 2-letter code to squeeze past its own POIs — it stays full or yields
|
|
2860
|
+
// entirely (the post-POI guard below hides it on collision).
|
|
2861
|
+
// On a zoomed US choropleth, drop the abbreviation entirely (full name or
|
|
2862
|
+
// a leader callout — never "NH"). Elsewhere the full → abbrev → hide
|
|
2863
|
+
// cascade stands (compact tries abbrev first; a POI container never
|
|
2864
|
+
// abbreviates).
|
|
2865
|
+
const abbrev =
|
|
2866
|
+
isUsState && !usChoroplethZoom ? r.id.replace(/^US-/, '') : undefined;
|
|
2468
2867
|
const candidates =
|
|
2469
2868
|
abbrev !== undefined
|
|
2470
2869
|
? isCompact
|
|
2471
2870
|
? [abbrev, r.label]
|
|
2472
|
-
:
|
|
2871
|
+
: isContainer
|
|
2872
|
+
? [r.label]
|
|
2873
|
+
: [r.label, abbrev]
|
|
2473
2874
|
: [r.label];
|
|
2474
2875
|
const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : undefined;
|
|
2475
2876
|
const c = anchor
|
|
@@ -2481,6 +2882,18 @@ export function layoutMap(
|
|
|
2481
2882
|
.filter((e): e is NonNullable<typeof e> => e !== null)
|
|
2482
2883
|
.sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
|
|
2483
2884
|
const placedRegionRects: LabelRect[] = [];
|
|
2885
|
+
// Valued regions too small to carry their name+value stack in place — gathered
|
|
2886
|
+
// here and laid out as a margin callout column after the in-place pass.
|
|
2887
|
+
const regionCallouts: Array<{
|
|
2888
|
+
name: string;
|
|
2889
|
+
value: string;
|
|
2890
|
+
cx: number;
|
|
2891
|
+
cy: number;
|
|
2892
|
+
bw: number;
|
|
2893
|
+
bh: number;
|
|
2894
|
+
fill: string;
|
|
2895
|
+
lineNumber: number;
|
|
2896
|
+
}> = [];
|
|
2484
2897
|
// POI markers are obstacles for region labels: a region whose centroid sits on
|
|
2485
2898
|
// a POI (e.g. Colorado's centroid under the "Core POP" dot in Denver) must NOT
|
|
2486
2899
|
// stamp its name there — the POI's own label owns that spot, and two names by
|
|
@@ -2496,35 +2909,394 @@ export function layoutMap(
|
|
|
2496
2909
|
w: 2 * (p.r + POI_LABEL_PAD),
|
|
2497
2910
|
h: 2 * (p.r + POI_LABEL_PAD),
|
|
2498
2911
|
}));
|
|
2912
|
+
// Ocean side of the frame (zoomed US choropleth callouts column there). Sample
|
|
2913
|
+
// a vertical strip just inside each side edge; the side with more open water
|
|
2914
|
+
// hosts the callout column, so leaders run over sea, not across the interior.
|
|
2915
|
+
const waterSideOf = (): 'left' | 'right' => {
|
|
2916
|
+
let leftHits = 0;
|
|
2917
|
+
let rightHits = 0;
|
|
2918
|
+
const lx = width * 0.06;
|
|
2919
|
+
const rx = width * 0.94;
|
|
2920
|
+
for (let i = 1; i < 12; i++) {
|
|
2921
|
+
const y = topPad + ((height - topPad) * i) / 12;
|
|
2922
|
+
if (fillAt(lx, y) === water) leftHits++;
|
|
2923
|
+
if (fillAt(rx, y) === water) rightHits++;
|
|
2924
|
+
}
|
|
2925
|
+
return rightHits >= leftHits ? 'right' : 'left';
|
|
2926
|
+
};
|
|
2927
|
+
const calloutSide = usChoroplethZoom ? waterSideOf() : undefined;
|
|
2499
2928
|
for (const { r, c, boxW, boxH, candidates } of entries) {
|
|
2929
|
+
const valStr = regionValueStr(r.value);
|
|
2930
|
+
// A region hugs a canvas edge if it sits within a short leader's reach of
|
|
2931
|
+
// it — only such a region may use a margin callout column, so the leader is
|
|
2932
|
+
// always SHORT (no cross-map lines for a centred region).
|
|
2933
|
+
const maxLeader = Math.min(width * 0.26, 300);
|
|
2934
|
+
// "Near an edge" is measured against the LAND-facing edge of each reserved
|
|
2935
|
+
// band when a reserve is active (second pass) — the map has shrunk away from
|
|
2936
|
+
// that side, so the cluster now sits at the band's inner edge, not the raw
|
|
2937
|
+
// canvas edge. Without a reserve (first pass) this is just the canvas edge.
|
|
2938
|
+
const rsv = opts._calloutReserve;
|
|
2939
|
+
const rEdge = rsv?.right ? width - rsv.right : width;
|
|
2940
|
+
const lEdge = rsv?.left ?? 0;
|
|
2941
|
+
// On a zoomed US choropleth a cramped state always takes a margin callout (a
|
|
2942
|
+
// full-name + value chip in the ocean-side column, leader from its centroid)
|
|
2943
|
+
// rather than degrading to an abbreviation — only a handful of states are in
|
|
2944
|
+
// frame, so the column stays short. Otherwise a callout is reserved for
|
|
2945
|
+
// edge-hugging regions so no leader runs across a wide view.
|
|
2946
|
+
const nearEdge =
|
|
2947
|
+
usChoroplethZoom ||
|
|
2948
|
+
c[0] >= rEdge - maxLeader ||
|
|
2949
|
+
c[0] <= lEdge + maxLeader;
|
|
2950
|
+
// A tiny region hugging a canvas edge — one whose FULL name won't fit its
|
|
2951
|
+
// own box (RI/CT/NH/MA on a US map) — goes straight to a clean margin
|
|
2952
|
+
// column: a tidy full-name list reads far better than crammed 2-letter
|
|
2953
|
+
// abbreviations piled on the cluster, and the edge keeps the leader short. A
|
|
2954
|
+
// region whose full name DOES fit labels in place as usual; an interior tiny
|
|
2955
|
+
// region (a centred world-map country) is handled by the on-land overflow
|
|
2956
|
+
// below — never a long cross-map leader.
|
|
2957
|
+
if (
|
|
2958
|
+
valStr &&
|
|
2959
|
+
nearEdge &&
|
|
2960
|
+
r.label !== undefined &&
|
|
2961
|
+
(labelW(r.label) > boxW || labelH > boxH)
|
|
2962
|
+
) {
|
|
2963
|
+
regionCallouts.push({
|
|
2964
|
+
name: r.label,
|
|
2965
|
+
value: valStr,
|
|
2966
|
+
cx: c[0],
|
|
2967
|
+
cy: c[1],
|
|
2968
|
+
bw: boxW,
|
|
2969
|
+
bh: boxH,
|
|
2970
|
+
fill: r.fill,
|
|
2971
|
+
lineNumber: r.lineNumber,
|
|
2972
|
+
});
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2500
2975
|
// The first candidate that BOTH fits its own footprint AND clears every
|
|
2501
2976
|
// already-placed region label AND every POI marker wins; none qualifies →
|
|
2502
2977
|
// the label is hidden (a country has no abbrev, so it degrades full → hide;
|
|
2503
2978
|
// a US state may fall back to its 2-letter code before hiding).
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2979
|
+
// When the region carries a metric value, the name+value STACK is tried
|
|
2980
|
+
// first; if the stack won't fit (a smaller state), it degrades to the bare
|
|
2981
|
+
// name (today's behaviour) so adding values never costs an existing label.
|
|
2982
|
+
//
|
|
2983
|
+
// Two collision tests, deliberately different footprints:
|
|
2984
|
+
// - vs other REGION labels: use the FULL stack rect (two stacks must not
|
|
2985
|
+
// overlap).
|
|
2986
|
+
// - vs POI obstacles: use only the NAME rect. A POI obstacle exists to keep
|
|
2987
|
+
// the region NAME off a POI's dot/label; the (shorter, dimmer) value line
|
|
2988
|
+
// hanging below a name that already clears the dot is fine. Testing the
|
|
2989
|
+
// taller stack here made a region with a nearby POI (Texas under the big
|
|
2990
|
+
// Dallas marker) silently drop its value even though the name fit.
|
|
2991
|
+
const fitsRegions = (rect: LabelRect): boolean =>
|
|
2992
|
+
!placedRegionRects.some((p) => rectsOverlap(rect, p));
|
|
2993
|
+
const fitsPois = (rect: LabelRect): boolean =>
|
|
2994
|
+
!poiObstacles.some((o) => rectsOverlap(rect, o));
|
|
2995
|
+
// Try the centroid first (existing placement — unchanged when it fits),
|
|
2996
|
+
// then a ring of offsets WITHIN the region's box so a label blocked at the
|
|
2997
|
+
// centroid (typically a POI marker sitting on it — Dallas on Texas) is
|
|
2998
|
+
// re-seated on open land of the SAME region rather than exiled to a far
|
|
2999
|
+
// callout column. Off-centroid anchors are kept on the region's own fill
|
|
3000
|
+
// (fillAt) so the label never drifts onto a neighbour or the sea.
|
|
3001
|
+
// Centroid is always tried. The off-centroid re-seat ring is added ONLY for
|
|
3002
|
+
// a region that carries a value — the point of seeking is to not lose the
|
|
3003
|
+
// region's VALUE to a POI on its centroid. A valueless frame container
|
|
3004
|
+
// (e.g. the state hosting a POI hub) keeps the old behaviour: it yields the
|
|
3005
|
+
// spot to the POI rather than sprouting a re-seated name near the hub.
|
|
3006
|
+
const seekAnchors: Array<{ x: number; y: number; guard: boolean }> = [
|
|
3007
|
+
{ x: c[0], y: c[1], guard: false },
|
|
3008
|
+
];
|
|
3009
|
+
if (valStr) {
|
|
3010
|
+
seekAnchors.push(
|
|
3011
|
+
{ x: c[0], y: c[1] + boxH * 0.26, guard: true },
|
|
3012
|
+
{ x: c[0], y: c[1] - boxH * 0.26, guard: true },
|
|
3013
|
+
{ x: c[0] + boxW * 0.26, y: c[1], guard: true },
|
|
3014
|
+
{ x: c[0] - boxW * 0.26, y: c[1], guard: true },
|
|
3015
|
+
{ x: c[0] + boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
|
|
3016
|
+
{ x: c[0] - boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
|
|
3017
|
+
{ x: c[0] + boxW * 0.22, y: c[1] - boxH * 0.22, guard: true },
|
|
3018
|
+
{ x: c[0] - boxW * 0.22, y: c[1] - boxH * 0.22, guard: true }
|
|
2510
3019
|
);
|
|
3020
|
+
}
|
|
3021
|
+
let chosen:
|
|
3022
|
+
| { text: string; valueLine?: string; ax: number; ay: number }
|
|
3023
|
+
| undefined;
|
|
3024
|
+
for (const a of seekAnchors) {
|
|
3025
|
+
if (a.guard && fillAt(a.x, a.y) !== r.fill) continue;
|
|
3026
|
+
for (const t of candidates) {
|
|
3027
|
+
const nameRect = regionLabelRect(a.x, a.y, t);
|
|
3028
|
+
if (valStr && stackW(t, valStr) <= boxW && stackH(true) <= boxH) {
|
|
3029
|
+
const stackRect = regionLabelRect(a.x, a.y, t, valStr);
|
|
3030
|
+
if (fitsRegions(stackRect) && fitsPois(nameRect)) {
|
|
3031
|
+
chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
|
|
3032
|
+
break;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
if (labelW(t) <= boxW && labelH <= boxH) {
|
|
3036
|
+
if (fitsRegions(nameRect) && fitsPois(nameRect)) {
|
|
3037
|
+
chosen = { text: t, ax: a.x, ay: a.y };
|
|
3038
|
+
break;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
if (chosen) break;
|
|
3043
|
+
}
|
|
3044
|
+
if (chosen === undefined && valStr) {
|
|
3045
|
+
// A VALUED region not placed in-box, and not an edge-hugging tiny region
|
|
3046
|
+
// (those columned above). Label it ON its own land, letting the name
|
|
3047
|
+
// OVERFLOW its small box onto neighbours/ocean (the halo keeps it legible),
|
|
3048
|
+
// as long as it clears already-placed labels + POIs. This keeps a country
|
|
3049
|
+
// on a world choropleth (Germany, France) labelled in place instead of
|
|
3050
|
+
// exiled to a far margin. If even that collides, the label simply drops —
|
|
3051
|
+
// never a long cross-map leader. Gated to valued regions so a valueless
|
|
3052
|
+
// POI-frame container keeps its old behaviour (yield rather than overflow).
|
|
3053
|
+
for (const a of seekAnchors) {
|
|
3054
|
+
if (fillAt(a.x, a.y) !== r.fill) continue;
|
|
3055
|
+
for (const t of candidates) {
|
|
3056
|
+
const nameRect = regionLabelRect(a.x, a.y, t);
|
|
3057
|
+
if (
|
|
3058
|
+
valStr &&
|
|
3059
|
+
fitsRegions(regionLabelRect(a.x, a.y, t, valStr)) &&
|
|
3060
|
+
fitsPois(nameRect)
|
|
3061
|
+
) {
|
|
3062
|
+
chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
|
|
3063
|
+
break;
|
|
3064
|
+
}
|
|
3065
|
+
if (fitsRegions(nameRect) && fitsPois(nameRect)) {
|
|
3066
|
+
chosen = { text: t, ax: a.x, ay: a.y };
|
|
3067
|
+
break;
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
if (chosen) break;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
// Nothing placed (a valueless region that didn't fit, or a valued region
|
|
3074
|
+
// whose overflow also collided) → drop, leaving the map clean.
|
|
3075
|
+
if (chosen === undefined) continue;
|
|
3076
|
+
// Footprint-driven growth applies ONLY to orientation backdrop names — a
|
|
3077
|
+
// data-less neighbour/frame region (Canada framing a POI, foreign land).
|
|
3078
|
+
// DATA labels (a choropleth value) keep the base font + full contrast and
|
|
3079
|
+
// the existing fit-inside cascade UNCHANGED: fading a value washed it
|
|
3080
|
+
// lighter than its own region fill, and a loose bbox let a wide name
|
|
3081
|
+
// ("United States of America") spill past its region. Orientation names sit
|
|
3082
|
+
// on neutral basemap land where a larger, gently-faded backdrop reads well.
|
|
3083
|
+
const isOrient = r.value === undefined && r.layer === 'base';
|
|
3084
|
+
let font = FONT;
|
|
3085
|
+
let fade = 0;
|
|
3086
|
+
if (isOrient) {
|
|
3087
|
+
const growT = sizeT(boxW, boxH);
|
|
3088
|
+
const desiredFont = Math.round(
|
|
3089
|
+
FONT + growT * (REGION_FONT_MAX_ORIENT - FONT)
|
|
3090
|
+
);
|
|
3091
|
+
const hasVal = chosen.valueLine !== undefined;
|
|
3092
|
+
for (let f = desiredFont; f > FONT; f--) {
|
|
3093
|
+
// Fit the footprint box, clear neighbours/POIs, AND — the real guard —
|
|
3094
|
+
// stay INSIDE the region's own fill at the bigger size. The bbox is far
|
|
3095
|
+
// too loose for an irregular shape (Alaska blows up the US bbox), so
|
|
3096
|
+
// sample the grown name's horizontal extremes against `fillAt`: if
|
|
3097
|
+
// either leaves this region's fill, don't grow that far.
|
|
3098
|
+
if (
|
|
3099
|
+
stackW(chosen.text, chosen.valueLine, f) > boxW ||
|
|
3100
|
+
stackH(hasVal, f) > boxH
|
|
3101
|
+
)
|
|
3102
|
+
continue;
|
|
3103
|
+
const gRect = regionLabelRect(
|
|
3104
|
+
chosen.ax,
|
|
3105
|
+
chosen.ay,
|
|
3106
|
+
chosen.text,
|
|
3107
|
+
chosen.valueLine,
|
|
3108
|
+
f
|
|
3109
|
+
);
|
|
3110
|
+
const gName = regionLabelRect(
|
|
3111
|
+
chosen.ax,
|
|
3112
|
+
chosen.ay,
|
|
3113
|
+
chosen.text,
|
|
3114
|
+
undefined,
|
|
3115
|
+
f
|
|
3116
|
+
);
|
|
3117
|
+
if (!fitsRegions(gRect) || !fitsPois(gName)) continue;
|
|
3118
|
+
const halfW = measureLegendText(chosen.text, f) / 2;
|
|
3119
|
+
if (
|
|
3120
|
+
fillAt(chosen.ax - halfW, chosen.ay) !== r.fill ||
|
|
3121
|
+
fillAt(chosen.ax + halfW, chosen.ay) !== r.fill
|
|
3122
|
+
)
|
|
3123
|
+
continue;
|
|
3124
|
+
font = f;
|
|
3125
|
+
break;
|
|
3126
|
+
}
|
|
3127
|
+
fade = font > FONT ? Math.round(growT * REGION_FADE_ORIENT) : 0;
|
|
3128
|
+
}
|
|
3129
|
+
const rRect = regionLabelRect(
|
|
3130
|
+
chosen.ax,
|
|
3131
|
+
chosen.ay,
|
|
3132
|
+
chosen.text,
|
|
3133
|
+
chosen.valueLine,
|
|
3134
|
+
font
|
|
3135
|
+
);
|
|
3136
|
+
placedRegionRects.push(rRect);
|
|
3137
|
+
pushRegionLabel(
|
|
3138
|
+
chosen.ax,
|
|
3139
|
+
chosen.ay,
|
|
3140
|
+
chosen.text,
|
|
3141
|
+
r.fill,
|
|
3142
|
+
r.lineNumber,
|
|
3143
|
+
chosen.valueLine,
|
|
3144
|
+
font,
|
|
3145
|
+
fade
|
|
3146
|
+
);
|
|
3147
|
+
// Guard so a POI label landing here later makes this label yield (below).
|
|
3148
|
+
regionLabelGuards.push({
|
|
3149
|
+
label: labels[labels.length - 1]!,
|
|
3150
|
+
rect: rRect,
|
|
2511
3151
|
});
|
|
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
3152
|
}
|
|
2516
3153
|
// AK/HI labels live in their insets (own projection centroids). Insets are
|
|
2517
3154
|
// tiny, so prefer the abbreviation when the canvas is compact.
|
|
2518
3155
|
for (const seed of insetLabelSeeds) {
|
|
2519
3156
|
const text = isCompact ? seed.iso.replace(/^US-/, '') : seed.name;
|
|
2520
3157
|
const src = regionById.get(seed.iso);
|
|
3158
|
+
const valStr = regionValueStr(src?.value);
|
|
2521
3159
|
pushRegionLabel(
|
|
2522
3160
|
seed.x,
|
|
2523
3161
|
seed.y,
|
|
2524
3162
|
text,
|
|
2525
3163
|
src ? regionFill(src) : neutralFill,
|
|
2526
|
-
seed.lineNumber
|
|
3164
|
+
seed.lineNumber,
|
|
3165
|
+
valStr
|
|
2527
3166
|
);
|
|
3167
|
+
regionLabelGuards.push({
|
|
3168
|
+
label: labels[labels.length - 1]!,
|
|
3169
|
+
rect: regionLabelRect(seed.x, seed.y, text, valStr),
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// Zoom-out reserve (first pass → re-run): tiny valued regions need margin
|
|
3174
|
+
// callouts, and the map currently fills the canvas with no room for them.
|
|
3175
|
+
// Measure them, reserve a band on the side the cluster leans toward, and
|
|
3176
|
+
// re-run the whole layout fitted into the canvas MINUS that band — the map
|
|
3177
|
+
// shrinks/shifts away from that edge and the callouts get real room. Guarded
|
|
3178
|
+
// by `_calloutReserve` so it recurses exactly once.
|
|
3179
|
+
if (regionCallouts.length > 0 && !opts._calloutReserve) {
|
|
3180
|
+
// Split the callouts by the side of the canvas they fall on — a cluster on
|
|
3181
|
+
// each coast gets its own reserved band + column. Band = widest chip in the
|
|
3182
|
+
// group + a leader run + edge padding, clamped so one stray callout never
|
|
3183
|
+
// over-shrinks the map nor a long name starves it.
|
|
3184
|
+
const bandFor = (group: typeof regionCallouts): number | undefined => {
|
|
3185
|
+
if (group.length === 0) return undefined;
|
|
3186
|
+
const maxChipW = group.reduce(
|
|
3187
|
+
(m, rc) =>
|
|
3188
|
+
Math.max(
|
|
3189
|
+
m,
|
|
3190
|
+
measureLegendText(rc.name, FONT),
|
|
3191
|
+
measureLegendText(rc.value, VALUE_FONT)
|
|
3192
|
+
),
|
|
3193
|
+
0
|
|
3194
|
+
);
|
|
3195
|
+
return Math.max(130, Math.min(maxChipW + 96, Math.floor(width * 0.3)));
|
|
3196
|
+
};
|
|
3197
|
+
// On a zoomed US choropleth all callouts share the ocean-side column (leaders
|
|
3198
|
+
// over sea, never across the interior); elsewhere split by the side each
|
|
3199
|
+
// region leans toward.
|
|
3200
|
+
const right =
|
|
3201
|
+
calloutSide === 'right'
|
|
3202
|
+
? regionCallouts
|
|
3203
|
+
: calloutSide === 'left'
|
|
3204
|
+
? []
|
|
3205
|
+
: regionCallouts.filter((rc) => rc.cx >= width / 2);
|
|
3206
|
+
const left =
|
|
3207
|
+
calloutSide === 'left'
|
|
3208
|
+
? regionCallouts
|
|
3209
|
+
: calloutSide === 'right'
|
|
3210
|
+
? []
|
|
3211
|
+
: regionCallouts.filter((rc) => rc.cx < width / 2);
|
|
3212
|
+
const leftPx = bandFor(left);
|
|
3213
|
+
const rightPx = bandFor(right);
|
|
3214
|
+
return layoutMap(resolved, data, size, {
|
|
3215
|
+
...opts,
|
|
3216
|
+
_calloutReserve: {
|
|
3217
|
+
...(leftPx !== undefined && { left: leftPx }),
|
|
3218
|
+
...(rightPx !== undefined && { right: rightPx }),
|
|
3219
|
+
},
|
|
3220
|
+
});
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
// ── Radial callouts for valued regions too small to label in place ──
|
|
3224
|
+
// Each gathered region gets a leader-lined chip (its name over the metric
|
|
3225
|
+
// value, same stack as an in-place label) placed in the OPEN SPACE around the
|
|
3226
|
+
// cluster: the chip marches OUTWARD from the cluster centre along its own
|
|
3227
|
+
// angle (so a dense cluster fans its labels out in all directions — east into
|
|
3228
|
+
// the ocean, north over Canada, etc.) until it clears the data regions, the
|
|
3229
|
+
// in-place labels, and the other chips. A small dot marks the region's true
|
|
3230
|
+
// centroid; the leader runs dot → chip. Chips may overlay unvalued base land
|
|
3231
|
+
// (e.g. Canada) but never a VALUED region's fill (keep the choropleth clean).
|
|
3232
|
+
if (regionCallouts.length > 0) {
|
|
3233
|
+
// Tidy callout column(s) in the reserved margin(s) the zoom-out pass opened.
|
|
3234
|
+
// Each chip is a name+value stack anchored just inside the band; a leader
|
|
3235
|
+
// runs from the region's centroid dot to the chip's inner edge. Rows are
|
|
3236
|
+
// ordered top→bottom by screen latitude so the column reads geographically
|
|
3237
|
+
// and the leaders stay short and roughly parallel. A cluster on each side of
|
|
3238
|
+
// the canvas gets its own column in its own reserved band.
|
|
3239
|
+
const reserveInfo = opts._calloutReserve;
|
|
3240
|
+
const EDGE = 28;
|
|
3241
|
+
const COL_GAP = 16; // chip inset from the land-facing edge of the band
|
|
3242
|
+
const chipH = FONT + VALUE_GAP + VALUE_FONT;
|
|
3243
|
+
const ROW = chipH + 10;
|
|
3244
|
+
const placeColumn = (
|
|
3245
|
+
group: typeof regionCallouts,
|
|
3246
|
+
side: 'left' | 'right',
|
|
3247
|
+
bandPx: number
|
|
3248
|
+
): void => {
|
|
3249
|
+
if (group.length === 0) return;
|
|
3250
|
+
const anchor: PlacedLabel['anchor'] =
|
|
3251
|
+
side === 'right' ? 'start' : 'end';
|
|
3252
|
+
const colX =
|
|
3253
|
+
side === 'right' ? width - bandPx + COL_GAP : bandPx - COL_GAP;
|
|
3254
|
+
const rows = [...group].sort((a, b) => a.cy - b.cy);
|
|
3255
|
+
const meanCy = rows.reduce((s, rc) => s + rc.cy, 0) / rows.length;
|
|
3256
|
+
const totalH = rows.length * ROW;
|
|
3257
|
+
const minTop = topPad + 6 + ROW / 2;
|
|
3258
|
+
const maxTop = Math.max(minTop, height - EDGE - totalH + ROW / 2);
|
|
3259
|
+
const startY = Math.max(
|
|
3260
|
+
minTop,
|
|
3261
|
+
Math.min(meanCy - totalH / 2 + ROW / 2, maxTop)
|
|
3262
|
+
);
|
|
3263
|
+
rows.forEach((rc, i) => {
|
|
3264
|
+
const ry = startY + i * ROW;
|
|
3265
|
+
const innerX = side === 'right' ? colX - 4 : colX + 4;
|
|
3266
|
+
// Darken the region's hue toward the text colour for leader/dot contrast
|
|
3267
|
+
// (a pale low-value fill on its own is near-invisible) while still tying
|
|
3268
|
+
// the line to its region by colour.
|
|
3269
|
+
const dark = mix(rc.fill, palette.text, 60);
|
|
3270
|
+
labels.push({
|
|
3271
|
+
x: colX,
|
|
3272
|
+
y: ry,
|
|
3273
|
+
text: rc.name,
|
|
3274
|
+
anchor,
|
|
3275
|
+
color: palette.text,
|
|
3276
|
+
halo: true,
|
|
3277
|
+
haloColor: palette.bg,
|
|
3278
|
+
valueLine: rc.value,
|
|
3279
|
+
leader: { x1: rc.cx, y1: rc.cy, x2: innerX, y2: ry },
|
|
3280
|
+
leaderColor: dark,
|
|
3281
|
+
calloutDot: { x: rc.cx, y: rc.cy, color: dark },
|
|
3282
|
+
lineNumber: rc.lineNumber,
|
|
3283
|
+
});
|
|
3284
|
+
});
|
|
3285
|
+
};
|
|
3286
|
+
const right =
|
|
3287
|
+
calloutSide === 'right'
|
|
3288
|
+
? regionCallouts
|
|
3289
|
+
: calloutSide === 'left'
|
|
3290
|
+
? []
|
|
3291
|
+
: regionCallouts.filter((rc) => rc.cx >= width / 2);
|
|
3292
|
+
const left =
|
|
3293
|
+
calloutSide === 'left'
|
|
3294
|
+
? regionCallouts
|
|
3295
|
+
: calloutSide === 'right'
|
|
3296
|
+
? []
|
|
3297
|
+
: regionCallouts.filter((rc) => rc.cx < width / 2);
|
|
3298
|
+
placeColumn(right, 'right', reserveInfo?.right ?? 150);
|
|
3299
|
+
placeColumn(left, 'left', reserveInfo?.left ?? 150);
|
|
2528
3300
|
}
|
|
2529
3301
|
}
|
|
2530
3302
|
|
|
@@ -2551,6 +3323,16 @@ export function layoutMap(
|
|
|
2551
3323
|
// from the east AND west — Boulder in the route-cluster gauntlet).
|
|
2552
3324
|
type Side = 'right' | 'left' | 'above' | 'below';
|
|
2553
3325
|
const GAP = 3;
|
|
3326
|
+
// Comfort buffer between any dot/label and the canvas edge — canvas-proportional
|
|
3327
|
+
// (≈3% of the shorter axis, floored) so a big preview pane breathes more than a
|
|
3328
|
+
// thumbnail. Used BOTH by the leader-column clamp (so a column never seats hard
|
|
3329
|
+
// against the frame) and by the edge-clearance re-fit below (dots + inline
|
|
3330
|
+
// labels). Keeping the two in sync is what stops the re-fit from fighting a
|
|
3331
|
+
// column that would otherwise re-clamp to the edge each pass.
|
|
3332
|
+
const POI_EDGE_CLEAR = Math.max(
|
|
3333
|
+
20,
|
|
3334
|
+
Math.round(Math.min(width, height) * 0.03)
|
|
3335
|
+
);
|
|
2554
3336
|
// Coincident-stack members (spiderfy) are labelled via a tidy leader-lined
|
|
2555
3337
|
// COLUMN beside the cluster (see the cluster-column pass after the column
|
|
2556
3338
|
// helpers below) — NOT radial inline labels, which pile up unreadably when
|
|
@@ -2589,7 +3371,8 @@ export function layoutMap(
|
|
|
2589
3371
|
p: MapLayoutPoi,
|
|
2590
3372
|
text: string,
|
|
2591
3373
|
w: number,
|
|
2592
|
-
side: Side
|
|
3374
|
+
side: Side,
|
|
3375
|
+
clusterId?: string
|
|
2593
3376
|
): void => {
|
|
2594
3377
|
const rect = inlineRect(p, w, side);
|
|
2595
3378
|
obstacles.push(rect);
|
|
@@ -2606,6 +3389,7 @@ export function layoutMap(
|
|
|
2606
3389
|
haloColor: palette.bg,
|
|
2607
3390
|
poiId: p.id,
|
|
2608
3391
|
lineNumber: p.lineNumber,
|
|
3392
|
+
...(clusterId !== undefined && { clusterMember: clusterId }),
|
|
2609
3393
|
});
|
|
2610
3394
|
};
|
|
2611
3395
|
const inlineFits = (p: MapLayoutPoi, w: number, side: Side): boolean => {
|
|
@@ -2664,11 +3448,14 @@ export function layoutMap(
|
|
|
2664
3448
|
// colX; a left column anchors its end at colX (text spans colX-maxW..colX).
|
|
2665
3449
|
const colX =
|
|
2666
3450
|
side === 'right'
|
|
2667
|
-
? Math.min(right + COL_GAP, width -
|
|
2668
|
-
: Math.max(left - COL_GAP,
|
|
3451
|
+
? Math.min(right + COL_GAP, width - POI_EDGE_CLEAR - maxW)
|
|
3452
|
+
: Math.max(left - COL_GAP, POI_EDGE_CLEAR + maxW);
|
|
2669
3453
|
const totalH = items.length * step;
|
|
2670
3454
|
let startY = cyMid - totalH / 2;
|
|
2671
|
-
startY = Math.max(
|
|
3455
|
+
startY = Math.max(
|
|
3456
|
+
POI_EDGE_CLEAR,
|
|
3457
|
+
Math.min(startY, height - totalH - POI_EDGE_CLEAR)
|
|
3458
|
+
);
|
|
2672
3459
|
return items.map((o, i) => {
|
|
2673
3460
|
const rowCy = startY + i * step + step / 2;
|
|
2674
3461
|
return {
|
|
@@ -2698,12 +3485,50 @@ export function layoutMap(
|
|
|
2698
3485
|
rect.y + rect.h <= height &&
|
|
2699
3486
|
!collides(rect)
|
|
2700
3487
|
);
|
|
2701
|
-
//
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
3488
|
+
// Open-space score for a candidate label rect (higher = better). Cartographic
|
|
3489
|
+
// convention: a coastal point throws its label out over the water, never back
|
|
3490
|
+
// across the land it sits on. So a side whose label footprint lands over open
|
|
3491
|
+
// water dominates; among equally-wet (or equally-dry) sides, the one with more
|
|
3492
|
+
// clearance to the canvas edge wins. Sampled at a fixed 3×2 grid → deterministic.
|
|
3493
|
+
const WATER_PREF = 1000; // a water-facing side beats any land-facing side
|
|
3494
|
+
const openness = (rect: LabelRect): number => {
|
|
3495
|
+
const xs = [
|
|
3496
|
+
rect.x + rect.w * 0.15,
|
|
3497
|
+
rect.x + rect.w * 0.5,
|
|
3498
|
+
rect.x + rect.w * 0.85,
|
|
3499
|
+
];
|
|
3500
|
+
const ys = [rect.y + rect.h * 0.25, rect.y + rect.h * 0.75];
|
|
3501
|
+
let waterHits = 0;
|
|
3502
|
+
for (const x of xs)
|
|
3503
|
+
for (const y of ys) if (fillAt(x, y) === water) waterHits++;
|
|
3504
|
+
const waterFrac = waterHits / (xs.length * ys.length);
|
|
3505
|
+
const edgeClear = Math.max(
|
|
3506
|
+
0,
|
|
3507
|
+
Math.min(
|
|
3508
|
+
rect.x,
|
|
3509
|
+
width - (rect.x + rect.w),
|
|
3510
|
+
rect.y,
|
|
3511
|
+
height - (rect.y + rect.h)
|
|
3512
|
+
)
|
|
3513
|
+
);
|
|
3514
|
+
// edgeClear scaled to ~0..30 so it only breaks ties, never overrides water.
|
|
3515
|
+
return WATER_PREF * waterFrac + edgeClear * 0.1;
|
|
2706
3516
|
};
|
|
3517
|
+
// A column side's openness = mean openness over its rows' label rects.
|
|
3518
|
+
const columnSideScore = (
|
|
3519
|
+
items: ColItem[],
|
|
3520
|
+
side: 'right' | 'left'
|
|
3521
|
+
): number => {
|
|
3522
|
+
const rows = columnRows(items, side);
|
|
3523
|
+
if (rows.length === 0) return -Infinity;
|
|
3524
|
+
return rows.reduce((s, { rect }) => s + openness(rect), 0) / rows.length;
|
|
3525
|
+
};
|
|
3526
|
+
// Side heuristic for ungated callouts: prefer the more open (water-facing,
|
|
3527
|
+
// then roomier) flank rather than blindly seating the column on the right.
|
|
3528
|
+
const defaultColumnSide = (items: ColItem[]): 'right' | 'left' =>
|
|
3529
|
+
columnSideScore(items, 'right') >= columnSideScore(items, 'left')
|
|
3530
|
+
? 'right'
|
|
3531
|
+
: 'left';
|
|
2707
3532
|
// Commit a visible callout column on the GIVEN side (no re-deriving the
|
|
2708
3533
|
// side — the caller has already validated it). When `clusterId` is set the
|
|
2709
3534
|
// rows are tagged `clusterMember` so the app shows/hides them (text AND
|
|
@@ -2763,22 +3588,83 @@ export function layoutMap(
|
|
|
2763
3588
|
});
|
|
2764
3589
|
};
|
|
2765
3590
|
|
|
2766
|
-
//
|
|
2767
|
-
// the
|
|
2768
|
-
//
|
|
2769
|
-
//
|
|
2770
|
-
//
|
|
3591
|
+
// A small coincident stack reads best with each member's label hugging its
|
|
3592
|
+
// OWN fanned dot on the side it fans toward — the fan already seats the dots
|
|
3593
|
+
// radially (member 0 due North, the next due South for a pair, …), so a top
|
|
3594
|
+
// dot takes its label ABOVE and a bottom dot takes it BELOW. Compact and
|
|
3595
|
+
// symmetric, and — unlike a one-sided leader column — it never overruns the
|
|
3596
|
+
// frame when the stack sits hard against a coast (the San Jose case). The
|
|
3597
|
+
// labels carry `clusterMember` so the app still toggles them with the badge.
|
|
3598
|
+
const STACK_RADIAL_MAX = 4; // above/below/left/right — one slot per member
|
|
3599
|
+
const radialSide = (p: MapLayoutPoi, cx: number, cy: number): Side => {
|
|
3600
|
+
const dx = p.cx - cx;
|
|
3601
|
+
const dy = p.cy - cy;
|
|
3602
|
+
return Math.abs(dy) >= Math.abs(dx)
|
|
3603
|
+
? dy <= 0
|
|
3604
|
+
? 'above'
|
|
3605
|
+
: 'below'
|
|
3606
|
+
: dx < 0
|
|
3607
|
+
? 'left'
|
|
3608
|
+
: 'right';
|
|
3609
|
+
};
|
|
3610
|
+
// Seat every member radially (preferred side first, then the rest), each new
|
|
3611
|
+
// label blocking the next. All-or-nothing: if any member can't seat on-canvas
|
|
3612
|
+
// and clean, bail so the caller falls back to the leader-lined column.
|
|
3613
|
+
const tryStackRadial = (items: ColItem[], clusterId: string): boolean => {
|
|
3614
|
+
const cluster = clusters.find((c) => c.id === clusterId);
|
|
3615
|
+
if (!cluster || items.length > STACK_RADIAL_MAX) return false;
|
|
3616
|
+
const temp: LabelRect[] = [];
|
|
3617
|
+
const seated: Array<{
|
|
3618
|
+
p: MapLayoutPoi;
|
|
3619
|
+
text: string;
|
|
3620
|
+
w: number;
|
|
3621
|
+
side: Side;
|
|
3622
|
+
}> = [];
|
|
3623
|
+
for (const { p, text, w } of items) {
|
|
3624
|
+
const pref = radialSide(p, cluster.cx, cluster.cy);
|
|
3625
|
+
const order: Side[] = [
|
|
3626
|
+
pref,
|
|
3627
|
+
...(['above', 'below', 'right', 'left'] as Side[]).filter(
|
|
3628
|
+
(s) => s !== pref
|
|
3629
|
+
),
|
|
3630
|
+
];
|
|
3631
|
+
const side = order.find((s) => {
|
|
3632
|
+
const rect = inlineRect(p, w, s);
|
|
3633
|
+
return (
|
|
3634
|
+
rect.x >= 0 &&
|
|
3635
|
+
rect.x + rect.w <= width &&
|
|
3636
|
+
rect.y >= 0 &&
|
|
3637
|
+
rect.y + rect.h <= height &&
|
|
3638
|
+
!collides(rect) &&
|
|
3639
|
+
!temp.some((t) => rectsOverlap(t, rect))
|
|
3640
|
+
);
|
|
3641
|
+
});
|
|
3642
|
+
if (side === undefined) return false;
|
|
3643
|
+
temp.push(inlineRect(p, w, side));
|
|
3644
|
+
seated.push({ p, text, w, side });
|
|
3645
|
+
}
|
|
3646
|
+
for (const { p, text, w, side } of seated)
|
|
3647
|
+
pushInline(p, text, w, side, clusterId);
|
|
3648
|
+
return true;
|
|
3649
|
+
};
|
|
3650
|
+
// Spiderfy clusters: committed FIRST so the singleton/group passes route
|
|
3651
|
+
// around them. Try the compact radial layout; only a stack too big (or too
|
|
3652
|
+
// boxed-in) for cardinal slots falls back to a tidy leader-lined column,
|
|
3653
|
+
// thrown to the cleaner/seaward flank.
|
|
2771
3654
|
for (const [clusterId, members] of clusterMembersById) {
|
|
2772
3655
|
if (members.length === 0) continue;
|
|
2773
3656
|
const items = makeItems(members);
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
const side =
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
3657
|
+
if (tryStackRadial(items, clusterId)) continue;
|
|
3658
|
+
const cleanR = wouldColumnBeClean(items, 'right');
|
|
3659
|
+
const cleanL = wouldColumnBeClean(items, 'left');
|
|
3660
|
+
const side =
|
|
3661
|
+
cleanR && cleanL
|
|
3662
|
+
? defaultColumnSide(items)
|
|
3663
|
+
: cleanR
|
|
3664
|
+
? 'right'
|
|
3665
|
+
: cleanL
|
|
3666
|
+
? 'left'
|
|
3667
|
+
: defaultColumnSide(items);
|
|
2782
3668
|
commitColumn(items, side, clusterId);
|
|
2783
3669
|
}
|
|
2784
3670
|
|
|
@@ -2795,11 +3681,25 @@ export function layoutMap(
|
|
|
2795
3681
|
// Singleton: inline if it fits, else today's single-row callout —
|
|
2796
3682
|
// always placed, never hover-only (Decision #2 / AC9).
|
|
2797
3683
|
const { p, text, w } = items[0]!;
|
|
2798
|
-
const
|
|
2799
|
-
inlineFits(p, w, s)
|
|
3684
|
+
const fits = (['right', 'left', 'above', 'below'] as const).filter(
|
|
3685
|
+
(s) => inlineFits(p, w, s)
|
|
2800
3686
|
);
|
|
2801
|
-
if (
|
|
2802
|
-
|
|
3687
|
+
if (fits.length === 0) {
|
|
3688
|
+
commitColumn(items, defaultColumnSide(items));
|
|
3689
|
+
continue;
|
|
3690
|
+
}
|
|
3691
|
+
// Horizontal sides read best; fall to vertical only if neither flank
|
|
3692
|
+
// fits. Among the pool, divert to a water-facing side when one exists
|
|
3693
|
+
// (seaward coastal label); otherwise keep the right-first reading order.
|
|
3694
|
+
const horiz = fits.filter((s) => s === 'right' || s === 'left');
|
|
3695
|
+
const pool = horiz.length > 0 ? horiz : fits;
|
|
3696
|
+
const score = (s: Side): number => openness(inlineRect(p, w, s));
|
|
3697
|
+
const wet = pool.filter((s) => score(s) >= WATER_PREF * 0.5);
|
|
3698
|
+
const side =
|
|
3699
|
+
wet.length > 0
|
|
3700
|
+
? wet.reduce((b, s) => (score(s) > score(b) ? s : b))
|
|
3701
|
+
: pool[0]!;
|
|
3702
|
+
pushInline(p, text, w, side);
|
|
2803
3703
|
continue;
|
|
2804
3704
|
}
|
|
2805
3705
|
// Gate (a): bounding-box diagonal over marker extents — a sprawling chain
|
|
@@ -2820,12 +3720,154 @@ export function layoutMap(
|
|
|
2820
3720
|
// or left-side column places fully clean; commit on that exact side, else
|
|
2821
3721
|
// the whole cluster goes hover-only.
|
|
2822
3722
|
for (const items of clusterPending) {
|
|
2823
|
-
const
|
|
3723
|
+
const cleanSides = (['right', 'left'] as const).filter((s) =>
|
|
2824
3724
|
wouldColumnBeClean(items, s)
|
|
2825
3725
|
);
|
|
3726
|
+
const side =
|
|
3727
|
+
cleanSides.length > 1
|
|
3728
|
+
? defaultColumnSide(items) // both clean → most open flank
|
|
3729
|
+
: cleanSides[0];
|
|
2826
3730
|
if (side) commitColumn(items, side);
|
|
2827
3731
|
else items.forEach((o) => pushHidden(o.p));
|
|
2828
3732
|
}
|
|
3733
|
+
|
|
3734
|
+
// ── Edge clearance (re-fit, first pass → re-run) ──
|
|
3735
|
+
// The tight fit (FIT_PAD = 24px) can seat a POI — or its label (inline OR
|
|
3736
|
+
// leader column) — hard against a side, off-canvas, or demoted to hover-only.
|
|
3737
|
+
// Measure how far every POI dot AND every POI label crosses a comfort
|
|
3738
|
+
// clearance line on each of the four sides, reserve the deepest intrusion per
|
|
3739
|
+
// side as a band, and re-fit the whole map into the canvas MINUS those bands —
|
|
3740
|
+
// the data (dots and labels together) slides inward so nothing hugs the frame.
|
|
3741
|
+
// The clearance scales with the canvas (≈3% of the shorter axis, floored) so a
|
|
3742
|
+
// big preview pane gets proportionally more breathing room than a thumbnail.
|
|
3743
|
+
// Asymmetric and "just enough": only the crowded sides zoom out, the rest stay
|
|
3744
|
+
// tight. A committed label's box is reconstructed from its baseline/anchor; a
|
|
3745
|
+
// still-hidden (hover-only) label is measured at its IDEAL seaward position
|
|
3746
|
+
// (its stored rect is clamped on-canvas and would read as no intrusion).
|
|
3747
|
+
// Re-measured and accumulated each pass until nothing intrudes, capped at
|
|
3748
|
+
// `MAX_CLEARANCE_PASSES` so a pathologically small canvas can't loop forever.
|
|
3749
|
+
const clearancePass = opts._poiClearancePass ?? 0;
|
|
3750
|
+
const MAX_CLEARANCE_PASSES = 4;
|
|
3751
|
+
if (clearancePass < MAX_CLEARANCE_PASSES && pois.length > 0) {
|
|
3752
|
+
const EDGE_CLEAR = POI_EDGE_CLEAR; // shared with the leader-column clamp
|
|
3753
|
+
const capH = Math.floor(width * 0.3); // never starve the map for one wide name
|
|
3754
|
+
const capV = Math.floor(height * 0.3);
|
|
3755
|
+
const poiById2 = new Map(pois.map((p) => [p.id, p]));
|
|
3756
|
+
let needLeft = 0;
|
|
3757
|
+
let needRight = 0;
|
|
3758
|
+
let needTop = 0;
|
|
3759
|
+
let needBottom = 0;
|
|
3760
|
+
// Dots first: a marker itself must clear every edge by the buffer, so a
|
|
3761
|
+
// corner cluster is pulled bodily inward (its labels ride along).
|
|
3762
|
+
// Top is measured against the canvas edge (y=0), NOT topPad: the title band
|
|
3763
|
+
// (topPad) already separates content from the top, so a dot/label just under
|
|
3764
|
+
// it is not "hugging the edge" — referencing topPad would shove every POI map
|
|
3765
|
+
// down by the buffer for no reason.
|
|
3766
|
+
for (const p of pois) {
|
|
3767
|
+
needLeft = Math.max(needLeft, EDGE_CLEAR - (p.cx - p.r));
|
|
3768
|
+
needRight = Math.max(needRight, p.cx + p.r + EDGE_CLEAR - width);
|
|
3769
|
+
needTop = Math.max(needTop, EDGE_CLEAR - (p.cy - p.r));
|
|
3770
|
+
needBottom = Math.max(needBottom, p.cy + p.r + EDGE_CLEAR - height);
|
|
3771
|
+
}
|
|
3772
|
+
for (const l of labels) {
|
|
3773
|
+
if (l.poiId === undefined) continue;
|
|
3774
|
+
const p = poiById2.get(l.poiId);
|
|
3775
|
+
if (!p) continue;
|
|
3776
|
+
// A leader-lined COLUMN (visible) or a hover-only HIDDEN label both want a
|
|
3777
|
+
// seaward column beside the dot. Measuring their CLAMPED rect is useless —
|
|
3778
|
+
// a column self-clamps to the edge (so it reads as no intrusion yet sits on
|
|
3779
|
+
// the dots), and a hidden label's stored rect is clamped too. Instead
|
|
3780
|
+
// reserve from the DOT so the column fits at its NATURAL seat (dot edge +
|
|
3781
|
+
// COL_GAP + label width + buffer). This is dot-based, so it CONVERGES as
|
|
3782
|
+
// the data slides in — unlike measuring the self-clamped label, which would
|
|
3783
|
+
// never move off the edge. The column then seats beside the dots (no clamp,
|
|
3784
|
+
// no overlap) and shows. COL_GAP matches the column layout's own gap.
|
|
3785
|
+
if (l.hidden || l.leader) {
|
|
3786
|
+
const lw = l.hidden
|
|
3787
|
+
? labelInfo(p).w
|
|
3788
|
+
: measureLegendText(l.text, FONT);
|
|
3789
|
+
const reach = p.r + COL_GAP + lw + EDGE_CLEAR;
|
|
3790
|
+
if (p.cx >= width / 2)
|
|
3791
|
+
needRight = Math.max(needRight, p.cx + reach - width);
|
|
3792
|
+
else needLeft = Math.max(needLeft, reach - p.cx);
|
|
3793
|
+
continue;
|
|
3794
|
+
}
|
|
3795
|
+
// Visible inline label: reconstruct its box from baseline + anchor and
|
|
3796
|
+
// measure how far it crosses each clearance line (negative = inside).
|
|
3797
|
+
const w = measureLegendText(l.text, FONT);
|
|
3798
|
+
const boxLeft =
|
|
3799
|
+
l.anchor === 'start'
|
|
3800
|
+
? l.x
|
|
3801
|
+
: l.anchor === 'end'
|
|
3802
|
+
? l.x - w
|
|
3803
|
+
: l.x - w / 2;
|
|
3804
|
+
const boxTop = l.y - FONT / 3 - poiLabH / 2;
|
|
3805
|
+
const boxRight = boxLeft + w;
|
|
3806
|
+
const boxBottom = boxTop + poiLabH;
|
|
3807
|
+
needLeft = Math.max(needLeft, EDGE_CLEAR - boxLeft);
|
|
3808
|
+
needRight = Math.max(needRight, boxRight + EDGE_CLEAR - width);
|
|
3809
|
+
needTop = Math.max(needTop, EDGE_CLEAR - boxTop);
|
|
3810
|
+
needBottom = Math.max(needBottom, boxBottom + EDGE_CLEAR - height);
|
|
3811
|
+
}
|
|
3812
|
+
needLeft = Math.min(Math.max(0, Math.ceil(needLeft)), capH);
|
|
3813
|
+
needRight = Math.min(Math.max(0, Math.ceil(needRight)), capH);
|
|
3814
|
+
needTop = Math.min(Math.max(0, Math.ceil(needTop)), capV);
|
|
3815
|
+
needBottom = Math.min(Math.max(0, Math.ceil(needBottom)), capV);
|
|
3816
|
+
if (needLeft >= 1 || needRight >= 1 || needTop >= 1 || needBottom >= 1) {
|
|
3817
|
+
// ADD the residual intrusion to the band already reserved (the measured
|
|
3818
|
+
// positions already reflect prior bands, so `need` is what's still over the
|
|
3819
|
+
// line) and re-fit. Accumulating — not max — is what makes a too-tight
|
|
3820
|
+
// first shift converge on the next pass instead of stalling.
|
|
3821
|
+
const prev = opts._calloutReserve;
|
|
3822
|
+
const left = Math.min((prev?.left ?? 0) + needLeft, capH);
|
|
3823
|
+
const right = Math.min((prev?.right ?? 0) + needRight, capH);
|
|
3824
|
+
const top = Math.min((prev?.top ?? 0) + needTop, capV);
|
|
3825
|
+
const bottom = Math.min((prev?.bottom ?? 0) + needBottom, capV);
|
|
3826
|
+
return layoutMap(resolved, data, size, {
|
|
3827
|
+
...opts,
|
|
3828
|
+
_poiClearancePass: clearancePass + 1,
|
|
3829
|
+
_calloutReserve: {
|
|
3830
|
+
...(left > 0 && { left }),
|
|
3831
|
+
...(right > 0 && { right }),
|
|
3832
|
+
...(top > 0 && { top }),
|
|
3833
|
+
...(bottom > 0 && { bottom }),
|
|
3834
|
+
},
|
|
3835
|
+
});
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
// Region/orientation labels yield to POI labels (the subject). A region label
|
|
3841
|
+
// whose footprint a visible POI label now overlaps is removed — the POI data
|
|
3842
|
+
// owns that spot, and the region label is orientation that reads fine absent
|
|
3843
|
+
// here (vs. crammed atop a dot). Done after POI placement because the region
|
|
3844
|
+
// pass runs first and couldn't see where the POI labels would land. POI label
|
|
3845
|
+
// rects are padded a touch so a near-touch also triggers the yield.
|
|
3846
|
+
if (regionLabelGuards.length > 0) {
|
|
3847
|
+
const PAD = 2;
|
|
3848
|
+
const poiRects = labels
|
|
3849
|
+
.filter((l) => l.poiId !== undefined && l.hidden !== true)
|
|
3850
|
+
.map((l) => {
|
|
3851
|
+
const w = measureLegendText(l.text, FONT);
|
|
3852
|
+
const x =
|
|
3853
|
+
l.anchor === 'start'
|
|
3854
|
+
? l.x
|
|
3855
|
+
: l.anchor === 'end'
|
|
3856
|
+
? l.x - w
|
|
3857
|
+
: l.x - w / 2;
|
|
3858
|
+
return {
|
|
3859
|
+
x: x - PAD,
|
|
3860
|
+
y: l.y - FONT,
|
|
3861
|
+
w: w + 2 * PAD,
|
|
3862
|
+
h: FONT * 1.4 + 2 * PAD,
|
|
3863
|
+
};
|
|
3864
|
+
});
|
|
3865
|
+
for (const g of regionLabelGuards) {
|
|
3866
|
+
if (poiRects.some((pr) => rectsOverlap(pr, g.rect))) {
|
|
3867
|
+
const i = labels.indexOf(g.label);
|
|
3868
|
+
if (i >= 0) labels.splice(i, 1);
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
2829
3871
|
}
|
|
2830
3872
|
|
|
2831
3873
|
// -- Context labels (orientation backdrop, §24B). Placed DEAD LAST so they
|
|
@@ -2935,6 +3977,69 @@ export function layoutMap(
|
|
|
2935
3977
|
labels.push(...contextLabels);
|
|
2936
3978
|
}
|
|
2937
3979
|
|
|
3980
|
+
// ── Subtle city dots (basemap orientation, §24B `no-cities`) ──
|
|
3981
|
+
// A faint scatter of gazetteer cities for geographic context. Population-ranked
|
|
3982
|
+
// and spacing-thinned: the min-pixel gap makes density adapt to zoom for free —
|
|
3983
|
+
// at world scale only the biggest of a dense cluster (Europe) survive; zoomed
|
|
3984
|
+
// into one country the same cities spread apart and more local ones fill in.
|
|
3985
|
+
// Explicit POIs always win — a city dot never sits under a referenced marker.
|
|
3986
|
+
//
|
|
3987
|
+
// The ON-CANVAS projected-pixel test is the ONLY cull — NOT a lon/lat extent
|
|
3988
|
+
// box. `resolved.extent` wraps the antimeridian for albers-usa whenever AK/HI
|
|
3989
|
+
// are referenced (west lon > east lon), which a naive `lon<w||lon>e` box reads
|
|
3990
|
+
// as "reject every mainland city" → an all-blank US map. The pixel test is
|
|
3991
|
+
// projection-agnostic and antimeridian-safe, and it naturally includes the
|
|
3992
|
+
// near-border neighbour cities the viewport actually shows.
|
|
3993
|
+
const cityDots: MapLayoutCityDot[] = [];
|
|
3994
|
+
if (resolved.directives.noCities !== true) {
|
|
3995
|
+
const CITY_DOT_SPACING = 12; // min px between two dots (and dot↔POI)
|
|
3996
|
+
const CITY_DOT_CAP = 220;
|
|
3997
|
+
const SPACING_SQ = CITY_DOT_SPACING * CITY_DOT_SPACING;
|
|
3998
|
+
// Radius scales with population on a log axis (pop spans ~50k → 37M, so a
|
|
3999
|
+
// linear map would collapse everything but the megacities to one size). A
|
|
4000
|
+
// metropolis reads as a slightly fatter dot; a small town stays a faint
|
|
4001
|
+
// speck. Still decorative — the range is deliberately tight so the layer
|
|
4002
|
+
// never competes with POIs.
|
|
4003
|
+
const CITY_DOT_R_MIN = 0.7;
|
|
4004
|
+
const CITY_DOT_R_MAX = 2.6;
|
|
4005
|
+
const CITY_POP_MIN = 50_000; // ≤ this → R_MIN
|
|
4006
|
+
const CITY_POP_MAX = 15_000_000; // ≥ this → R_MAX
|
|
4007
|
+
const LOG_MIN = Math.log10(CITY_POP_MIN);
|
|
4008
|
+
const LOG_SPAN = Math.log10(CITY_POP_MAX) - LOG_MIN;
|
|
4009
|
+
const cityDotRadius = (pop: number): number => {
|
|
4010
|
+
if (!(pop > CITY_POP_MIN)) return CITY_DOT_R_MIN;
|
|
4011
|
+
const t = Math.min(1, (Math.log10(pop) - LOG_MIN) / LOG_SPAN);
|
|
4012
|
+
return CITY_DOT_R_MIN + t * (CITY_DOT_R_MAX - CITY_DOT_R_MIN);
|
|
4013
|
+
};
|
|
4014
|
+
// Seed the occupancy set with explicit POI positions so dots dodge markers.
|
|
4015
|
+
const placed: { x: number; y: number }[] = pois.map((p) => ({
|
|
4016
|
+
x: p.cx,
|
|
4017
|
+
y: p.cy,
|
|
4018
|
+
}));
|
|
4019
|
+
const sorted = [...data.gazetteer.cities].sort((a, b) => b[3] - a[3]);
|
|
4020
|
+
for (const c of sorted) {
|
|
4021
|
+
if (cityDots.length >= CITY_DOT_CAP) break;
|
|
4022
|
+
const lat = c[0];
|
|
4023
|
+
const lon = c[1];
|
|
4024
|
+
const p = project(lon, lat);
|
|
4025
|
+
if (!p) continue;
|
|
4026
|
+
const [px, py] = p;
|
|
4027
|
+
if (px < 0 || px > width || py < 0 || py > height) continue;
|
|
4028
|
+
let tooClose = false;
|
|
4029
|
+
for (const q of placed) {
|
|
4030
|
+
const dx = q.x - px;
|
|
4031
|
+
const dy = q.y - py;
|
|
4032
|
+
if (dx * dx + dy * dy < SPACING_SQ) {
|
|
4033
|
+
tooClose = true;
|
|
4034
|
+
break;
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
if (tooClose) continue;
|
|
4038
|
+
placed.push({ x: px, y: py });
|
|
4039
|
+
cityDots.push({ cx: px, cy: py, r: cityDotRadius(c[3]) });
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
|
|
2938
4043
|
return {
|
|
2939
4044
|
width,
|
|
2940
4045
|
height,
|
|
@@ -2949,6 +4054,7 @@ export function layoutMap(
|
|
|
2949
4054
|
coastlineStyle,
|
|
2950
4055
|
legs,
|
|
2951
4056
|
pois,
|
|
4057
|
+
cityDots,
|
|
2952
4058
|
clusters,
|
|
2953
4059
|
labels,
|
|
2954
4060
|
legend,
|