@diagrammo/dgmo 0.33.0 → 0.35.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/.claude/commands/dgmo.md +17 -32
- package/.github/copilot-instructions.md +1 -1
- package/README.md +7 -1
- package/SKILL.md +1 -1
- package/dist/advanced.cjs +791 -614
- package/dist/advanced.d.cts +8 -51
- package/dist/advanced.d.ts +8 -51
- package/dist/advanced.js +791 -615
- package/dist/auto.cjs +793 -292
- package/dist/auto.js +95 -95
- package/dist/auto.mjs +793 -292
- package/dist/cli.cjs +145 -328
- package/dist/index.cjs +792 -291
- package/dist/index.js +792 -291
- package/docs/ai-integration.md +46 -26
- package/docs/language-reference.md +2 -2
- package/package.json +3 -3
- package/src/advanced.ts +0 -12
- package/src/boxes-and-lines/label-placement.ts +339 -0
- package/src/boxes-and-lines/layout-search.ts +16 -2
- package/src/boxes-and-lines/layout.ts +32 -7
- package/src/boxes-and-lines/renderer.ts +67 -139
- package/src/c4/parser.ts +15 -6
- package/src/chart.ts +1 -1
- package/src/class/parser.ts +6 -2
- package/src/cli-banner.ts +2 -3
- package/src/cli.ts +539 -1223
- package/src/cycle/parser.ts +4 -1
- package/src/dgmo-router.ts +16 -2
- package/src/diagnostics.ts +0 -0
- package/src/echarts.ts +5 -1
- package/src/er/parser.ts +5 -1
- package/src/gantt/parser.ts +13 -4
- package/src/graph/flowchart-parser.ts +5 -1
- package/src/graph/state-parser.ts +5 -1
- package/src/infra/parser.ts +10 -6
- package/src/journey-map/parser.ts +5 -2
- package/src/kanban/parser.ts +6 -2
- package/src/map/context-labels.ts +29 -19
- package/src/map/layout.ts +403 -108
- package/src/map/legend-band.ts +28 -0
- package/src/map/parser.ts +13 -4
- package/src/map/renderer.ts +74 -23
- package/src/map/resolver.ts +2 -2
- package/src/org/parser.ts +8 -2
- package/src/pyramid/parser.ts +4 -1
- package/src/raci/parser.ts +4 -1
- package/src/ring/parser.ts +4 -1
- package/src/sequence/parser.ts +6 -3
- package/src/sitemap/parser.ts +12 -3
- package/src/tech-radar/parser.ts +6 -3
- package/src/utils/parsing.ts +2 -2
- package/src/utils/tag-groups.ts +3 -3
- package/src/visualizations/parse.ts +9 -3
- package/src/migrate/data-comma.ts +0 -117
- package/src/migrate/embedded.ts +0 -238
- package/src/migrate/index.ts +0 -295
- package/src/migrate/line-classifier.ts +0 -128
- package/src/migrate/line-transformer.ts +0 -218
package/src/map/layout.ts
CHANGED
|
@@ -41,7 +41,7 @@ import { measureLegendText } from '../utils/legend-constants';
|
|
|
41
41
|
import { compactNumber } from '../utils/number-format';
|
|
42
42
|
import { TITLE_FONT_SIZE, TITLE_Y } from '../utils/title-constants';
|
|
43
43
|
import type { LegendMode } from '../utils/legend-types';
|
|
44
|
-
import { mapLegendBand } from './legend-band';
|
|
44
|
+
import { mapLegendBand, mapLegendBox } from './legend-band';
|
|
45
45
|
import type { MapLayoutLegend } from './types';
|
|
46
46
|
import type { DgmoError } from '../diagnostics';
|
|
47
47
|
import type { BoundaryTopology } from './data/types';
|
|
@@ -811,41 +811,50 @@ export function countryLabelPositions(args: {
|
|
|
811
811
|
};
|
|
812
812
|
const dist2ToCentre = (c: Cell): number =>
|
|
813
813
|
(c.sx - cx) ** 2 + (c.sy - cy) ** 2;
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
814
|
+
// Center on the visible region FIRST (what a human does): the label's home is
|
|
815
|
+
// the centroid of the country's in-frame land. The caller tries positions in
|
|
816
|
+
// order and commits the first that clears every obstacle, so a well-centred
|
|
817
|
+
// anchor that's clear wins outright, and only an awkward case (a POI or the
|
|
818
|
+
// legend sitting on the centroid) falls back to the on-land grid cells — which
|
|
819
|
+
// are interior-first, NOT scattered to far corners. The centroid leads ONLY
|
|
820
|
+
// when it actually sits on the country's own land (so the on-land invariant
|
|
821
|
+
// holds); a concave country whose centroid lands off its body falls straight
|
|
822
|
+
// to the grid.
|
|
823
|
+
const centreLon = kept.reduce((s, c) => s + c.lon, 0) / kept.length;
|
|
824
|
+
const centreLat = kept.reduce((s, c) => s + c.lat, 0) / kept.length;
|
|
825
|
+
const centreScreen = project(centreLon, centreLat);
|
|
826
|
+
const lead =
|
|
827
|
+
pointInGeometry(geometry, centreLon, centreLat) &&
|
|
828
|
+
centreScreen &&
|
|
829
|
+
Number.isFinite(centreScreen[0]) &&
|
|
830
|
+
Number.isFinite(centreScreen[1]) &&
|
|
831
|
+
centreScreen[0] >= 0 &&
|
|
832
|
+
centreScreen[0] <= width &&
|
|
833
|
+
centreScreen[1] >= 0 &&
|
|
834
|
+
centreScreen[1] <= height
|
|
835
|
+
? [
|
|
836
|
+
{
|
|
837
|
+
lonLat: [centreLon, centreLat] as [number, number],
|
|
838
|
+
screen: [centreScreen[0], centreScreen[1]] as [number, number],
|
|
839
|
+
},
|
|
840
|
+
]
|
|
841
|
+
: [];
|
|
842
|
+
// On-land grid cells, most-interior first (tie → nearest the visible centroid).
|
|
843
|
+
const grid = [...kept]
|
|
844
|
+
.sort(
|
|
845
|
+
(a, b) =>
|
|
846
|
+
interiorness(b) - interiorness(a) || dist2ToCentre(a) - dist2ToCentre(b)
|
|
847
|
+
)
|
|
848
|
+
.map((c) => ({
|
|
849
|
+
lonLat: [c.lon, c.lat] as [number, number],
|
|
850
|
+
screen: [c.sx, c.sy] as [number, number],
|
|
851
|
+
}));
|
|
852
|
+
// Curated anchor (D13): forced to slot 0 when present (a trusted mainland
|
|
853
|
+
// point, e.g. Russia → European Russia).
|
|
844
854
|
const curatedPos = curated
|
|
845
855
|
? mkCurated(curated, project)
|
|
846
856
|
: ([] as { lonLat: [number, number]; screen: [number, number] }[]);
|
|
847
|
-
|
|
848
|
-
return out;
|
|
857
|
+
return [...curatedPos, ...lead, ...grid].slice(0, MAX_COUNTRY_POSITIONS);
|
|
849
858
|
}
|
|
850
859
|
|
|
851
860
|
/** Project a single curated lon/lat into the position-list shape (or [] when it
|
|
@@ -1484,6 +1493,40 @@ export function layoutMap(
|
|
|
1484
1493
|
: FOREIGN_TINT_LIGHT
|
|
1485
1494
|
);
|
|
1486
1495
|
|
|
1496
|
+
// One muted, contrast-floored tone for every BACKDROP place name — orientation
|
|
1497
|
+
// regions (Minnesota, Texas) AND unreferenced context countries (Canada) — plus
|
|
1498
|
+
// one for water. Subordination is by MUTING (start from textMuted / a muted
|
|
1499
|
+
// blue-gray), never by lowering contrast: blend toward palette.text until the
|
|
1500
|
+
// tone clears the WCAG-AA floor against the substrate it sits on, so a backdrop
|
|
1501
|
+
// name is always legible WITHOUT a halo. This replaces the old fade-toward-bg,
|
|
1502
|
+
// which washed a grown name to ~background (Minnesota rendered at ~#dddfe0 over
|
|
1503
|
+
// ~#e9ece9 land — contrast ≈ 1).
|
|
1504
|
+
// Floored against EVERY light substrate a backdrop name may touch — its own
|
|
1505
|
+
// neutral land AND open water — so the no-halo label stays legible even where a
|
|
1506
|
+
// big country name (Florida, Chile) spills off its coast onto the sea. (The
|
|
1507
|
+
// placement gate below still keeps these names off the only substrate the tone
|
|
1508
|
+
// can't clear: a saturated foreign DATA fill.)
|
|
1509
|
+
const contrastFloorTone = (tone: string, ...substrates: string[]): string => {
|
|
1510
|
+
const clears = (t: string): boolean =>
|
|
1511
|
+
substrates.every((s) => contrastRatio(t, s) >= REGION_LABEL_HALO_RATIO);
|
|
1512
|
+
if (clears(tone)) return tone;
|
|
1513
|
+
for (let s = 10; s <= 100; s += 10) {
|
|
1514
|
+
const t = mix(palette.text, tone, s);
|
|
1515
|
+
if (clears(t)) return t;
|
|
1516
|
+
}
|
|
1517
|
+
return palette.text;
|
|
1518
|
+
};
|
|
1519
|
+
const backdropLandTone = contrastFloorTone(
|
|
1520
|
+
palette.textMuted,
|
|
1521
|
+
neutralFill,
|
|
1522
|
+
water
|
|
1523
|
+
);
|
|
1524
|
+
const backdropWaterTone = contrastFloorTone(
|
|
1525
|
+
mix(palette.colors.blue, palette.textMuted, 50),
|
|
1526
|
+
water,
|
|
1527
|
+
neutralFill
|
|
1528
|
+
);
|
|
1529
|
+
|
|
1487
1530
|
// -- Colorize: content-inferred distinct political fills (§24B) --
|
|
1488
1531
|
// Colorize is the DEFAULT dress for any map that is NOT colouring regions by
|
|
1489
1532
|
// data. The things that turn it off: (1) a data dimension exists on a
|
|
@@ -1695,6 +1738,43 @@ export function layoutMap(
|
|
|
1695
1738
|
hasSubtitle: Boolean(resolved.subtitle),
|
|
1696
1739
|
});
|
|
1697
1740
|
if (legendBand > topPad) topPad = legendBand;
|
|
1741
|
+
// The title and legend are foreground overlays in the top band. Capture their
|
|
1742
|
+
// actual centred boxes so EVERY on-map label (region, data, context) treats
|
|
1743
|
+
// them as obstacles and dodges to clear space — otherwise a neighbour name
|
|
1744
|
+
// creeps up under the title (Canada on a US map). These are centred boxes, NOT
|
|
1745
|
+
// the full-width band, so a label can still sit in a clear top corner (a water
|
|
1746
|
+
// name, or Canada beside the title) rather than being shoved off its own land.
|
|
1747
|
+
const topReserved: LabelRect[] = [];
|
|
1748
|
+
if (shownTitle) {
|
|
1749
|
+
const lines = resolved.subtitle ? 2 : 1;
|
|
1750
|
+
const tw =
|
|
1751
|
+
Math.max(
|
|
1752
|
+
measureLegendText(shownTitle, TITLE_FONT_SIZE),
|
|
1753
|
+
resolved.subtitle
|
|
1754
|
+
? measureLegendText(resolved.subtitle, TITLE_FONT_SIZE)
|
|
1755
|
+
: 0
|
|
1756
|
+
) +
|
|
1757
|
+
2 * TITLE_FONT_SIZE; // breathing room around the centred banner
|
|
1758
|
+
topReserved.push({
|
|
1759
|
+
x: width / 2 - tw / 2,
|
|
1760
|
+
y: TITLE_Y - TITLE_FONT_SIZE,
|
|
1761
|
+
w: tw,
|
|
1762
|
+
h: TITLE_FONT_SIZE * (lines + 0.5),
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
const legendBox = mapLegendBox(legend, {
|
|
1766
|
+
width,
|
|
1767
|
+
mode: opts.legendMode ?? 'preview',
|
|
1768
|
+
hasTitle: Boolean(shownTitle),
|
|
1769
|
+
hasSubtitle: Boolean(resolved.subtitle),
|
|
1770
|
+
});
|
|
1771
|
+
if (legendBox)
|
|
1772
|
+
topReserved.push({
|
|
1773
|
+
x: legendBox.x,
|
|
1774
|
+
y: legendBox.y,
|
|
1775
|
+
w: legendBox.width,
|
|
1776
|
+
h: legendBox.height,
|
|
1777
|
+
});
|
|
1698
1778
|
// Reserve a side band for margin callouts (second pass only): the projection
|
|
1699
1779
|
// fits into the canvas MINUS this band, so the data shrinks and slides away
|
|
1700
1780
|
// from that edge, opening room for the callout chips + leaders.
|
|
@@ -2892,6 +2972,30 @@ export function layoutMap(
|
|
|
2892
2972
|
const by = b.cy - ((b.cy - py) / tb) * trimB;
|
|
2893
2973
|
return `M${ax},${ay}Q${px},${py} ${bx},${by}`;
|
|
2894
2974
|
};
|
|
2975
|
+
// Where a leg's label sits: the MIDPOINT OF THE DRAWN PATH, nudged a few px to
|
|
2976
|
+
// the bow side so the text rides just off the line. A straight leg's midpoint is
|
|
2977
|
+
// the chord midpoint; an arc is a quadratic Q whose t=0.5 point is
|
|
2978
|
+
// chord-mid + ½·normal·bow — so a long bowed arc (a trans-Atlantic route) must
|
|
2979
|
+
// follow the CURVE, else the label floats in open space far below the line.
|
|
2980
|
+
const legLabelPoint = (
|
|
2981
|
+
a: { cx: number; cy: number; r: number },
|
|
2982
|
+
b: { cx: number; cy: number; r: number },
|
|
2983
|
+
curved: boolean,
|
|
2984
|
+
offset: number
|
|
2985
|
+
): { x: number; y: number } => {
|
|
2986
|
+
const mx = (a.cx + b.cx) / 2;
|
|
2987
|
+
const my = (a.cy + b.cy) / 2;
|
|
2988
|
+
if (!curved && offset === 0) return { x: mx, y: my - 4 };
|
|
2989
|
+
const dx = b.cx - a.cx;
|
|
2990
|
+
const dy = b.cy - a.cy;
|
|
2991
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
2992
|
+
const nx = -dy / len;
|
|
2993
|
+
const ny = dx / len;
|
|
2994
|
+
const bow = offset !== 0 ? offset : len * ARC_CURVE_FRAC;
|
|
2995
|
+
// arc apex (½·bow) + a small lift further out so the text clears the stroke.
|
|
2996
|
+
const off = bow * 0.5 + Math.sign(bow || 1) * 8;
|
|
2997
|
+
return { x: mx + nx * off, y: my + ny * off };
|
|
2998
|
+
};
|
|
2895
2999
|
|
|
2896
3000
|
// Routes: each leg is an edge (fromId → toId) carrying its own label,
|
|
2897
3001
|
// value→thickness, and arc shape. Loop-closing legs are explicit in `rt.legs`;
|
|
@@ -2912,13 +3016,12 @@ export function layoutMap(
|
|
|
2912
3016
|
const a = poiScreen.get(leg.fromId);
|
|
2913
3017
|
const b = poiScreen.get(leg.toId);
|
|
2914
3018
|
if (!a || !b) continue;
|
|
2915
|
-
const
|
|
2916
|
-
const my = (a.cy + b.cy) / 2;
|
|
3019
|
+
const lp = legLabelPoint(a, b, leg.style === 'arc', 0);
|
|
2917
3020
|
const bow = {
|
|
2918
3021
|
curved: leg.style === 'arc',
|
|
2919
3022
|
offset: 0,
|
|
2920
|
-
labelX:
|
|
2921
|
-
labelY:
|
|
3023
|
+
labelX: lp.x,
|
|
3024
|
+
labelY: lp.y,
|
|
2922
3025
|
};
|
|
2923
3026
|
const routeLabelStyle =
|
|
2924
3027
|
leg.label !== undefined
|
|
@@ -2972,13 +3075,13 @@ export function layoutMap(
|
|
|
2972
3075
|
const b = poiScreen.get(e.toId);
|
|
2973
3076
|
if (!a || !b) return;
|
|
2974
3077
|
const fanOffset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
|
|
2975
|
-
const
|
|
2976
|
-
const
|
|
3078
|
+
const curved = e.style === 'arc' || n > 1;
|
|
3079
|
+
const lp = legLabelPoint(a, b, curved, fanOffset);
|
|
2977
3080
|
const bow = {
|
|
2978
|
-
curved
|
|
3081
|
+
curved,
|
|
2979
3082
|
offset: fanOffset,
|
|
2980
|
-
labelX:
|
|
2981
|
-
labelY:
|
|
3083
|
+
labelX: lp.x,
|
|
3084
|
+
labelY: lp.y,
|
|
2982
3085
|
};
|
|
2983
3086
|
const edgeLabelStyle =
|
|
2984
3087
|
e.label !== undefined
|
|
@@ -3007,7 +3110,9 @@ export function layoutMap(
|
|
|
3007
3110
|
|
|
3008
3111
|
// -- Labels: regions + POIs with escalation (AR5) --
|
|
3009
3112
|
const labels: PlacedLabel[] = [];
|
|
3010
|
-
|
|
3113
|
+
// Seed with the title + legend boxes so every label (region, POI, context)
|
|
3114
|
+
// collides against them and never lands under the title/legend overlay.
|
|
3115
|
+
const obstacles: LabelRect[] = [...topReserved];
|
|
3011
3116
|
// Region/orientation labels are the frame; POI labels are the subject. The
|
|
3012
3117
|
// region pass runs first (can't yet see where POI labels land), so each region
|
|
3013
3118
|
// label registers a guard here; after POI placement any guard a POI label
|
|
@@ -3066,6 +3171,14 @@ export function layoutMap(
|
|
|
3066
3171
|
// ocean. At the compact breakpoint (decision D2) the abbreviation is preferred
|
|
3067
3172
|
// FIRST for US states.
|
|
3068
3173
|
const showRegionLabels = resolved.directives.noRegionLabels !== true;
|
|
3174
|
+
// Ids of regions that won a static on-map label in the region pass. The
|
|
3175
|
+
// unreferenced-country / framed-state context pass below excludes these so a
|
|
3176
|
+
// region named once is never named twice: the layout `regions` list carries
|
|
3177
|
+
// auto-added poiFrameContainers (e.g. Canada framing northern POIs) that are
|
|
3178
|
+
// NOT in `regionById` (which holds only user-referenced `resolved.regions`),
|
|
3179
|
+
// so `regionById.has(iso)` alone misses them and the context pass would
|
|
3180
|
+
// re-label the same country at a different anchor (the "double-Canada" bug).
|
|
3181
|
+
const labeledRegionIds = new Set<string>();
|
|
3069
3182
|
// Metric value shown UNDER each data region's name (`no-region-value` opts out).
|
|
3070
3183
|
// The value line is rendered smaller + dimmer than the name; see the renderer.
|
|
3071
3184
|
// Scoped to a `region-metric` choropleth: only when the SCORE ramp is the active
|
|
@@ -3128,7 +3241,12 @@ export function layoutMap(
|
|
|
3128
3241
|
const REGION_FONT_MAX_ORIENT = 22; // px ceiling, backdrop names
|
|
3129
3242
|
const REGION_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
|
|
3130
3243
|
const REGION_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
|
|
3131
|
-
|
|
3244
|
+
// A valueless SUBJECT (referenced, no metric) grows up to this ceiling in-shape;
|
|
3245
|
+
// if it can't host its name at least at the prominence FLOOR inside its own fill
|
|
3246
|
+
// (a thin ribbon like Chile), it leaders into the open space instead.
|
|
3247
|
+
const SUBJECT_FONT_MAX = 18; // px ceiling for a prominent in-shape subject name
|
|
3248
|
+
const SUBJECT_MIN_PROMINENCE = 13; // px floor below which a subject leaders out
|
|
3249
|
+
const SUBJECT_LEADER_FONT = 15; // px for a leadered subject chip (Chile in the sea)
|
|
3132
3250
|
const canvasLinear = Math.sqrt(Math.max(1, width * height));
|
|
3133
3251
|
const sizeT = (boxW: number, boxH: number): number => {
|
|
3134
3252
|
const frac = Math.sqrt(Math.max(0, boxW * boxH)) / canvasLinear;
|
|
@@ -3149,7 +3267,7 @@ export function layoutMap(
|
|
|
3149
3267
|
lineNumber: number,
|
|
3150
3268
|
valueLine?: string,
|
|
3151
3269
|
fontSize: number = FONT,
|
|
3152
|
-
|
|
3270
|
+
colorOverride?: string
|
|
3153
3271
|
): void => {
|
|
3154
3272
|
// Colour is contrast-picked against the region's own fill (see labelOnFill).
|
|
3155
3273
|
// The halo, though, is gated by CONTAINMENT — not fill tone. A label that
|
|
@@ -3162,9 +3280,12 @@ export function layoutMap(
|
|
|
3162
3280
|
// fills: if any extreme lands on a fill other than the region's own, the
|
|
3163
3281
|
// label overflows and earns a halo.
|
|
3164
3282
|
const { color: baseColor, haloColor } = labelOnFill(fill);
|
|
3165
|
-
//
|
|
3166
|
-
//
|
|
3167
|
-
|
|
3283
|
+
// A backdrop label passes an explicit muted, contrast-floored tone
|
|
3284
|
+
// (`backdropLandTone`); subordination is by muting, NOT by fading toward bg
|
|
3285
|
+
// (which destroyed contrast — the old REGION_FADE_ORIENT washed Minnesota to
|
|
3286
|
+
// ~background). Data labels pass no override and keep the contrast-picked-
|
|
3287
|
+
// vs-fill colour.
|
|
3288
|
+
const color = colorOverride ?? baseColor;
|
|
3168
3289
|
// Widest of name / value drives the overflow sample (the value line can be
|
|
3169
3290
|
// the wider of the two, e.g. a short name over a long number). Scales with
|
|
3170
3291
|
// the actual (possibly grown) font so the halo gate matches what's drawn.
|
|
@@ -3183,7 +3304,11 @@ export function layoutMap(
|
|
|
3183
3304
|
text,
|
|
3184
3305
|
anchor: 'middle',
|
|
3185
3306
|
color,
|
|
3186
|
-
|
|
3307
|
+
// A backdrop label (colorOverride) NEVER haloes: the placement gate above
|
|
3308
|
+
// guarantees it sits on a floored-readable substrate (own fill, neutral
|
|
3309
|
+
// land, foreign land, or water), so a halo is pure noise. Data labels keep
|
|
3310
|
+
// the containment-gated halo until the role-tier pass (Phase 3).
|
|
3311
|
+
halo: colorOverride ? false : overflows,
|
|
3187
3312
|
haloColor,
|
|
3188
3313
|
...(fontSize !== FONT && { fontSize }),
|
|
3189
3314
|
...(valueLine !== undefined && { valueLine }),
|
|
@@ -3263,7 +3388,9 @@ export function layoutMap(
|
|
|
3263
3388
|
})
|
|
3264
3389
|
.filter((e): e is NonNullable<typeof e> => e !== null)
|
|
3265
3390
|
.sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
|
|
3266
|
-
|
|
3391
|
+
// Seed with the title + legend boxes (via fitsRegions) so region/data labels
|
|
3392
|
+
// dodge the overlay the same way context labels do (through `obstacles`).
|
|
3393
|
+
const placedRegionRects: LabelRect[] = [...topReserved];
|
|
3267
3394
|
// Leaders already drawn (region short-hop callouts). A new short-hop leader
|
|
3268
3395
|
// that would cross any of these is rejected — crossing leaders read as
|
|
3269
3396
|
// spaghetti, so the label is dropped instead (the shading + legend + hover
|
|
@@ -3316,15 +3443,18 @@ export function layoutMap(
|
|
|
3316
3443
|
cy: number,
|
|
3317
3444
|
fill: string,
|
|
3318
3445
|
name: string,
|
|
3319
|
-
value: string | undefined
|
|
3446
|
+
value: string | undefined,
|
|
3447
|
+
font: number = FONT,
|
|
3448
|
+
gap: number = HOP_GAP,
|
|
3449
|
+
maxLen: number = SHORT_HOP_MAX
|
|
3320
3450
|
): {
|
|
3321
3451
|
x: number;
|
|
3322
3452
|
y: number;
|
|
3323
3453
|
rect: LabelRect;
|
|
3324
3454
|
leader: [number, number, number, number];
|
|
3325
3455
|
} | null => {
|
|
3326
|
-
const chipW = stackW(name, value);
|
|
3327
|
-
const chipH = stackH(value !== undefined);
|
|
3456
|
+
const chipW = stackW(name, value, font);
|
|
3457
|
+
const chipH = stackH(value !== undefined, font);
|
|
3328
3458
|
const dirs: Array<[number, number]> = [
|
|
3329
3459
|
[0, -1], // north (over Canada)
|
|
3330
3460
|
[0, 1], // south (over Mexico / Gulf)
|
|
@@ -3344,7 +3474,7 @@ export function layoutMap(
|
|
|
3344
3474
|
let ex = cx;
|
|
3345
3475
|
let ey = cy;
|
|
3346
3476
|
let steps = 0;
|
|
3347
|
-
const maxSteps = Math.ceil(
|
|
3477
|
+
const maxSteps = Math.ceil(maxLen / STEP) + 1;
|
|
3348
3478
|
while (steps < maxSteps && fillAt(ex, ey) === fill) {
|
|
3349
3479
|
ex += dx * STEP;
|
|
3350
3480
|
ey += dy * STEP;
|
|
@@ -3353,8 +3483,8 @@ export function layoutMap(
|
|
|
3353
3483
|
if (fillAt(ex, ey) === fill) continue; // never left the region in reach
|
|
3354
3484
|
// Chip centre sits a gap + half-extent beyond the border along the dir.
|
|
3355
3485
|
const halfAlong = (Math.abs(dx) * chipW + Math.abs(dy) * chipH) / 2;
|
|
3356
|
-
const ccx = ex + dx * (
|
|
3357
|
-
const ccy = ey + dy * (
|
|
3486
|
+
const ccx = ex + dx * (gap + halfAlong);
|
|
3487
|
+
const ccy = ey + dy * (gap + halfAlong);
|
|
3358
3488
|
const rect: LabelRect = {
|
|
3359
3489
|
x: ccx - chipW / 2,
|
|
3360
3490
|
y: ccy - chipH / 2,
|
|
@@ -3390,7 +3520,7 @@ export function layoutMap(
|
|
|
3390
3520
|
const innerX = ccx - dx * (chipW / 2);
|
|
3391
3521
|
const innerY = ccy - dy * (chipH / 2);
|
|
3392
3522
|
const len = Math.hypot(innerX - cx, innerY - cy);
|
|
3393
|
-
if (len >
|
|
3523
|
+
if (len > maxLen) continue;
|
|
3394
3524
|
if (
|
|
3395
3525
|
placedLeaders.some((l) =>
|
|
3396
3526
|
segmentsCross(cx, cy, innerX, innerY, l[0], l[1], l[2], l[3])
|
|
@@ -3418,6 +3548,117 @@ export function layoutMap(
|
|
|
3418
3548
|
};
|
|
3419
3549
|
for (const { r, c, boxW, boxH, candidates } of entries) {
|
|
3420
3550
|
const valStr = regionValueStr(r.value);
|
|
3551
|
+
// vs other placed REGION labels (full stack) / vs POI obstacles (name only)
|
|
3552
|
+
// — defined up here so the subject pre-pass below can reuse them.
|
|
3553
|
+
const fitsRegions = (rect: LabelRect): boolean =>
|
|
3554
|
+
!placedRegionRects.some((p) => rectsOverlap(rect, p));
|
|
3555
|
+
const fitsPois = (rect: LabelRect): boolean =>
|
|
3556
|
+
!poiObstacles.some((o) => rectsOverlap(rect, o));
|
|
3557
|
+
// A SUBJECT is a user-referenced region (the thing the map is ABOUT —
|
|
3558
|
+
// `regionById` holds only `resolved.regions`, so an auto-added
|
|
3559
|
+
// poiFrameContainer is NOT a subject). A subject must read prominently and
|
|
3560
|
+
// NEVER drop: a thin ribbon (Chile) whose centroid sits on a sliver doesn't
|
|
3561
|
+
// stamp a cramped name there — it leaders into the open space beside it.
|
|
3562
|
+
const isSubject = regionById.has(r.id);
|
|
3563
|
+
// Both horizontal extremes of `text` (centred at ax,ay, base font) land on
|
|
3564
|
+
// the region's own fill — true in-shape containment. Gates a DATA label's
|
|
3565
|
+
// in-place placement so a name that would spill off its own choropleth fill
|
|
3566
|
+
// onto a neighbour (where its fill-picked colour is illegible) leaders out
|
|
3567
|
+
// instead — which is what lets the data label drop its halo (it can no
|
|
3568
|
+
// longer overflow, so `overflows` is false and no rescue stroke is drawn).
|
|
3569
|
+
const extremesOnFill = (
|
|
3570
|
+
ax: number,
|
|
3571
|
+
ay: number,
|
|
3572
|
+
text: string
|
|
3573
|
+
): boolean => {
|
|
3574
|
+
const halfW = measureLegendText(text, FONT) / 2;
|
|
3575
|
+
return (
|
|
3576
|
+
fillAt(ax - halfW, ay) === r.fill && fillAt(ax + halfW, ay) === r.fill
|
|
3577
|
+
);
|
|
3578
|
+
};
|
|
3579
|
+
// ── Valueless SUBJECT: prominent in-shape, else leader (the Chile case) ──
|
|
3580
|
+
// A user-referenced region with no metric is the map's point and must read
|
|
3581
|
+
// PROMINENTLY. Find the largest font (up to a cap) whose full name sits
|
|
3582
|
+
// wholly inside the region's own fill at the centroid AND clears every
|
|
3583
|
+
// obstacle (other labels, POI markers, route arcs). If that best size meets
|
|
3584
|
+
// the prominence floor, place it there in full contrast. If the shape is too
|
|
3585
|
+
// thin/small to host a prominent name even at the floor — a ribbon like Chile
|
|
3586
|
+
// whose centroid is a sliver — fall through to a leader that carries the
|
|
3587
|
+
// full name into the open space beside it. A subject NEVER drops.
|
|
3588
|
+
if (isSubject && valStr === undefined && r.label !== undefined) {
|
|
3589
|
+
const name = candidates[0]!; // subjects use the full name, never an abbrev
|
|
3590
|
+
let best = -1;
|
|
3591
|
+
for (let f = SUBJECT_FONT_MAX; f >= FONT; f--) {
|
|
3592
|
+
if (measureLegendText(name, f) > boxW || f + 2 * LABEL_PADY > boxH)
|
|
3593
|
+
continue;
|
|
3594
|
+
const rect = regionLabelRect(c[0], c[1], name, undefined, f);
|
|
3595
|
+
if (!fitsRegions(rect) || !fitsPois(rect) || collides(rect)) continue;
|
|
3596
|
+
const halfW = measureLegendText(name, f) / 2;
|
|
3597
|
+
if (
|
|
3598
|
+
fillAt(c[0] - halfW, c[1]) !== r.fill ||
|
|
3599
|
+
fillAt(c[0] + halfW, c[1]) !== r.fill
|
|
3600
|
+
)
|
|
3601
|
+
continue;
|
|
3602
|
+
best = f;
|
|
3603
|
+
break;
|
|
3604
|
+
}
|
|
3605
|
+
if (best >= SUBJECT_MIN_PROMINENCE) {
|
|
3606
|
+
const rect = regionLabelRect(c[0], c[1], name, undefined, best);
|
|
3607
|
+
placedRegionRects.push(rect);
|
|
3608
|
+
pushRegionLabel(
|
|
3609
|
+
c[0],
|
|
3610
|
+
c[1],
|
|
3611
|
+
name,
|
|
3612
|
+
r.fill,
|
|
3613
|
+
r.lineNumber,
|
|
3614
|
+
undefined,
|
|
3615
|
+
best
|
|
3616
|
+
);
|
|
3617
|
+
labeledRegionIds.add(r.id);
|
|
3618
|
+
regionLabelGuards.push({ label: labels[labels.length - 1]!, rect });
|
|
3619
|
+
} else {
|
|
3620
|
+
// The shape can't host a prominent name (Chile's ribbon) → leader the
|
|
3621
|
+
// name into the open space beside it at a PROMINENT size, pushed well
|
|
3622
|
+
// clear of the coast (a larger gap + reach) so it reads as the subject,
|
|
3623
|
+
// not a cramped afterthought.
|
|
3624
|
+
const hop = tryShortHopCallout(
|
|
3625
|
+
c[0],
|
|
3626
|
+
c[1],
|
|
3627
|
+
r.fill,
|
|
3628
|
+
r.label,
|
|
3629
|
+
undefined,
|
|
3630
|
+
SUBJECT_LEADER_FONT,
|
|
3631
|
+
HOP_GAP + SUBJECT_LEADER_FONT,
|
|
3632
|
+
SHORT_HOP_MAX * 2.2
|
|
3633
|
+
);
|
|
3634
|
+
if (hop) {
|
|
3635
|
+
placedRegionRects.push(hop.rect);
|
|
3636
|
+
placedLeaders.push(hop.leader);
|
|
3637
|
+
const dark = mix(r.fill, palette.text, 60);
|
|
3638
|
+
labels.push({
|
|
3639
|
+
x: hop.x,
|
|
3640
|
+
y: hop.y,
|
|
3641
|
+
text: r.label,
|
|
3642
|
+
anchor: 'middle',
|
|
3643
|
+
color: palette.text,
|
|
3644
|
+
halo: false,
|
|
3645
|
+
haloColor: palette.bg,
|
|
3646
|
+
fontSize: SUBJECT_LEADER_FONT,
|
|
3647
|
+
leader: {
|
|
3648
|
+
x1: hop.leader[0],
|
|
3649
|
+
y1: hop.leader[1],
|
|
3650
|
+
x2: hop.leader[2],
|
|
3651
|
+
y2: hop.leader[3],
|
|
3652
|
+
},
|
|
3653
|
+
leaderColor: dark,
|
|
3654
|
+
calloutDot: { x: c[0], y: c[1], color: dark },
|
|
3655
|
+
lineNumber: r.lineNumber,
|
|
3656
|
+
});
|
|
3657
|
+
labeledRegionIds.add(r.id);
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
continue;
|
|
3661
|
+
}
|
|
3421
3662
|
// The first candidate that BOTH fits its own footprint AND clears every
|
|
3422
3663
|
// already-placed region label AND every POI marker wins; none qualifies →
|
|
3423
3664
|
// the label is hidden (a country has no abbrev, so it degrades full → hide;
|
|
@@ -3434,10 +3675,6 @@ export function layoutMap(
|
|
|
3434
3675
|
// hanging below a name that already clears the dot is fine. Testing the
|
|
3435
3676
|
// taller stack here made a region with a nearby POI (Texas under the big
|
|
3436
3677
|
// Dallas marker) silently drop its value even though the name fit.
|
|
3437
|
-
const fitsRegions = (rect: LabelRect): boolean =>
|
|
3438
|
-
!placedRegionRects.some((p) => rectsOverlap(rect, p));
|
|
3439
|
-
const fitsPois = (rect: LabelRect): boolean =>
|
|
3440
|
-
!poiObstacles.some((o) => rectsOverlap(rect, o));
|
|
3441
3678
|
// Try the centroid first (existing placement — unchanged when it fits),
|
|
3442
3679
|
// then a ring of offsets WITHIN the region's box so a label blocked at the
|
|
3443
3680
|
// centroid (typically a POI marker sitting on it — Dallas on Texas) is
|
|
@@ -3473,13 +3710,23 @@ export function layoutMap(
|
|
|
3473
3710
|
const nameRect = regionLabelRect(a.x, a.y, t);
|
|
3474
3711
|
if (valStr && stackW(t, valStr) <= boxW && stackH(true) <= boxH) {
|
|
3475
3712
|
const stackRect = regionLabelRect(a.x, a.y, t, valStr);
|
|
3476
|
-
if (
|
|
3713
|
+
if (
|
|
3714
|
+
fitsRegions(stackRect) &&
|
|
3715
|
+
fitsPois(nameRect) &&
|
|
3716
|
+
extremesOnFill(a.x, a.y, t)
|
|
3717
|
+
) {
|
|
3477
3718
|
chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
|
|
3478
3719
|
break;
|
|
3479
3720
|
}
|
|
3480
3721
|
}
|
|
3481
3722
|
if (labelW(t) <= boxW && labelH <= boxH) {
|
|
3482
|
-
|
|
3723
|
+
// A data label must sit inside its own choropleth fill (so it can drop
|
|
3724
|
+
// the halo); a context/container label keeps the looser bbox fit.
|
|
3725
|
+
if (
|
|
3726
|
+
fitsRegions(nameRect) &&
|
|
3727
|
+
fitsPois(nameRect) &&
|
|
3728
|
+
(valStr === undefined || extremesOnFill(a.x, a.y, t))
|
|
3729
|
+
) {
|
|
3483
3730
|
chosen = { text: t, ax: a.x, ay: a.y };
|
|
3484
3731
|
break;
|
|
3485
3732
|
}
|
|
@@ -3487,22 +3734,23 @@ export function layoutMap(
|
|
|
3487
3734
|
}
|
|
3488
3735
|
if (chosen) break;
|
|
3489
3736
|
}
|
|
3490
|
-
if (chosen === undefined &&
|
|
3491
|
-
// A
|
|
3492
|
-
//
|
|
3493
|
-
//
|
|
3494
|
-
//
|
|
3495
|
-
//
|
|
3496
|
-
//
|
|
3497
|
-
//
|
|
3498
|
-
//
|
|
3737
|
+
if (chosen === undefined && r.label !== undefined && isSubject) {
|
|
3738
|
+
// A SUBJECT whose name won't sit inside its own shape — a thin ribbon
|
|
3739
|
+
// (Chile) or a small valued region crowded by a POI. Rather than overflow
|
|
3740
|
+
// onto neighbours (illegible) or fire a long leader to a margin column
|
|
3741
|
+
// (spaghetti), nudge the FULL name(+value) chip a short hop into the open
|
|
3742
|
+
// space right beside it — out to sea, north over Canada, south over Mexico
|
|
3743
|
+
// — joined by a tiny non-crossing leader. A subject is the map's point, so
|
|
3744
|
+
// it gets this leader whether or not it carries a value. If no direction
|
|
3745
|
+
// has clean adjacent room within reach, the region gets no static label
|
|
3746
|
+
// (the shading/legend + hover carry it); a readable blank beats a tangle.
|
|
3499
3747
|
const hop = tryShortHopCallout(c[0], c[1], r.fill, r.label, valStr);
|
|
3500
3748
|
if (hop) {
|
|
3501
3749
|
placedRegionRects.push(hop.rect);
|
|
3502
3750
|
placedLeaders.push(hop.leader);
|
|
3503
|
-
// Chip sits on
|
|
3504
|
-
//
|
|
3505
|
-
// the
|
|
3751
|
+
// Chip sits on empty land / water (the callout only seats on isEmptyFill)
|
|
3752
|
+
// → palette text, NO halo (full-contrast dark/light text reads cleanly on
|
|
3753
|
+
// the light basemap; the leader + dot tie the line back to the region).
|
|
3506
3754
|
const dark = mix(r.fill, palette.text, 60);
|
|
3507
3755
|
labels.push({
|
|
3508
3756
|
x: hop.x,
|
|
@@ -3510,9 +3758,9 @@ export function layoutMap(
|
|
|
3510
3758
|
text: r.label,
|
|
3511
3759
|
anchor: 'middle',
|
|
3512
3760
|
color: palette.text,
|
|
3513
|
-
halo:
|
|
3761
|
+
halo: false,
|
|
3514
3762
|
haloColor: palette.bg,
|
|
3515
|
-
valueLine: valStr,
|
|
3763
|
+
...(valStr !== undefined && { valueLine: valStr }),
|
|
3516
3764
|
leader: {
|
|
3517
3765
|
x1: hop.leader[0],
|
|
3518
3766
|
y1: hop.leader[1],
|
|
@@ -3523,6 +3771,7 @@ export function layoutMap(
|
|
|
3523
3771
|
calloutDot: { x: c[0], y: c[1], color: dark },
|
|
3524
3772
|
lineNumber: r.lineNumber,
|
|
3525
3773
|
});
|
|
3774
|
+
labeledRegionIds.add(r.id);
|
|
3526
3775
|
}
|
|
3527
3776
|
continue;
|
|
3528
3777
|
}
|
|
@@ -3538,19 +3787,31 @@ export function layoutMap(
|
|
|
3538
3787
|
// on neutral basemap land where a larger, gently-faded backdrop reads well.
|
|
3539
3788
|
const isOrient = r.value === undefined && r.layer === 'base';
|
|
3540
3789
|
let font = FONT;
|
|
3541
|
-
let fade = 0;
|
|
3542
3790
|
if (isOrient) {
|
|
3543
3791
|
const growT = sizeT(boxW, boxH);
|
|
3544
3792
|
const desiredFont = Math.round(
|
|
3545
3793
|
FONT + growT * (REGION_FONT_MAX_ORIENT - FONT)
|
|
3546
3794
|
);
|
|
3547
3795
|
const hasVal = chosen.valueLine !== undefined;
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3796
|
+
// A backdrop name carries NO halo, so it must sit cleanly at whatever size
|
|
3797
|
+
// it takes. Try the desired (footprint-grown) size DOWN TO the base font;
|
|
3798
|
+
// at each, the name must fit the box, clear neighbours/POIs/route arcs
|
|
3799
|
+
// (`collides` adds the arc segments `fitsPois` misses), AND have both
|
|
3800
|
+
// horizontal extremes on a floored-readable substrate — its own fill, or
|
|
3801
|
+
// empty land/water (neutral/foreign/sea). The only thing it must NOT cross
|
|
3802
|
+
// is a saturated foreign DATA fill, where the muted floored tone is
|
|
3803
|
+
// illegible and there's no halo to rescue it. If nothing — not even the
|
|
3804
|
+
// base font — sits clean, the label is DROPPED (the basemap shading +
|
|
3805
|
+
// hover carry the region); a readable blank beats an unreadable name.
|
|
3806
|
+
const extremesClean = (f: number): boolean => {
|
|
3807
|
+
const halfW = measureLegendText(chosen.text, f) / 2;
|
|
3808
|
+
return [chosen.ax - halfW, chosen.ax + halfW].every((sx) => {
|
|
3809
|
+
const fAt = fillAt(sx, chosen.ay);
|
|
3810
|
+
return fAt === r.fill || isEmptyFill(fAt);
|
|
3811
|
+
});
|
|
3812
|
+
};
|
|
3813
|
+
let fit = -1;
|
|
3814
|
+
for (let f = desiredFont; f >= FONT; f--) {
|
|
3554
3815
|
if (
|
|
3555
3816
|
stackW(chosen.text, chosen.valueLine, f) > boxW ||
|
|
3556
3817
|
stackH(hasVal, f) > boxH
|
|
@@ -3570,17 +3831,24 @@ export function layoutMap(
|
|
|
3570
3831
|
undefined,
|
|
3571
3832
|
f
|
|
3572
3833
|
);
|
|
3573
|
-
if (!fitsRegions(gRect) || !fitsPois(gName))
|
|
3574
|
-
const halfW = measureLegendText(chosen.text, f) / 2;
|
|
3575
|
-
if (
|
|
3576
|
-
fillAt(chosen.ax - halfW, chosen.ay) !== r.fill ||
|
|
3577
|
-
fillAt(chosen.ax + halfW, chosen.ay) !== r.fill
|
|
3578
|
-
)
|
|
3834
|
+
if (!fitsRegions(gRect) || !fitsPois(gName) || collides(gName))
|
|
3579
3835
|
continue;
|
|
3580
|
-
|
|
3836
|
+
if (!extremesClean(f)) continue;
|
|
3837
|
+
fit = f;
|
|
3581
3838
|
break;
|
|
3582
3839
|
}
|
|
3583
|
-
|
|
3840
|
+
if (fit < 0) {
|
|
3841
|
+
// A POI-frame CONTAINER is the map's subject (its headline name) — it
|
|
3842
|
+
// must never drop, so it falls back to the base font even when no size
|
|
3843
|
+
// sits perfectly clean (the floored tone keeps it legible). A mere
|
|
3844
|
+
// backdrop NEIGHBOUR with no clean placement is dropped: the basemap
|
|
3845
|
+
// shading + hover carry it, and a readable blank beats a name crammed
|
|
3846
|
+
// onto an arc or a foreign data fill.
|
|
3847
|
+
if (!frameContainers.has(r.id)) continue;
|
|
3848
|
+
font = FONT;
|
|
3849
|
+
} else {
|
|
3850
|
+
font = fit;
|
|
3851
|
+
}
|
|
3584
3852
|
}
|
|
3585
3853
|
const rRect = regionLabelRect(
|
|
3586
3854
|
chosen.ax,
|
|
@@ -3598,8 +3866,9 @@ export function layoutMap(
|
|
|
3598
3866
|
r.lineNumber,
|
|
3599
3867
|
chosen.valueLine,
|
|
3600
3868
|
font,
|
|
3601
|
-
|
|
3869
|
+
isOrient ? backdropLandTone : undefined
|
|
3602
3870
|
);
|
|
3871
|
+
labeledRegionIds.add(r.id);
|
|
3603
3872
|
// Guard so a POI label landing here later makes this label yield (below).
|
|
3604
3873
|
regionLabelGuards.push({
|
|
3605
3874
|
label: labels[labels.length - 1]!,
|
|
@@ -3620,6 +3889,7 @@ export function layoutMap(
|
|
|
3620
3889
|
seed.lineNumber,
|
|
3621
3890
|
valStr
|
|
3622
3891
|
);
|
|
3892
|
+
labeledRegionIds.add(seed.iso);
|
|
3623
3893
|
regionLabelGuards.push({
|
|
3624
3894
|
label: labels[labels.length - 1]!,
|
|
3625
3895
|
rect: regionLabelRect(seed.x, seed.y, text, valStr),
|
|
@@ -4329,17 +4599,39 @@ export function layoutMap(
|
|
|
4329
4599
|
const nx = -dy / len;
|
|
4330
4600
|
const ny = dx / len; // unit normal to the chord
|
|
4331
4601
|
const perp = labelH + 2;
|
|
4332
|
-
//
|
|
4333
|
-
//
|
|
4334
|
-
//
|
|
4335
|
-
//
|
|
4602
|
+
// Walk the ACTUAL DRAWN PATH, not the chord: parse the leg's `d` (the
|
|
4603
|
+
// trimmed `M…Q…` arc or `M…L…` line) and sample the curve at each t, so a
|
|
4604
|
+
// label rides its own bowed line instead of floating at the chord midpoint
|
|
4605
|
+
// out in open space (the trans-Atlantic arc case). The perpendicular hop
|
|
4606
|
+
// uses the chord normal, which is close enough to nudge a label clear.
|
|
4607
|
+
const pm =
|
|
4608
|
+
/^M(-?[\d.]+),(-?[\d.]+)(?:L(-?[\d.]+),(-?[\d.]+)|Q(-?[\d.]+),(-?[\d.]+) (-?[\d.]+),(-?[\d.]+))$/.exec(
|
|
4609
|
+
lg.d
|
|
4610
|
+
);
|
|
4611
|
+
const pointAt = (t: number): [number, number] => {
|
|
4612
|
+
if (pm?.[5] !== undefined) {
|
|
4613
|
+
const u = 1 - t;
|
|
4614
|
+
return [
|
|
4615
|
+
u * u * +pm[1]! + 2 * u * t * +pm[5]! + t * t * +pm[7]!,
|
|
4616
|
+
u * u * +pm[2]! + 2 * u * t * +pm[6]! + t * t * +pm[8]!,
|
|
4617
|
+
];
|
|
4618
|
+
}
|
|
4619
|
+
if (pm?.[3] !== undefined)
|
|
4620
|
+
return [
|
|
4621
|
+
+pm[1]! + (+pm[3]! - +pm[1]!) * t,
|
|
4622
|
+
+pm[2]! + (+pm[4]! - +pm[2]!) * t,
|
|
4623
|
+
];
|
|
4624
|
+
return [a.cx + dx * t, a.cy + dy * t];
|
|
4625
|
+
};
|
|
4626
|
+
// Stay ON the line first (slide along it), then escalate to a perpendicular
|
|
4627
|
+
// hop — growing — so a label on a SHORT leg between two close dots (where
|
|
4628
|
+
// every on-line slot straddles an endpoint) can still escape above or below.
|
|
4336
4629
|
const candidates: Array<[number, number]> = [];
|
|
4337
4630
|
for (const off of [0, perp, -perp, 2 * perp, -2 * perp])
|
|
4338
|
-
for (const t of T_LIST)
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
]);
|
|
4631
|
+
for (const t of T_LIST) {
|
|
4632
|
+
const [bx, by] = pointAt(t);
|
|
4633
|
+
candidates.push([bx + nx * off, by + ny * off - 4]);
|
|
4634
|
+
}
|
|
4343
4635
|
const clean = ([cx, cy]: [number, number]): boolean => {
|
|
4344
4636
|
const rect = { x: cx - w / 2, y: cy - labelH / 2, w, h: labelH };
|
|
4345
4637
|
if (
|
|
@@ -4354,10 +4646,8 @@ export function layoutMap(
|
|
|
4354
4646
|
if (placedEdge.some((o) => rectsOverlap(o, rect))) return false;
|
|
4355
4647
|
return true;
|
|
4356
4648
|
};
|
|
4357
|
-
const
|
|
4358
|
-
|
|
4359
|
-
a.cy + dy * 0.5 - 4,
|
|
4360
|
-
];
|
|
4649
|
+
const mid = pointAt(0.5);
|
|
4650
|
+
const [cx, cy] = candidates.find(clean) ?? [mid[0], mid[1] - 4];
|
|
4361
4651
|
const style = labelOnFill(fillAt(cx, cy));
|
|
4362
4652
|
// MapLayoutLeg fields are readonly — replace the entry with an updated copy.
|
|
4363
4653
|
legs[i] = {
|
|
@@ -4419,7 +4709,7 @@ export function layoutMap(
|
|
|
4419
4709
|
const rawCountries: RawCountry[] = [];
|
|
4420
4710
|
for (const f of worldLayer.values()) {
|
|
4421
4711
|
const iso = typeof f.id === 'string' ? f.id : String(f.id ?? '');
|
|
4422
|
-
if (!iso || regionById.has(iso)) continue;
|
|
4712
|
+
if (!iso || regionById.has(iso) || labeledRegionIds.has(iso)) continue;
|
|
4423
4713
|
// F3: skip a country whose SUBDIVISIONS are the referenced data (e.g. a
|
|
4424
4714
|
// US-states choropleth on a world projection) — the states ARE the data,
|
|
4425
4715
|
// so don't slap a redundant "United States" context label over them.
|
|
@@ -4536,7 +4826,7 @@ export function layoutMap(
|
|
|
4536
4826
|
);
|
|
4537
4827
|
if (usLayer && framedStateContainers) {
|
|
4538
4828
|
for (const [iso, f] of usLayer) {
|
|
4539
|
-
if (regionById.has(iso)) continue;
|
|
4829
|
+
if (regionById.has(iso) || labeledRegionIds.has(iso)) continue;
|
|
4540
4830
|
const viewF = cullFeatureToView(f);
|
|
4541
4831
|
if (!viewF) continue; // not in frame
|
|
4542
4832
|
const b = path.bounds(viewF as never) as [
|
|
@@ -4563,6 +4853,11 @@ export function layoutMap(
|
|
|
4563
4853
|
waterBodies: data.waterBodies,
|
|
4564
4854
|
countries: countryCandidates,
|
|
4565
4855
|
palette,
|
|
4856
|
+
// One muted, contrast-floored tone shared with the orientation backdrop
|
|
4857
|
+
// (region pass) so a context country (Canada) and an orientation region
|
|
4858
|
+
// (Minnesota) read identically — never below the WCAG-AA floor, no fade.
|
|
4859
|
+
countryTone: backdropLandTone,
|
|
4860
|
+
waterTone: backdropWaterTone,
|
|
4566
4861
|
project,
|
|
4567
4862
|
collides,
|
|
4568
4863
|
contentPoints,
|