@diagrammo/dgmo 0.30.0 → 0.32.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 (85) hide show
  1. package/.cursorrules +4 -1
  2. package/.github/copilot-instructions.md +4 -1
  3. package/.windsurfrules +4 -1
  4. package/README.md +21 -3
  5. package/SKILL.md +4 -1
  6. package/dist/advanced.cjs +1853 -623
  7. package/dist/advanced.d.cts +143 -16
  8. package/dist/advanced.d.ts +143 -16
  9. package/dist/advanced.js +1846 -623
  10. package/dist/auto.cjs +1640 -581
  11. package/dist/auto.js +99 -99
  12. package/dist/auto.mjs +1640 -581
  13. package/dist/cli.cjs +148 -147
  14. package/dist/index.cjs +1643 -662
  15. package/dist/index.js +1643 -662
  16. package/docs/ai-integration.md +4 -1
  17. package/docs/language-reference.md +282 -27
  18. package/gallery/fixtures/boxes-and-lines.dgmo +2 -2
  19. package/gallery/fixtures/c4-full.dgmo +4 -5
  20. package/gallery/fixtures/c4.dgmo +2 -3
  21. package/package.json +7 -1
  22. package/src/advanced.ts +10 -0
  23. package/src/boxes-and-lines/focus.ts +257 -0
  24. package/src/boxes-and-lines/layout-search.ts +345 -65
  25. package/src/boxes-and-lines/layout.ts +11 -1
  26. package/src/boxes-and-lines/parser.ts +97 -4
  27. package/src/boxes-and-lines/renderer.ts +111 -8
  28. package/src/boxes-and-lines/types.ts +9 -0
  29. package/src/c4/parser.ts +8 -7
  30. package/src/c4/renderer.ts +7 -5
  31. package/src/chart-type-registry.ts +129 -4
  32. package/src/chart-types.ts +3 -3
  33. package/src/chart.ts +18 -1
  34. package/src/class/renderer.ts +4 -2
  35. package/src/cli-banner.ts +107 -0
  36. package/src/cli.ts +13 -0
  37. package/src/colors.ts +247 -2
  38. package/src/cycle/parser.ts +2 -7
  39. package/src/d3.ts +67 -54
  40. package/src/diagnostics.ts +17 -0
  41. package/src/dimensions.ts +9 -13
  42. package/src/echarts.ts +42 -14
  43. package/src/er/parser.ts +6 -1
  44. package/src/er/renderer.ts +4 -2
  45. package/src/gantt/parser.ts +44 -7
  46. package/src/graph/flowchart-parser.ts +77 -3
  47. package/src/graph/flowchart-renderer.ts +4 -2
  48. package/src/graph/state-renderer.ts +6 -4
  49. package/src/infra/parser.ts +80 -0
  50. package/src/infra/renderer.ts +8 -4
  51. package/src/journey-map/parser.ts +23 -8
  52. package/src/journey-map/renderer.ts +1 -1
  53. package/src/kanban/parser.ts +8 -7
  54. package/src/kanban/renderer.ts +1 -1
  55. package/src/map/context-labels.ts +134 -27
  56. package/src/map/geo.ts +10 -2
  57. package/src/map/layout.ts +259 -4
  58. package/src/map/parser.ts +2 -0
  59. package/src/map/renderer.ts +49 -25
  60. package/src/map/resolver.ts +68 -19
  61. package/src/mindmap/parser.ts +15 -7
  62. package/src/mindmap/renderer.ts +55 -15
  63. package/src/org/parser.ts +8 -7
  64. package/src/org/renderer.ts +89 -127
  65. package/src/palettes/color-utils.ts +19 -4
  66. package/src/palettes/index.ts +1 -0
  67. package/src/pert/renderer.ts +15 -10
  68. package/src/pyramid/parser.ts +2 -7
  69. package/src/quadrant/renderer.ts +2 -2
  70. package/src/raci/parser.ts +2 -7
  71. package/src/raci/renderer.ts +5 -5
  72. package/src/ring/parser.ts +2 -7
  73. package/src/sequence/parser.ts +18 -7
  74. package/src/sequence/renderer.ts +4 -4
  75. package/src/sitemap/parser.ts +8 -7
  76. package/src/sitemap/renderer.ts +37 -39
  77. package/src/tech-radar/parser.ts +2 -7
  78. package/src/timeline/renderer.ts +15 -5
  79. package/src/utils/card.ts +183 -0
  80. package/src/utils/parsing.ts +13 -1
  81. package/src/utils/scaling.ts +38 -81
  82. package/src/utils/tag-groups.ts +48 -10
  83. package/src/utils/visual-conventions.ts +61 -0
  84. package/src/visualizations/parse.ts +6 -1
  85. package/src/wireframe/parser.ts +6 -1
@@ -34,6 +34,13 @@ export interface CountryCandidate {
34
34
  * bypasses the both-axes smear gate (its full-canvas bbox is an antimeridian
35
35
  * artifact, not a real footprint, so the unreliable-centroid concern is moot). */
36
36
  readonly curatedAnchor?: boolean;
37
+ /** Ordered interior placement positions (screen coords), best-first: the commit
38
+ * loop tries each in turn and places at the first that clears all collisions, so
39
+ * a country can dodge a data cluster onto open ground on its own land
40
+ * (map-context-neighbor-labels, D7/D12). INVARIANT: either ABSENT or NON-EMPTY,
41
+ * and `anchor === positions[0]` always (an empty array is illegal — F7).
42
+ * Absent ⇒ single-anchor behaviour (`positions ?? [anchor]`). */
43
+ readonly positions?: readonly (readonly [number, number])[];
37
44
  }
38
45
 
39
46
  export interface ContextLabelArgs {
@@ -48,6 +55,13 @@ export interface ContextLabelArgs {
48
55
  readonly project: (lon: number, lat: number) => [number, number] | null;
49
56
  /** Collision test against every committed data/region/POI/route obstacle. */
50
57
  readonly collides: (rect: LabelRect) => boolean;
58
+ /** Screen positions of the diagram's CONTENT (POI markers / data points). When
59
+ * non-empty, country candidates rank by proximity to the NEAREST point (not a
60
+ * centroid — a centroid is dragged off by outlying POIs and pulls in irrelevant
61
+ * giants) so a thin budget labels the countries adjacent to the story (Belarus,
62
+ * Poland) rather than far-corner giants (Kazakhstan). Empty/absent ⇒ the legacy
63
+ * area-descending rank (map-context-neighbor-labels, proximity knob). */
64
+ readonly contentPoints?: readonly (readonly [number, number])[] | undefined;
51
65
  /** True when the screen point sits over LAND (a country/state fill) rather than
52
66
  * open water. WATER labels are rejected when their footprint touches land — an
53
67
  * ocean name belongs over the ocean (they're optional orientation aids, so drop
@@ -76,6 +90,15 @@ const COUNTRY_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
76
90
  const COUNTRY_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
77
91
  const COUNTRY_FADE_MAX = 45; // % blend toward bg at max font (subdue big names)
78
92
 
93
+ // Multi-position country dodging (map-context-neighbor-labels). A country gets
94
+ // several interior on-its-own-land positions so its label can dodge a colliding
95
+ // data cluster into open space instead of being dropped. Layout generates the
96
+ // positions (geo work stays in layout); the commit loop below walks them
97
+ // best-first and places at the first that clears all collisions.
98
+ export const MAX_COUNTRY_POSITIONS = 4; // ordered positions per country (cap; D7/D15)
99
+ export const COUNTRY_POS_GRID = 5; // lon/lat sampling grid resolution N (N×N cells; D9)
100
+ export const COUNTRY_POS_TOPN_MARGIN = 3; // generate positions for top budget+margin (D15)
101
+
79
102
  // Water-kind priority within a tier (oceans first, then seas, then the rest) so
80
103
  // a thin budget always spends on the highest-orientation-value names.
81
104
  const KIND_ORDER: Record<WaterKind, number> = {
@@ -107,12 +130,18 @@ export function labelBudget(
107
130
  band: TierBand
108
131
  ): number {
109
132
  const bandCap: Record<TierBand, number> = {
110
- world: 7,
111
- continental: 6,
112
- regional: 5,
113
- local: 4,
133
+ world: 10,
134
+ continental: 9,
135
+ regional: 7,
136
+ local: 6,
114
137
  };
115
- const area = Math.floor(Math.sqrt(Math.max(0, width * height)) / 150);
138
+ // Area divisor lowered 150→105 (map-context-neighbor-labels, budget knob): the
139
+ // old budget was so thin a regional view spent every slot on a couple of giant
140
+ // landmasses, leaving the countries that ring the story unlabeled. The lower
141
+ // divisor + raised band caps roughly +50% the slots so the proximity rank has
142
+ // room to surface the neighbourhood. Still floors to ~1 on a thumbnail / 0 on a
143
+ // tiny canvas (AC9).
144
+ const area = Math.floor(Math.sqrt(Math.max(0, width * height)) / 105);
116
145
  return Math.max(0, Math.min(area, bandCap[band]));
117
146
  }
118
147
 
@@ -217,6 +246,22 @@ function rectAround(
217
246
  return { x: cx - w / 2, y: cy - h / 2, w, h };
218
247
  }
219
248
 
249
+ /** Squared distance from point (px,py) to an axis-aligned bbox [x0,y0,x1,y1]; 0
250
+ * when the point is inside. Used to rank country candidates by how close their
251
+ * footprint reaches to the diagram's content centre (proximity knob). */
252
+ function rectDist2(
253
+ px: number,
254
+ py: number,
255
+ x0: number,
256
+ y0: number,
257
+ x1: number,
258
+ y1: number
259
+ ): number {
260
+ const dx = Math.max(x0 - px, 0, px - x1);
261
+ const dy = Math.max(y0 - py, 0, py - y1);
262
+ return dx * dx + dy * dy;
263
+ }
264
+
220
265
  function rectFits(r: LabelRect, width: number, height: number): boolean {
221
266
  return r.x >= 0 && r.y >= 0 && r.x + r.w <= width && r.y + r.h <= height;
222
267
  }
@@ -244,6 +289,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
244
289
  palette,
245
290
  project,
246
291
  collides,
292
+ contentPoints,
247
293
  overLand,
248
294
  } = args;
249
295
 
@@ -276,6 +322,9 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
276
322
  color: string;
277
323
  fontSize: number;
278
324
  sort: number; // priority key (lower first)
325
+ // Ordered dodge positions (screen coords), best-first. Absent on water (single
326
+ // anchor); on a country, mirrors CountryCandidate.positions (anchor === [0]).
327
+ positions?: readonly (readonly [number, number])[];
279
328
  };
280
329
  const candidates: Candidate[] = [];
281
330
 
@@ -360,18 +409,39 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
360
409
  });
361
410
  }
362
411
 
363
- // -- Country candidates (unreferenced; biggest projected area first) --
364
- // Rank by screen bbox area; keep only those whose name fits the footprint
365
- // (width-fit, like region-labels) and whose anchor projects inside the view.
412
+ // -- Country candidates (unreferenced) --
413
+ // Rank PROXIMITY-FIRST when a content centre is known: the countries that ring
414
+ // the diagram's story should win the thin budget, not whichever giants happen to
415
+ // be biggest in frame (Kazakhstan/Sweden on a Ukraine map). The rank's anchor is
416
+ // the candidate's primary position (`positions[0]` === `anchor`). Without a
417
+ // content centre, fall back to the legacy biggest-area-first rank. Area is still
418
+ // computed (it drives the font/fade ramp and the smear gate below).
366
419
  const ranked = countries
367
420
  .map((c) => {
368
421
  const [x0, y0, x1, y1] = c.bbox;
369
422
  const w = x1 - x0;
370
423
  const h = y1 - y0;
371
- return { c, w, h, area: w * h };
424
+ // Distance from the NEAREST content point to the country's FOOTPRINT (0 if a
425
+ // point is inside the bbox, else squared distance to the nearest edge). Edge
426
+ // distance — not centroid distance — so a large neighbour that REACHES toward
427
+ // the action (Poland) outranks a tiny country merely near a point (the
428
+ // Baltics), with no size-weighting constant; nearest-point — not a single
429
+ // centroid — so an outlying POI doesn't drag the rank toward distant giants.
430
+ let dist = Infinity;
431
+ if (contentPoints?.length) {
432
+ for (const p of contentPoints) {
433
+ const d = rectDist2(p[0], p[1], x0, y0, x1, y1);
434
+ if (d < dist) dist = d;
435
+ }
436
+ }
437
+ return { c, w, h, area: w * h, dist };
372
438
  })
373
439
  .filter((r) => Number.isFinite(r.area) && r.area > 0)
374
- .sort((a, b) => b.area - a.area);
440
+ .sort((a, b) =>
441
+ contentPoints?.length
442
+ ? a.dist - b.dist || b.area - a.area // nearest the story first; area breaks ties
443
+ : b.area - a.area
444
+ );
375
445
  // Canvas linear extent — the denominator for the footprint size ramp below.
376
446
  const canvasLinear = Math.sqrt(Math.max(1, width * height));
377
447
  let ci = 0;
@@ -406,7 +476,14 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
406
476
  );
407
477
  const fontSize = Math.round(FONT + t * (COUNTRY_FONT_MAX - FONT));
408
478
  const fade = Math.round(t * COUNTRY_FADE_MAX);
409
- const color = fade > 0 ? mix(countryColor, palette.bg, fade) : countryColor;
479
+ // Blend `fade`% TOWARD the bg (subdue big names). `mix(a,b,pct)` weights `a` by
480
+ // `pct`, so the bg fraction must be `100 - fade` — i.e. a small country (fade≈0)
481
+ // stays fully muted/dark and only a big landmass fades to a soft backdrop. (The
482
+ // earlier `mix(countryColor, bg, fade)` was inverted: it lightened the SMALL
483
+ // names toward white instead, which read as illegible once proximity started
484
+ // surfacing small neighbours like Belarus/Georgia.)
485
+ const color =
486
+ fade > 0 ? mix(countryColor, palette.bg, 100 - fade) : countryColor;
410
487
  // Always the full country name — never an ISO abbreviation. If the name
411
488
  // doesn't fit the footprint, drop the label rather than abbreviate.
412
489
  const text = c.name;
@@ -424,6 +501,9 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
424
501
  letterSpacing: 0,
425
502
  color,
426
503
  fontSize,
504
+ // Multi-position dodging: carry the ordered interior positions through to the
505
+ // commit loop. Invariant anchor === positions[0], so `cx/cy` is positions[0].
506
+ ...(c.positions ? { positions: c.positions } : {}),
427
507
  // Band 1 (orientation-value ranking): above MINOR water (band 2, 2000+) but
428
508
  // below MAJOR water — oceans + major seas (band 0, ≤~16). So a big country
429
509
  // (US, Canada, Russia) outranks a minor sea/bay (Sargasso, Bahía de
@@ -445,17 +525,25 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
445
525
  const countryCount = candidates.reduce((n, c) => n + (c.italic ? 0 : 1), 0);
446
526
  const waterCap = budget - Math.min(2, countryCount);
447
527
  let waterPlaced = 0;
448
- for (const cand of candidates) {
449
- if (placed.length >= budget) break;
450
- if (cand.italic && waterPlaced >= waterCap) continue;
528
+ // Test one trial position for a candidate against every gate (fit, water-on-land,
529
+ // committed-obstacle collision, context-overlap). Returns the placed rect when the
530
+ // position clears, else null. Country candidates carry several ordered positions
531
+ // (map-context-neighbor-labels); we walk them best-first and commit the first that
532
+ // clears, so a label dodges a colliding cluster instead of being dropped. Water
533
+ // candidates have a single position, so behaviour is unchanged for them.
534
+ const gateAt = (
535
+ cx: number,
536
+ cy: number,
537
+ cand: Candidate
538
+ ): LabelRect | null => {
451
539
  const rect = rectAround(
452
- cand.cx,
453
- cand.cy,
540
+ cx,
541
+ cy,
454
542
  cand.lines,
455
543
  cand.letterSpacing,
456
544
  cand.fontSize
457
545
  );
458
- if (!rectFits(rect, width, height)) continue;
546
+ if (!rectFits(rect, width, height)) return null;
459
547
  // Water labels must sit over OPEN WATER and NEVER touch land — sample a grid
460
548
  // over every wrapped line (each line's own horizontal extent at five points);
461
549
  // drop the whole label if ANY sample hits land (Decision: optional orientation
@@ -463,12 +551,12 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
463
551
  // exempt — they belong on their country.
464
552
  if (cand.italic && overLand) {
465
553
  const inset = 2;
466
- const top = cand.cy - ((cand.lines.length - 1) / 2) * LINE_HEIGHT;
554
+ const top = cy - ((cand.lines.length - 1) / 2) * LINE_HEIGHT;
467
555
  const touchesLand = cand.lines.some((line, li) => {
468
556
  const lw = labelWidth(line, cand.letterSpacing);
469
- const x0 = cand.cx - lw / 2 + inset;
470
- const x1 = cand.cx + lw / 2 - inset;
471
- const xs = [x0, (x0 + cand.cx) / 2, cand.cx, (cand.cx + x1) / 2, x1];
557
+ const x0 = cx - lw / 2 + inset;
558
+ const x1 = cx + lw / 2 - inset;
559
+ const xs = [x0, (x0 + cx) / 2, cx, (cx + x1) / 2, x1];
472
560
  const base = top + li * LINE_HEIGHT;
473
561
  // Sample the glyph body top→baseline (text rises above the baseline) so a
474
562
  // label whose ascenders clip a coastline is rejected, not just one whose
@@ -477,15 +565,34 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
477
565
  xs.some((x) => overLand(x, y))
478
566
  );
479
567
  });
480
- if (touchesLand) continue;
568
+ if (touchesLand) return null;
569
+ }
570
+ if (collides(rect)) return null;
571
+ if (placedRects.some((r) => overlapsPadded(rect, r, CONTEXT_PAD)))
572
+ return null;
573
+ return rect;
574
+ };
575
+ for (const cand of candidates) {
576
+ if (placed.length >= budget) break;
577
+ if (cand.italic && waterPlaced >= waterCap) continue;
578
+ // Walk positions best-first; commit at the first that clears every gate. F8:
579
+ // `positions ?? [[cx,cy]]` keeps single-anchor (water + non-top-N country)
580
+ // behaviour identical. If none clears → drop (no halo, no overlap — D16).
581
+ const positions = cand.positions ?? [[cand.cx, cand.cy]];
582
+ let chosen: { x: number; y: number; rect: LabelRect } | null = null;
583
+ for (const [px, py] of positions) {
584
+ const rect = gateAt(px!, py!, cand);
585
+ if (rect) {
586
+ chosen = { x: px!, y: py!, rect };
587
+ break;
588
+ }
481
589
  }
482
- if (collides(rect)) continue;
483
- if (placedRects.some((r) => overlapsPadded(rect, r, CONTEXT_PAD))) continue;
484
- placedRects.push(rect);
590
+ if (!chosen) continue;
591
+ placedRects.push(chosen.rect);
485
592
  if (cand.italic) waterPlaced++;
486
593
  placed.push({
487
- x: cand.cx,
488
- y: cand.cy,
594
+ x: chosen.x,
595
+ y: chosen.y,
489
596
  text: cand.text,
490
597
  anchor: 'middle',
491
598
  color: cand.color,
package/src/map/geo.ts CHANGED
@@ -171,8 +171,16 @@ function pointOnRingEdge(
171
171
  }
172
172
 
173
173
  /** Point-in-polygon for a Polygon/MultiPolygon geometry (outer ring minus holes).
174
- * A point on the outer boundary counts as inside (deterministic border handling). */
175
- function pointInGeometry(geometry: unknown, lon: number, lat: number): boolean {
174
+ * A point on the outer boundary counts as inside (deterministic border handling).
175
+ * Exported so layout's context-label dodge-position generation can validate that
176
+ * a candidate interior point sits on the country's OWN rendered geometry —
177
+ * holes-aware, so enclaves and neighbours are both rejected (map-context-neighbor
178
+ * -labels D8). */
179
+ export function pointInGeometry(
180
+ geometry: unknown,
181
+ lon: number,
182
+ lat: number
183
+ ): boolean {
176
184
  const g = geometry as {
177
185
  type: string;
178
186
  coordinates: number[][][] | number[][][][];
package/src/map/layout.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  politicalTints,
26
26
  valueRampColor,
27
27
  } from '../palettes/color-utils';
28
- import { buildAdjacency, featureBboxPrimary } from './geo';
28
+ import { buildAdjacency, featureBboxPrimary, pointInGeometry } from './geo';
29
29
  import { assignColors } from './colorize';
30
30
  import { resolveColor } from '../colors';
31
31
  import type { PaletteColors } from '../palettes/types';
@@ -52,7 +52,14 @@ import type {
52
52
  ProjectionFamily,
53
53
  GeoExtent,
54
54
  } from './resolved-types';
55
- import { placeContextLabels } from './context-labels';
55
+ import {
56
+ placeContextLabels,
57
+ tierBand,
58
+ labelBudget,
59
+ MAX_COUNTRY_POSITIONS,
60
+ COUNTRY_POS_GRID,
61
+ COUNTRY_POS_TOPN_MARGIN,
62
+ } from './context-labels';
56
63
  import type { CountryCandidate } from './context-labels';
57
64
 
58
65
  // Minimal GeoJSON shapes (avoid a hard @types/geojson dep; cast at d3 calls).
@@ -316,6 +323,17 @@ export interface MapLayoutRegion {
316
323
  * area-weighted centroid stays on the body. Honours WORLD_LABEL_ANCHORS. */
317
324
  readonly labelX?: number;
318
325
  readonly labelY?: number;
326
+ /** Screen-space bounding box `[minX, minY, maxX, maxY]` of the drawn path,
327
+ * computed once in `layoutMap` (reusing the `fillAt` hit-target parse) so the
328
+ * renderer's per-POI-label region cull doesn't re-parse every path string per
329
+ * label blob. Absent only if the layout was built before this field existed —
330
+ * the renderer falls back to parsing `d`. */
331
+ bbox?: readonly [number, number, number, number];
332
+ /** Parsed screen-space rings of `d`, computed once in `layoutMap` (the same
333
+ * `fillAt` hit-target parse as `bbox`) so the renderer's coastline buffering
334
+ * doesn't re-parse every region path on every render. Absent only for layouts
335
+ * predating this field — callers fall back to `parsePathRings(d)`. */
336
+ rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
319
337
  }
320
338
 
321
339
  /** A framed inset "cutout" (albers-usa AK/HI), in screen px. The frame is a
@@ -708,6 +726,139 @@ function decodeLayer(topo: BoundaryTopology): Map<string, GeoFeature> {
708
726
  return out;
709
727
  }
710
728
 
729
+ /** Generate ordered interior label positions for a country, screen-projected and
730
+ * best-first, so its context label can DODGE a colliding data cluster onto open
731
+ * ground on its own land (map-context-neighbor-labels, Opt F). PURE +
732
+ * DETERMINISTIC — the geo work that the pure context-labels module must not do.
733
+ *
734
+ * Algorithm (D8/D9): lay a `COUNTRY_POS_GRID²` lon/lat grid over the feature's
735
+ * geographic bbox; keep cells that are (a) on the country's OWN rendered geometry
736
+ * (`pointInGeometry` — holes-aware, so neighbours AND enclaves are rejected) and
737
+ * (b) project to a finite point inside the viewport (its visible lobe). Order the
738
+ * kept cells: the most-interior cell (most on-land 8-grid-neighbours, tie-broken
739
+ * by proximity to the visible centroid) leads, then a greedy max-min spread of the
740
+ * rest so fallbacks actually dodge. A `curated` lon/lat (WORLD_LABEL_ANCHORS) is
741
+ * forced to slot 0 with grid cells filling the rest (D13).
742
+ *
743
+ * Returns `{ lonLat, screen }[]` (≤ MAX_COUNTRY_POSITIONS), or `[]` when the
744
+ * geometry yields no valid in-frame position (caller falls back to the single
745
+ * centroid anchor, D11). The `lonLat` is exposed so tests can verify on-own-land
746
+ * containment with an INDEPENDENT oracle (not a re-call of the acceptance test). */
747
+ export function countryLabelPositions(args: {
748
+ geometry: unknown;
749
+ bounds: readonly [readonly [number, number], readonly [number, number]];
750
+ project: (lon: number, lat: number) => [number, number] | null;
751
+ width: number;
752
+ height: number;
753
+ curated?: readonly [number, number] | null;
754
+ }): { lonLat: [number, number]; screen: [number, number] }[] {
755
+ const { geometry, bounds, project, width, height, curated } = args;
756
+ const w0 = bounds[0][0];
757
+ const s0 = bounds[0][1];
758
+ const e0 = bounds[1][0];
759
+ const n0 = bounds[1][1];
760
+ // Bail on non-finite, antimeridian-wrapping (e0 < w0 — NE crossers ship seam-split,
761
+ // but a feature whose own bbox still wraps falls back to the single anchor), or
762
+ // degenerate (zero-span) bboxes — the grid math needs a positive lon/lat span. `<=`
763
+ // makes the zero-span case explicit rather than relying on downstream emptiness
764
+ // (D9/D11).
765
+ if (![w0, s0, e0, n0].every(Number.isFinite) || e0 <= w0 || n0 <= s0) {
766
+ return mkCurated(curated, project);
767
+ }
768
+ const N = COUNTRY_POS_GRID;
769
+ // onLand[i][j]: cell centre is on the country's own geometry (for interiorness).
770
+ const onLand: boolean[][] = [];
771
+ type Cell = {
772
+ i: number;
773
+ j: number;
774
+ lon: number;
775
+ lat: number;
776
+ sx: number;
777
+ sy: number;
778
+ };
779
+ const kept: Cell[] = [];
780
+ for (let i = 0; i < N; i++) {
781
+ onLand[i] = [];
782
+ const lon = w0 + ((i + 0.5) / N) * (e0 - w0);
783
+ for (let j = 0; j < N; j++) {
784
+ const lat = s0 + ((j + 0.5) / N) * (n0 - s0);
785
+ const land = pointInGeometry(geometry, lon, lat);
786
+ onLand[i]![j] = land;
787
+ if (!land) continue;
788
+ const p = project(lon, lat);
789
+ if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
790
+ // Only the visible lobe: an off-frame cell can never host a fitting label.
791
+ if (p[0] < 0 || p[0] > width || p[1] < 0 || p[1] > height) continue;
792
+ kept.push({ i, j, lon, lat, sx: p[0], sy: p[1] });
793
+ }
794
+ }
795
+ if (!kept.length) return mkCurated(curated, project);
796
+ // Visible centroid (mean of kept screen points) — tie-breaks interiorness toward
797
+ // the country's in-frame mass.
798
+ const cx = kept.reduce((s, c) => s + c.sx, 0) / kept.length;
799
+ const cy = kept.reduce((s, c) => s + c.sy, 0) / kept.length;
800
+ const interiorness = (c: Cell): number => {
801
+ let n = 0;
802
+ for (let di = -1; di <= 1; di++)
803
+ for (let dj = -1; dj <= 1; dj++) {
804
+ if (di === 0 && dj === 0) continue;
805
+ const ni = c.i + di;
806
+ const nj = c.j + dj;
807
+ if (ni >= 0 && ni < N && nj >= 0 && nj < N && onLand[ni]![nj]) n++;
808
+ }
809
+ return n;
810
+ };
811
+ const dist2ToCentre = (c: Cell): number =>
812
+ (c.sx - cx) ** 2 + (c.sy - cy) ** 2;
813
+ // Most-interior cell leads (tie → nearest the visible centroid).
814
+ const pool = [...kept];
815
+ pool.sort((a, b) => {
816
+ const d = interiorness(b) - interiorness(a);
817
+ return d !== 0 ? d : dist2ToCentre(a) - dist2ToCentre(b);
818
+ });
819
+ // grid ordering: best cell, then greedy max-min spread of the rest.
820
+ const ordered: Cell[] = [pool.shift()!];
821
+ while (ordered.length < MAX_COUNTRY_POSITIONS && pool.length) {
822
+ let bestIdx = 0;
823
+ let bestMin = -1;
824
+ for (let k = 0; k < pool.length; k++) {
825
+ const c = pool[k]!;
826
+ let minD = Infinity;
827
+ for (const o of ordered) {
828
+ const d = (c.sx - o.sx) ** 2 + (c.sy - o.sy) ** 2;
829
+ if (d < minD) minD = d;
830
+ }
831
+ if (minD > bestMin) {
832
+ bestMin = minD;
833
+ bestIdx = k;
834
+ }
835
+ }
836
+ ordered.push(pool.splice(bestIdx, 1)[0]!);
837
+ }
838
+ const grid = ordered.map((c) => ({
839
+ lonLat: [c.lon, c.lat] as [number, number],
840
+ screen: [c.sx, c.sy] as [number, number],
841
+ }));
842
+ // Curated anchor (D13): forced to slot 0; grid cells fill the rest.
843
+ const curatedPos = curated
844
+ ? mkCurated(curated, project)
845
+ : ([] as { lonLat: [number, number]; screen: [number, number] }[]);
846
+ const out = [...curatedPos, ...grid].slice(0, MAX_COUNTRY_POSITIONS);
847
+ return out;
848
+ }
849
+
850
+ /** Project a single curated lon/lat into the position-list shape (or [] when it
851
+ * doesn't project finitely). Helper for `countryLabelPositions`. */
852
+ function mkCurated(
853
+ curated: readonly [number, number] | null | undefined,
854
+ project: (lon: number, lat: number) => [number, number] | null
855
+ ): { lonLat: [number, number]; screen: [number, number] }[] {
856
+ if (!curated) return [];
857
+ const p = project(curated[0], curated[1]);
858
+ if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) return [];
859
+ return [{ lonLat: [curated[0], curated[1]], screen: [p[0], p[1]] }];
860
+ }
861
+
711
862
  // Our own US map (replaces d3 geoAlbersUsa, whose fixed composite clips
712
863
  // Canada/Mexico to hard lines and bakes in inset boxes we can't control). A
713
864
  // plain Albers conic for the contiguous 48 — it does NOT clip, so neighbour land
@@ -2312,6 +2463,20 @@ export function layoutMap(
2312
2463
  if (p[1] < minY) minY = p[1];
2313
2464
  if (p[1] > maxY) maxY = p[1];
2314
2465
  }
2466
+ // Stash the bbox + parsed rings on the region so the renderer's per-POI-label
2467
+ // cull (bbox) and coastline buffering (rings) reuse this parse instead of
2468
+ // re-parsing `d` (roadmap #2/#4).
2469
+ (
2470
+ r as {
2471
+ bbox?: readonly [number, number, number, number];
2472
+ rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
2473
+ }
2474
+ ).bbox = [minX, minY, maxX, maxY];
2475
+ (
2476
+ r as {
2477
+ rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
2478
+ }
2479
+ ).rings = rings;
2315
2480
  return { fill: r.fill, rings, minX, minY, maxX, maxY };
2316
2481
  });
2317
2482
  const fillAt = (x: number, y: number): string => {
@@ -4238,6 +4403,19 @@ export function layoutMap(
4238
4403
  // work (bbox/anchor) stays here; area-rank + fit + collision live in the
4239
4404
  // pure module so the strict density invariants (AC7) are unit-testable.
4240
4405
  const countryCandidates: CountryCandidate[] = [];
4406
+ // Pass 1: collect the raw country records (feature + screen bbox/anchor),
4407
+ // carrying `f` so pass 2 can generate dodge positions from the SAME rendered
4408
+ // geometry (D14 — no re-decode).
4409
+ type RawCountry = {
4410
+ f: GeoFeature;
4411
+ iso: string;
4412
+ name: string;
4413
+ bbox: [number, number, number, number];
4414
+ anchor: [number, number] | null;
4415
+ curatedLngLat: readonly [number, number] | null;
4416
+ area: number;
4417
+ };
4418
+ const rawCountries: RawCountry[] = [];
4241
4419
  for (const f of worldLayer.values()) {
4242
4420
  const iso = typeof f.id === 'string' ? f.id : String(f.id ?? '');
4243
4421
  if (!iso || regionById.has(iso)) continue;
@@ -4259,11 +4437,87 @@ export function layoutMap(
4259
4437
  const a = anchorLngLat
4260
4438
  ? project(anchorLngLat[0], anchorLngLat[1])
4261
4439
  : (path.centroid(f as never) as [number, number]);
4262
- countryCandidates.push({
4440
+ rawCountries.push({
4441
+ f,
4442
+ iso,
4263
4443
  name: (f.properties as { name?: string } | undefined)?.name ?? iso,
4264
4444
  bbox: [x0, y0, x1, y1],
4265
4445
  anchor: a && Number.isFinite(a[0]) ? [a[0], a[1]] : null,
4266
- curatedAnchor: !!anchorLngLat,
4446
+ curatedLngLat: anchorLngLat ?? null,
4447
+ area: (x1 - x0) * (y1 - y0),
4448
+ });
4449
+ }
4450
+ // Pass 2: generate multi-position dodge candidates EAGERLY for the top
4451
+ // `budget + margin` area-ranked countries only — only that many can win a slot,
4452
+ // so generating for all ~45 in-view countries is wasted PiP work (D15). The
4453
+ // band/budget helpers live in the pure module; compute them here since layout
4454
+ // doesn't otherwise hold them. Positions[0] becomes the anchor so the
4455
+ // single-anchor `anchor === positions[0]` invariant (D12) holds.
4456
+ const cBand = tierBand(Math.max(dLonSpan, dLatSpan));
4457
+ const cBudget = labelBudget(width, height, cBand);
4458
+ // Content points = the POI markers (the diagram's story). Drive BOTH the
4459
+ // proximity rank in placeContextLabels AND which countries get dodge positions
4460
+ // generated here, so the near-action winners are equipped to dodge
4461
+ // (map-context-neighbor-labels, proximity knob). Empty ⇒ legacy area rank.
4462
+ const contentPoints: [number, number][] = markers.map((m) => [m.cx, m.cy]);
4463
+ // Generate dodge positions for the top `budget + margin` candidates ranked the
4464
+ // SAME way placeContextLabels will pick winners — by proximity to the nearest
4465
+ // content point when known, else by area. (Generating for all ~45 in-view
4466
+ // countries is wasted PiP work; D15.) The +MARGIN slack covers the rank skew
4467
+ // from the module's extra fit/viewport filtering, so the eventual winner still
4468
+ // carries dodge positions.
4469
+ const topN = cBudget + COUNTRY_POS_TOPN_MARGIN;
4470
+ const rankOrder = rawCountries
4471
+ .map((r, idx) => {
4472
+ // Match placeContextLabels' rank: distance from the NEAREST content point to
4473
+ // the country's footprint bbox (0 if inside, else nearest-edge), so the same
4474
+ // near-the-action winners get dodge positions generated.
4475
+ let dist = Infinity;
4476
+ const [x0, y0, x1, y1] = r.bbox;
4477
+ for (const [px, py] of contentPoints) {
4478
+ const dx = Math.max(x0 - px, 0, px - x1);
4479
+ const dy = Math.max(y0 - py, 0, py - y1);
4480
+ const d = dx * dx + dy * dy;
4481
+ if (d < dist) dist = d;
4482
+ }
4483
+ return { idx, area: r.area, dist };
4484
+ })
4485
+ .filter((r) => Number.isFinite(r.area) && r.area > 0)
4486
+ .sort((a, b) =>
4487
+ contentPoints.length
4488
+ ? a.dist - b.dist || b.area - a.area
4489
+ : b.area - a.area
4490
+ )
4491
+ .slice(0, topN);
4492
+ const genIdx = new Set(rankOrder.map((r) => r.idx));
4493
+ for (let i = 0; i < rawCountries.length; i++) {
4494
+ const r = rawCountries[i]!;
4495
+ let anchor = r.anchor;
4496
+ let positions: readonly (readonly [number, number])[] | undefined;
4497
+ if (genIdx.has(i) && anchor) {
4498
+ const gb = geoBounds(r.f as never) as [
4499
+ [number, number],
4500
+ [number, number],
4501
+ ];
4502
+ const gen = countryLabelPositions({
4503
+ geometry: r.f.geometry,
4504
+ bounds: gb,
4505
+ project,
4506
+ width,
4507
+ height,
4508
+ curated: r.curatedLngLat,
4509
+ });
4510
+ if (gen.length) {
4511
+ positions = gen.map((p) => p.screen);
4512
+ anchor = positions[0] as [number, number]; // D12: anchor === positions[0]
4513
+ }
4514
+ }
4515
+ countryCandidates.push({
4516
+ name: r.name,
4517
+ bbox: r.bbox,
4518
+ anchor,
4519
+ curatedAnchor: !!r.curatedLngLat,
4520
+ ...(positions ? { positions } : {}),
4267
4521
  });
4268
4522
  }
4269
4523
  // Framed US states (POI-only region framing): when the frame is snapped to a
@@ -4310,6 +4564,7 @@ export function layoutMap(
4310
4564
  palette,
4311
4565
  project,
4312
4566
  collides,
4567
+ contentPoints,
4313
4568
  // Water labels must stay over open water — `fillAt` returns the ocean
4314
4569
  // backdrop colour off-land and a region fill on-land (lakes/states count
4315
4570
  // as land here, which is the safe side for an ocean name).
package/src/map/parser.ts CHANGED
@@ -102,6 +102,8 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
102
102
  diagnostics.push(makeDgmoError(line, message, 'error', code));
103
103
  result.error ??= formatDgmoError(diagnostics[diagnostics.length - 1]!);
104
104
  };
105
+ // Bespoke (not the shared makeFail, Story 111.4): map delegates to pushError,
106
+ // which is first-error-wins (`??=`) and carries an optional diagnostic code.
105
107
  const fail = (line: number, message: string): ParsedMap => {
106
108
  pushError(line, message);
107
109
  return result;