@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.
Files changed (59) hide show
  1. package/.claude/commands/dgmo.md +17 -32
  2. package/.github/copilot-instructions.md +1 -1
  3. package/README.md +7 -1
  4. package/SKILL.md +1 -1
  5. package/dist/advanced.cjs +791 -614
  6. package/dist/advanced.d.cts +8 -51
  7. package/dist/advanced.d.ts +8 -51
  8. package/dist/advanced.js +791 -615
  9. package/dist/auto.cjs +793 -292
  10. package/dist/auto.js +95 -95
  11. package/dist/auto.mjs +793 -292
  12. package/dist/cli.cjs +145 -328
  13. package/dist/index.cjs +792 -291
  14. package/dist/index.js +792 -291
  15. package/docs/ai-integration.md +46 -26
  16. package/docs/language-reference.md +2 -2
  17. package/package.json +3 -3
  18. package/src/advanced.ts +0 -12
  19. package/src/boxes-and-lines/label-placement.ts +339 -0
  20. package/src/boxes-and-lines/layout-search.ts +16 -2
  21. package/src/boxes-and-lines/layout.ts +32 -7
  22. package/src/boxes-and-lines/renderer.ts +67 -139
  23. package/src/c4/parser.ts +15 -6
  24. package/src/chart.ts +1 -1
  25. package/src/class/parser.ts +6 -2
  26. package/src/cli-banner.ts +2 -3
  27. package/src/cli.ts +539 -1223
  28. package/src/cycle/parser.ts +4 -1
  29. package/src/dgmo-router.ts +16 -2
  30. package/src/diagnostics.ts +0 -0
  31. package/src/echarts.ts +5 -1
  32. package/src/er/parser.ts +5 -1
  33. package/src/gantt/parser.ts +13 -4
  34. package/src/graph/flowchart-parser.ts +5 -1
  35. package/src/graph/state-parser.ts +5 -1
  36. package/src/infra/parser.ts +10 -6
  37. package/src/journey-map/parser.ts +5 -2
  38. package/src/kanban/parser.ts +6 -2
  39. package/src/map/context-labels.ts +29 -19
  40. package/src/map/layout.ts +403 -108
  41. package/src/map/legend-band.ts +28 -0
  42. package/src/map/parser.ts +13 -4
  43. package/src/map/renderer.ts +74 -23
  44. package/src/map/resolver.ts +2 -2
  45. package/src/org/parser.ts +8 -2
  46. package/src/pyramid/parser.ts +4 -1
  47. package/src/raci/parser.ts +4 -1
  48. package/src/ring/parser.ts +4 -1
  49. package/src/sequence/parser.ts +6 -3
  50. package/src/sitemap/parser.ts +12 -3
  51. package/src/tech-radar/parser.ts +6 -3
  52. package/src/utils/parsing.ts +2 -2
  53. package/src/utils/tag-groups.ts +3 -3
  54. package/src/visualizations/parse.ts +9 -3
  55. package/src/migrate/data-comma.ts +0 -117
  56. package/src/migrate/embedded.ts +0 -238
  57. package/src/migrate/index.ts +0 -295
  58. package/src/migrate/line-classifier.ts +0 -128
  59. 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
- // Most-interior cell leads (tie → nearest the visible centroid).
815
- const pool = [...kept];
816
- pool.sort((a, b) => {
817
- const d = interiorness(b) - interiorness(a);
818
- return d !== 0 ? d : dist2ToCentre(a) - dist2ToCentre(b);
819
- });
820
- // grid ordering: best cell, then greedy max-min spread of the rest.
821
- const ordered: Cell[] = [pool.shift()!];
822
- while (ordered.length < MAX_COUNTRY_POSITIONS && pool.length) {
823
- let bestIdx = 0;
824
- let bestMin = -1;
825
- for (let k = 0; k < pool.length; k++) {
826
- const c = pool[k]!;
827
- let minD = Infinity;
828
- for (const o of ordered) {
829
- const d = (c.sx - o.sx) ** 2 + (c.sy - o.sy) ** 2;
830
- if (d < minD) minD = d;
831
- }
832
- if (minD > bestMin) {
833
- bestMin = minD;
834
- bestIdx = k;
835
- }
836
- }
837
- ordered.push(pool.splice(bestIdx, 1)[0]!);
838
- }
839
- const grid = ordered.map((c) => ({
840
- lonLat: [c.lon, c.lat] as [number, number],
841
- screen: [c.sx, c.sy] as [number, number],
842
- }));
843
- // Curated anchor (D13): forced to slot 0; grid cells fill the rest.
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
- const out = [...curatedPos, ...grid].slice(0, MAX_COUNTRY_POSITIONS);
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 mx = (a.cx + b.cx) / 2;
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: mx,
2921
- labelY: my - 4,
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 mx = (a.cx + b.cx) / 2;
2976
- const my = (a.cy + b.cy) / 2;
3078
+ const curved = e.style === 'arc' || n > 1;
3079
+ const lp = legLabelPoint(a, b, curved, fanOffset);
2977
3080
  const bow = {
2978
- curved: e.style === 'arc' || n > 1,
3081
+ curved,
2979
3082
  offset: fanOffset,
2980
- labelX: mx,
2981
- labelY: my - 4,
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
- const obstacles: LabelRect[] = [];
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
- const REGION_FADE_ORIENT = 45; // % toward bg at max size, backdrop
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
- fade: number = 0
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
- // Subdue a grown label toward the background — gentle on data (value stays
3166
- // readable), stronger on orientation backdrop. Zero fade ⇒ exact base color.
3167
- const color = fade > 0 ? mix(baseColor, palette.bg, fade) : baseColor;
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
- halo: overflows,
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
- const placedRegionRects: LabelRect[] = [];
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(SHORT_HOP_MAX / STEP) + 1;
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 * (HOP_GAP + halfAlong);
3357
- const ccy = ey + dy * (HOP_GAP + halfAlong);
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 > SHORT_HOP_MAX) continue;
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 (fitsRegions(stackRect) && fitsPois(nameRect)) {
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
- if (fitsRegions(nameRect) && fitsPois(nameRect)) {
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 && valStr && r.label !== undefined) {
3491
- // A VALUED region whose name won't fit in place (even abbreviated). Rather
3492
- // than overflow onto neighbours (illegible) or fire a long leader to a
3493
- // margin column (spaghetti), nudge the FULL name+value chip a short hop
3494
- // into the open space right beside it — north over Canada, south over
3495
- // Mexico, out to sea — joined by a tiny non-crossing leader. If no
3496
- // direction has clean adjacent room within reach, the region simply gets
3497
- // no static label: the choropleth shading + legend carry it and hover
3498
- // reveals the name+value. A readable blank beats a crammed tangle.
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 base land / water of unpredictable tone → palette text
3504
- // + halo (matches the old column chips). The leader/dot are tinted from
3505
- // the region's own fill (darkened for contrast) to tie line to region.
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: true,
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
- for (let f = desiredFont; f > FONT; f--) {
3549
- // Fit the footprint box, clear neighbours/POIs, AND — the real guard —
3550
- // stay INSIDE the region's own fill at the bigger size. The bbox is far
3551
- // too loose for an irregular shape (Alaska blows up the US bbox), so
3552
- // sample the grown name's horizontal extremes against `fillAt`: if
3553
- // either leaves this region's fill, don't grow that far.
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)) continue;
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
- font = f;
3836
+ if (!extremesClean(f)) continue;
3837
+ fit = f;
3581
3838
  break;
3582
3839
  }
3583
- fade = font > FONT ? Math.round(growT * REGION_FADE_ORIENT) : 0;
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
- fade
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
- // Stay ON the line first (slide along the chord), then escalate to a
4333
- // perpendicular hop — growing — so a label on a SHORT leg between two close
4334
- // dots (where every on-chord slot straddles an endpoint) can still escape
4335
- // clear above or below the line.
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
- candidates.push([
4340
- a.cx + dx * t + nx * off,
4341
- a.cy + dy * t + ny * off - 4,
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 [cx, cy] = candidates.find(clean) ?? [
4358
- a.cx + dx * 0.5,
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,