@diagrammo/dgmo 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +5651 -3193
  3. package/dist/advanced.d.cts +272 -58
  4. package/dist/advanced.d.ts +272 -58
  5. package/dist/advanced.js +5650 -3186
  6. package/dist/auto.cjs +5511 -3070
  7. package/dist/auto.js +116 -137
  8. package/dist/auto.mjs +5510 -3069
  9. package/dist/cli.cjs +168 -189
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +5536 -3072
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +5535 -3071
  18. package/dist/internal.cjs +5651 -3193
  19. package/dist/internal.d.cts +272 -58
  20. package/dist/internal.d.ts +272 -58
  21. package/dist/internal.js +5650 -3186
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +7 -3
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout-layered.ts +722 -0
  36. package/src/boxes-and-lines/layout-search.ts +1200 -0
  37. package/src/boxes-and-lines/layout.ts +202 -571
  38. package/src/boxes-and-lines/parser.ts +43 -8
  39. package/src/boxes-and-lines/renderer.ts +223 -96
  40. package/src/boxes-and-lines/types.ts +9 -2
  41. package/src/c4/layout.ts +14 -32
  42. package/src/c4/parser.ts +9 -5
  43. package/src/c4/renderer.ts +34 -39
  44. package/src/class/layout.ts +118 -18
  45. package/src/class/parser.ts +35 -0
  46. package/src/class/renderer.ts +58 -2
  47. package/src/class/types.ts +3 -0
  48. package/src/cli.ts +4 -4
  49. package/src/completion.ts +26 -12
  50. package/src/cycle/layout.ts +55 -72
  51. package/src/cycle/renderer.ts +11 -6
  52. package/src/d3.ts +78 -117
  53. package/src/diagnostics.ts +16 -0
  54. package/src/echarts.ts +46 -33
  55. package/src/editor/keywords.ts +4 -0
  56. package/src/er/layout.ts +114 -22
  57. package/src/er/parser.ts +28 -0
  58. package/src/er/renderer.ts +55 -2
  59. package/src/er/types.ts +3 -0
  60. package/src/gantt/renderer.ts +46 -38
  61. package/src/gantt/resolver.ts +9 -2
  62. package/src/graph/edge-spline.ts +29 -0
  63. package/src/graph/flowchart-parser.ts +34 -1
  64. package/src/graph/flowchart-renderer.ts +78 -64
  65. package/src/graph/layout.ts +206 -23
  66. package/src/graph/notes.ts +21 -0
  67. package/src/graph/state-parser.ts +26 -1
  68. package/src/graph/state-renderer.ts +78 -64
  69. package/src/graph/types.ts +13 -0
  70. package/src/index.ts +1 -1
  71. package/src/infra/layout.ts +46 -26
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +101 -25
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1212 -96
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/renderer.ts +30 -43
  101. package/src/pyramid/renderer.ts +4 -5
  102. package/src/raci/renderer.ts +34 -68
  103. package/src/render.ts +1 -1
  104. package/src/ring/renderer.ts +1 -2
  105. package/src/sequence/parser.ts +100 -22
  106. package/src/sequence/renderer.ts +75 -50
  107. package/src/sitemap/layout.ts +27 -19
  108. package/src/sitemap/renderer.ts +12 -5
  109. package/src/tech-radar/renderer.ts +11 -35
  110. package/src/utils/arrow-markers.ts +51 -0
  111. package/src/utils/fit-canvas.ts +64 -0
  112. package/src/utils/legend-constants.ts +8 -54
  113. package/src/utils/legend-d3.ts +10 -7
  114. package/src/utils/legend-layout.ts +7 -4
  115. package/src/utils/legend-types.ts +10 -4
  116. package/src/utils/note-box/constants.ts +25 -0
  117. package/src/utils/note-box/index.ts +11 -0
  118. package/src/utils/note-box/metrics.ts +90 -0
  119. package/src/utils/note-box/svg.ts +331 -0
  120. package/src/utils/notes/bounds.ts +30 -0
  121. package/src/utils/notes/build.ts +131 -0
  122. package/src/utils/notes/index.ts +18 -0
  123. package/src/utils/notes/model.ts +19 -0
  124. package/src/utils/notes/parse.ts +131 -0
  125. package/src/utils/notes/place.ts +177 -0
  126. package/src/utils/notes/resolve.ts +88 -0
  127. package/src/utils/number-format.ts +36 -0
  128. package/src/utils/parsing.ts +41 -0
  129. package/src/utils/reserved-key-registry.ts +4 -0
  130. package/src/utils/text-measure.ts +122 -0
  131. package/src/wireframe/layout.ts +4 -2
  132. package/src/wireframe/renderer.ts +8 -6
  133. package/src/palettes/dracula.ts +0 -68
  134. package/src/palettes/gruvbox.ts +0 -85
  135. package/src/palettes/monokai.ts +0 -68
  136. package/src/palettes/one-dark.ts +0 -70
  137. package/src/palettes/rose-pine.ts +0 -84
  138. package/src/palettes/solarized.ts +0 -77
@@ -7,6 +7,7 @@ import { parseJourneyMap } from './parser';
7
7
  import {
8
8
  layoutJourneyMap,
9
9
  scoreToColor,
10
+ scoreToCurveY,
10
11
  TAG_STRIP_HEIGHT,
11
12
  type CurvePoint,
12
13
  type StepLayout,
@@ -21,6 +22,11 @@ import { renderLegendD3 } from '../utils/legend-d3';
21
22
  import type { LegendConfig, LegendState } from '../utils/legend-types';
22
23
  import { resolveActiveTagGroup } from '../utils/tag-groups';
23
24
  import { ScaleContext } from '../utils/scaling';
25
+ import {
26
+ measureText,
27
+ wrapTextToWidth,
28
+ truncateText as truncateToWidth,
29
+ } from '../utils/text-measure';
24
30
 
25
31
  // ============================================================
26
32
  // Interactive Options
@@ -66,7 +72,6 @@ const CURVE_STROKE_WIDTH = 2.5;
66
72
  const FACE_RADIUS = 14;
67
73
  const DIM_HOVER = 0.25;
68
74
  const TITLE_LINE_HEIGHT = 16;
69
- const TITLE_CHAR_WIDTH = 6.5;
70
75
 
71
76
  // ============================================================
72
77
  // Renderer
@@ -415,10 +420,7 @@ export function renderJourneyMap(
415
420
 
416
421
  // Grid lines at score levels 1-5
417
422
  for (let score = 1; score <= 5; score++) {
418
- const y =
419
- layout.curveAreaBottom -
420
- ((score - 1) / 4) * (layout.curveAreaBottom - layout.curveAreaTop - 120) -
421
- 10;
423
+ const y = scoreToCurveY(score, layout.curveAreaBottom);
422
424
 
423
425
  curveG
424
426
  .append('line')
@@ -621,7 +623,11 @@ export function renderJourneyMap(
621
623
  .attr('fill', onHeaderText)
622
624
  .text(
623
625
  isCollapsed
624
- ? truncateText(pl.phase.name, pl.width - COLUMN_PADDING * 2)
626
+ ? truncateText(
627
+ pl.phase.name,
628
+ pl.width - COLUMN_PADDING * 2,
629
+ FONT_SIZE_PHASE
630
+ )
625
631
  : pl.phase.name
626
632
  );
627
633
 
@@ -728,7 +734,7 @@ export function renderJourneyMap(
728
734
  .attr('y', itemY + COLLAPSED_CARD_H / 2 + FONT_SIZE_META / 2 - 1)
729
735
  .attr('font-size', FONT_SIZE_META)
730
736
  .attr('fill', palette.text)
731
- .text(truncateText(step.title, maxTextW));
737
+ .text(truncateText(step.title, maxTextW, FONT_SIZE_META));
732
738
 
733
739
  if (onNavigateToLine) {
734
740
  itemG.style('cursor', 'pointer').on('click', (event: Event) => {
@@ -893,7 +899,7 @@ export function renderJourneyMap(
893
899
  const lines = wrapText(thoughtText, THOUGHT_MAX_W, THOUGHT_FONT);
894
900
  const textW = Math.min(
895
901
  THOUGHT_MAX_W,
896
- Math.max(...lines.map((l) => l.length * THOUGHT_FONT * 0.6))
902
+ Math.max(...lines.map((l) => measureText(l, THOUGHT_FONT)))
897
903
  );
898
904
  const bw = textW + THOUGHT_PAD_X * 2;
899
905
  const bh = lines.length * THOUGHT_LINE_H + THOUGHT_PAD_Y * 2;
@@ -1170,20 +1176,7 @@ function renderStepCard(
1170
1176
 
1171
1177
  // Title (wrapped)
1172
1178
  const titleMaxW = sl.width - CARD_PADDING_X * 2;
1173
- const titleMaxChars = Math.max(1, Math.floor(titleMaxW / TITLE_CHAR_WIDTH));
1174
- const titleWords = sl.step.title.split(/\s+/);
1175
- const titleLines: string[] = [];
1176
- let titleCur = '';
1177
- for (const w of titleWords) {
1178
- const candidate = titleCur ? `${titleCur} ${w}` : w;
1179
- if (candidate.length > titleMaxChars && titleCur) {
1180
- titleLines.push(titleCur);
1181
- titleCur = w;
1182
- } else {
1183
- titleCur = candidate;
1184
- }
1185
- }
1186
- if (titleCur) titleLines.push(titleCur);
1179
+ const titleLines = wrapTextToWidth(sl.step.title, FONT_SIZE_STEP, titleMaxW);
1187
1180
 
1188
1181
  for (let i = 0; i < titleLines.length; i++) {
1189
1182
  stepG
@@ -1459,31 +1452,15 @@ function renderScoreFace(
1459
1452
  }
1460
1453
 
1461
1454
  function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
1462
- const charWidth = fontSize * 0.6;
1463
- const maxChars = Math.floor(maxWidth / charWidth);
1464
- if (maxChars <= 0) return [text];
1465
-
1466
- const words = text.split(/\s+/);
1467
- const lines: string[] = [];
1468
- let current = '';
1469
-
1470
- for (const word of words) {
1471
- const candidate = current ? `${current} ${word}` : word;
1472
- if (candidate.length > maxChars && current) {
1473
- lines.push(current);
1474
- current = word;
1475
- } else {
1476
- current = candidate;
1477
- }
1478
- }
1479
- if (current) lines.push(current);
1480
- return lines;
1455
+ return wrapTextToWidth(text, fontSize, maxWidth);
1481
1456
  }
1482
1457
 
1483
- function truncateText(text: string, maxWidth: number): string {
1484
- const maxChars = Math.floor(maxWidth / 6.6);
1485
- if (text.length <= maxChars) return text;
1486
- return text.substring(0, maxChars - 1) + '\u2026';
1458
+ function truncateText(
1459
+ text: string,
1460
+ maxWidth: number,
1461
+ fontSize: number
1462
+ ): string {
1463
+ return truncateToWidth(text, fontSize, maxWidth);
1487
1464
  }
1488
1465
 
1489
1466
  function annotationColor(
@@ -25,6 +25,7 @@ import type {
25
25
  D3Sel,
26
26
  } from '../utils/legend-types';
27
27
  import { ScaleContext } from '../utils/scaling';
28
+ import { measureText } from '../utils/text-measure';
28
29
 
29
30
  // ============================================================
30
31
  // Public options object
@@ -163,7 +164,6 @@ function computeLayout(
163
164
  const headerHeight = showTitle ? sTitleHeight + 8 : 0;
164
165
  const startY = sDiagramPadding + headerHeight;
165
166
 
166
- const charWidth = sCardTitleFontSize * 0.6;
167
167
  const columnLayouts: ColumnLayout[] = [];
168
168
 
169
169
  let maxColumnHeight = 0;
@@ -185,13 +185,13 @@ function computeLayout(
185
185
  continue;
186
186
  }
187
187
 
188
- let maxCardTextWidth = col.name.length * (sColumnHeaderFontSize * 0.65);
188
+ let maxCardTextWidth = measureText(col.name, sColumnHeaderFontSize);
189
189
 
190
190
  const cardLayouts: CardLayout[] = [];
191
191
  let cardY = sColumnHeaderHeight + sColumnPadding;
192
192
 
193
193
  for (const card of col.cards) {
194
- const titleWidth = card.title.length * charWidth;
194
+ const titleWidth = measureText(card.title, sCardTitleFontSize);
195
195
  maxCardTextWidth = Math.max(
196
196
  maxCardTextWidth,
197
197
  titleWidth + sCardPaddingX * 2
@@ -214,7 +214,7 @@ function computeLayout(
214
214
 
215
215
  for (const m of tagMeta) {
216
216
  const metaW =
217
- (m.label.length + 2 + m.value.length) * sCardMetaFontSize * 0.6 +
217
+ measureText(`${m.label}: ${m.value}`, sCardMetaFontSize) +
218
218
  sCardPaddingX * 2;
219
219
  maxCardTextWidth = Math.max(maxCardTextWidth, metaW);
220
220
  }
@@ -697,7 +697,7 @@ export function renderKanban(
697
697
  .attr('fill', onCardText)
698
698
  .text(`${meta.label}: `);
699
699
 
700
- const labelWidth = (meta.label.length + 2) * sCardMetaFontSize * 0.6;
700
+ const labelWidth = measureText(`${meta.label}: `, sCardMetaFontSize);
701
701
  cg.append('text')
702
702
  .attr('x', cx + sCardPaddingX + labelWidth)
703
703
  .attr('y', metaY)
@@ -768,6 +768,15 @@ function drawSwimlaneIcon(
768
768
  .attr('class', 'kanban-swimlane-icon')
769
769
  .attr('transform', `translate(${x}, ${y})`);
770
770
 
771
+ // Transparent hit area so the whole icon (not just the 2px bars) is clickable
772
+ iconG
773
+ .append('rect')
774
+ .attr('x', -5)
775
+ .attr('y', -5)
776
+ .attr('width', 22)
777
+ .attr('height', 18)
778
+ .attr('fill', 'transparent');
779
+
771
780
  const color = isActive ? palette.primary : palette.textMuted;
772
781
  const opacity = isActive ? 1 : 0.35;
773
782
  const barWidths = [8, 12, 6];
@@ -1413,7 +1422,7 @@ function renderSwimlaneCard(
1413
1422
  .attr('font-size', sCardMetaFontSize)
1414
1423
  .attr('fill', palette.textMuted)
1415
1424
  .text(`${meta.label}: `);
1416
- const labelWidth = (meta.label.length + 2) * sCardMetaFontSize * 0.6;
1425
+ const labelWidth = measureText(`${meta.label}: `, sCardMetaFontSize);
1417
1426
  cg.append('text')
1418
1427
  .attr('x', cx + sCardPaddingX + labelWidth)
1419
1428
  .attr('y', metaY)
@@ -2,6 +2,8 @@
2
2
  // Shared label collision detection and placement utilities
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
+ import { measureText } from './utils/text-measure';
6
+
5
7
  export interface LabelRect {
6
8
  x: number;
7
9
  y: number;
@@ -74,8 +76,6 @@ export function segmentRectOverlap(
74
76
  // Quadrant chart point label placement
75
77
  // ---------------------------------------------------------------------------
76
78
 
77
- const CHAR_WIDTH_RATIO = 0.6;
78
-
79
79
  export interface QuadrantLabelPoint {
80
80
  label: string;
81
81
  cx: number; // pixel x
@@ -120,7 +120,7 @@ export function computeQuadrantPointLabels(
120
120
 
121
121
  for (let i = 0; i < points.length; i++) {
122
122
  const pt = points[i]!; // In-bounds by loop guard.
123
- const labelWidth = pt.label.length * fontSize * CHAR_WIDTH_RATIO + 8;
123
+ const labelWidth = measureText(pt.label, fontSize) + 8;
124
124
 
125
125
  // Try 4 directions: above, below, left, right
126
126
  // Each direction generates candidate (labelX, labelY, anchor)
@@ -8,7 +8,7 @@
8
8
  // ./geo) so this module — exported from the main index — stays free of the
9
9
  // d3-geo / topojson imports that geo.ts pulls in. Keep it byte-identical to the
10
10
  // resolver/geo folding so matches agree.
11
- import type { Gazetteer, RegionName } from './data/types';
11
+ import type { Gazetteer, RegionName, AirportData } from './data/types';
12
12
 
13
13
  const fold = (s: string): string =>
14
14
  s
@@ -34,11 +34,24 @@ export interface MapPlaceCompletion {
34
34
  readonly iso: string;
35
35
  readonly sub?: string;
36
36
  readonly pop: number;
37
+ /** `'airport'` for IATA-code entries (icon/grouping affordance); absent or
38
+ * `'city'` for gazetteer cities. Cities rank above airports for a shared
39
+ * prefix so ~1500 codes never bury city names (ADR-5). */
40
+ readonly kind?: 'city' | 'airport';
37
41
  }
38
42
 
39
43
  export interface MapCompletionOptions {
40
44
  /** Max results (default 12). */
41
45
  readonly limit?: number;
46
+ /** IATA-coded airports (`airports.json`). When supplied, airport codes
47
+ * matching the prefix are offered as a second (post-city) group. Optional —
48
+ * absent (old DI bundles / no asset) just yields city-only completions. */
49
+ readonly airports?: AirportData;
50
+ /** Resolver-inferred map scope (country `US` or subdivision `US-CA`). Biases
51
+ * airport ranking so in-region airports sort above out-of-region same-prefix
52
+ * ones (ADR-6). Pure rank, never a filter — cross-region airports still
53
+ * appear. App passes the document's inferred scope in (Slice 2). */
54
+ readonly scopeISO?: string;
42
55
  }
43
56
 
44
57
  /**
@@ -65,35 +78,77 @@ export function completeMapPlaces(
65
78
  for (const [key, idx] of Object.entries(gazetteer.alt)) {
66
79
  if (key.startsWith(q)) matched.add(idx);
67
80
  }
68
- if (matched.size === 0) return [];
69
81
 
70
- const ranked = [...matched]
82
+ // Airport prefix matches (optional asset; guarded for absent bundles, F5/#8).
83
+ // Folded IATA code → index into `airports`. Scope-match (current map's country)
84
+ // then code-alpha — NOT pop (always 0) and NOT type (the tuple has no type).
85
+ const airports = opts?.airports;
86
+ const scopeCountry = opts?.scopeISO ? opts.scopeISO.slice(0, 2) : undefined;
87
+ const airportHits: Array<{ code: string; name: string; iso: string }> = [];
88
+ if (airports?.airportIata) {
89
+ for (const [key, idx] of Object.entries(airports.airportIata)) {
90
+ if (!key.startsWith(q)) continue;
91
+ const a = airports.airports[idx];
92
+ if (a) airportHits.push({ code: key, name: a[4], iso: a[2] });
93
+ }
94
+ }
95
+
96
+ // Relaxed early-return: only when BOTH groups are empty (an airport-only prefix
97
+ // must still complete — the old city-only `matched.size === 0` blocked it).
98
+ if (matched.size === 0 && airportHits.length === 0) return [];
99
+
100
+ // Cities first (pop desc), then airports — cities fill the limit slots first.
101
+ // Slice the sorted index list to `limit` BEFORE building items (a short prefix
102
+ // matches thousands of cities; mapping them all per keystroke is wasted work —
103
+ // airports are appended after, so they never starve cities).
104
+ const cityItems: MapPlaceCompletion[] = [...matched]
71
105
  .filter((i) => gazetteer.cities[i] !== undefined)
72
106
  .sort((a, b) => {
73
107
  const pa = gazetteer.cities[a]![3];
74
108
  const pb = gazetteer.cities[b]![3];
75
109
  return pb - pa || a - b; // pop desc, then deterministic index
76
110
  })
77
- .slice(0, limit);
111
+ .slice(0, limit)
112
+ .map((i) => {
113
+ const c = gazetteer.cities[i]!;
114
+ const [, , iso, pop, name, sub] = c;
115
+ const ambiguous = (gazetteer.byName[fold(name)]?.length ?? 0) > 1;
116
+ const qualifier = sub ?? iso;
117
+ const insert = ambiguous ? `${name} ${qualifier}` : name;
118
+ const label = ambiguous ? `${name} — ${qualifier}` : name;
119
+ const detail = `${qualifier} · ${groupThousands(pop)}`;
120
+ return {
121
+ name,
122
+ insert,
123
+ label,
124
+ detail,
125
+ iso,
126
+ pop,
127
+ kind: 'city' as const,
128
+ ...(sub !== undefined && { sub }),
129
+ };
130
+ });
131
+
132
+ const airportItems: MapPlaceCompletion[] = airportHits
133
+ .sort((a, b) => {
134
+ const sa = scopeCountry && a.iso === scopeCountry ? 0 : 1;
135
+ const sb = scopeCountry && b.iso === scopeCountry ? 0 : 1;
136
+ return sa - sb || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
137
+ })
138
+ .map(({ code, name, iso }) => {
139
+ const upper = code.toUpperCase();
140
+ return {
141
+ name,
142
+ insert: upper,
143
+ label: upper,
144
+ detail: `Airport · ${name}`,
145
+ iso,
146
+ pop: 0,
147
+ kind: 'airport' as const,
148
+ };
149
+ });
78
150
 
79
- return ranked.map((i) => {
80
- const c = gazetteer.cities[i]!;
81
- const [, , iso, pop, name, sub] = c;
82
- const ambiguous = (gazetteer.byName[fold(name)]?.length ?? 0) > 1;
83
- const qualifier = sub ?? iso;
84
- const insert = ambiguous ? `${name} ${qualifier}` : name;
85
- const label = ambiguous ? `${name} — ${qualifier}` : name;
86
- const detail = `${qualifier} · ${groupThousands(pop)}`;
87
- return {
88
- name,
89
- insert,
90
- label,
91
- detail,
92
- iso,
93
- pop,
94
- ...(sub !== undefined && { sub }),
95
- };
96
- });
151
+ return [...cityItems, ...airportItems].slice(0, limit);
97
152
  }
98
153
 
99
154
  export interface MapRegionCompletion {
@@ -29,6 +29,11 @@ export interface CountryCandidate {
29
29
  /** Projected screen anchor `[x, y]` (mainland anchor or `path.centroid`), or
30
30
  * null when the feature doesn't project to a finite point. */
31
31
  readonly anchor: readonly [number, number] | null;
32
+ /** True when `anchor` came from a curated WORLD_LABEL_ANCHORS entry (a trusted
33
+ * mainland point) rather than the area-weighted centroid. Such a country
34
+ * bypasses the both-axes smear gate (its full-canvas bbox is an antimeridian
35
+ * artifact, not a real footprint, so the unreliable-centroid concern is moot). */
36
+ readonly curatedAnchor?: boolean;
32
37
  }
33
38
 
34
39
  export interface ContextLabelArgs {
@@ -60,6 +65,17 @@ const CONTEXT_PAD = 4; // extra gap enforced between two context labels
60
65
  const EDGE_CLAMP_MARGIN = 8; // px inset for edge-clamped ocean labels
61
66
  const EDGE_CLAMP_OVERSHOOT = 0.35; // max off-frame overshoot (× dim) to still clamp
62
67
 
68
+ // Country labels scale with their projected footprint: a big landmass (Canada,
69
+ // Mexico on a US view) reads as a large, FADED backdrop name; a small one stays
70
+ // at the base font, fully muted. Size metric is the footprint's linear extent
71
+ // (√bbox-area) as a fraction of the canvas's linear extent (√canvas-area), so
72
+ // the ramp is resolution-independent. Below MIN ⇒ base font / no extra fade;
73
+ // at/above MAX ⇒ max font / max fade; linear between.
74
+ const COUNTRY_FONT_MAX = 22; // px ceiling for the largest footprint
75
+ const COUNTRY_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
76
+ const COUNTRY_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
77
+ const COUNTRY_FADE_MAX = 45; // % blend toward bg at max font (subdue big names)
78
+
63
79
  // Water-kind priority within a tier (oceans first, then seas, then the rest) so
64
80
  // a thin budget always spends on the highest-orientation-value names.
65
81
  const KIND_ORDER: Record<WaterKind, number> = {
@@ -91,10 +107,10 @@ export function labelBudget(
91
107
  band: TierBand
92
108
  ): number {
93
109
  const bandCap: Record<TierBand, number> = {
94
- world: 6,
95
- continental: 5,
96
- regional: 4,
97
- local: 3,
110
+ world: 7,
111
+ continental: 6,
112
+ regional: 5,
113
+ local: 4,
98
114
  };
99
115
  const area = Math.floor(Math.sqrt(Math.max(0, width * height)) / 150);
100
116
  return Math.max(0, Math.min(area, bandCap[band]));
@@ -136,10 +152,14 @@ function insideViewport(
136
152
  * the per-gap `letter-spacing` the renderer applies to water names, so without
137
153
  * this the fit/clamp math under-measures by ~`(len-1)*spacing` and the label
138
154
  * clips at the canvas edge. */
139
- export function labelWidth(text: string, letterSpacing: number): number {
155
+ export function labelWidth(
156
+ text: string,
157
+ letterSpacing: number,
158
+ font: number = FONT
159
+ ): number {
140
160
  const spacing =
141
161
  letterSpacing > 0 ? Math.max(0, text.length - 1) * letterSpacing : 0;
142
- return measureLegendText(text, FONT) + spacing + 2 * PADX;
162
+ return measureLegendText(text, font) + spacing + 2 * PADX;
143
163
  }
144
164
 
145
165
  /** Wrap a multi-word name into balanced lines, biased to wrap READILY — water
@@ -188,10 +208,12 @@ function rectAround(
188
208
  cx: number,
189
209
  cy: number,
190
210
  lines: readonly string[],
191
- letterSpacing: number
211
+ letterSpacing: number,
212
+ font: number = FONT
192
213
  ): LabelRect {
193
- const w = Math.max(...lines.map((l) => labelWidth(l, letterSpacing)));
194
- const h = (lines.length - 1) * LINE_HEIGHT + FONT + 2 * PADY;
214
+ const lineHeight = font + 2; // MUST match the renderer's per-line stride
215
+ const w = Math.max(...lines.map((l) => labelWidth(l, letterSpacing, font)));
216
+ const h = (lines.length - 1) * lineHeight + font + 2 * PADY;
195
217
  return { x: cx - w / 2, y: cy - h / 2, w, h };
196
218
  }
197
219
 
@@ -252,6 +274,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
252
274
  italic: boolean;
253
275
  letterSpacing: number;
254
276
  color: string;
277
+ fontSize: number;
255
278
  sort: number; // priority key (lower first)
256
279
  };
257
280
  const candidates: Candidate[] = [];
@@ -328,8 +351,12 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
328
351
  italic: true,
329
352
  letterSpacing: WATER_LETTER_SPACING,
330
353
  color: waterColor,
331
- // Water before any country (×1000), then by tier, then kind, then name.
332
- sort: tier * 10 + KIND_ORDER[kind],
354
+ fontSize: FONT, // water names keep the base font (no footprint to scale on)
355
+ // Orientation-value bands (lower = earlier): MAJOR water (oceans + major
356
+ // seas, tier ≤ 1) leads at `tier*10+kind` (0..~16); MINOR water (tier ≥ 2:
357
+ // smaller seas, bays, gulfs, straits) drops to band 2 (2000+) — BELOW the
358
+ // country band (1000+), so a big country outranks a minor basin.
359
+ sort: (tier <= 1 ? 0 : 2000) + tier * 10 + KIND_ORDER[kind],
333
360
  });
334
361
  }
335
362
 
@@ -345,22 +372,49 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
345
372
  })
346
373
  .filter((r) => Number.isFinite(r.area) && r.area > 0)
347
374
  .sort((a, b) => b.area - a.area);
375
+ // Canvas linear extent — the denominator for the footprint size ramp below.
376
+ const canvasLinear = Math.sqrt(Math.max(1, width * height));
348
377
  let ci = 0;
349
378
  for (const r of ranked) {
350
- const { c, w, h } = r;
351
- // F2: an antimeridian-crossing / global-smear country yields a near-full-
352
- // canvas bbox while its real landmass is split — the `path.centroid` anchor
353
- // is then unreliable (mid-map, wrong basin). Drop such over-wide candidates
354
- // rather than spend a top-priority slot on a mispositioned name.
355
- if (w > width * 0.66 || h > height * 0.66) continue;
379
+ const { c, w, h, area } = r;
380
+ // F2: an antimeridian-crossing / global-smear country fills the canvas in
381
+ // BOTH dimensions while its real landmass is split — the `path.centroid`
382
+ // anchor is then unreliable (mid-map, wrong basin). Reject only that
383
+ // both-axes canvas-smear: a country that is merely wide-but-short (Canada
384
+ // hugging the top of a US frame) or tall-but-narrow (Chile) is a legitimate
385
+ // landmass whose clipExtent-clipped centroid still lands over its own ground,
386
+ // so it must NOT be dropped for footprint shape alone. A `curatedAnchor`
387
+ // (WORLD_LABEL_ANCHORS, e.g. Russia → European Russia) is trustworthy by
388
+ // construction — the smear concern is moot, so it bypasses this gate (the
389
+ // `insideViewport` check below still drops an anchor that projects off-frame).
390
+ if (!c.curatedAnchor && w > width * 0.66 && h > height * 0.66) continue;
356
391
  if (!insideViewport(c.anchor, width, height)) continue;
392
+ // Footprint-driven scale (Decision: big landmass = large, faded backdrop
393
+ // name). t∈[0,1] over the [MIN,MAX] linear-fraction band; font ramps up and
394
+ // ink rises in lockstep, so a bigger name reads as a large, softly-inked
395
+ // backdrop. A curated-anchor giant (Russia) keeps the standard big-country
396
+ // styling — its full-canvas bbox lands at the top of the ramp (font/ink like
397
+ // any large in-frame country), which is the intended subdued-backdrop look.
398
+ const sizeFrac = Math.sqrt(area) / canvasLinear;
399
+ const t = Math.min(
400
+ 1,
401
+ Math.max(
402
+ 0,
403
+ (sizeFrac - COUNTRY_SIZE_FRAC_MIN) /
404
+ (COUNTRY_SIZE_FRAC_MAX - COUNTRY_SIZE_FRAC_MIN)
405
+ )
406
+ );
407
+ const fontSize = Math.round(FONT + t * (COUNTRY_FONT_MAX - FONT));
408
+ const fade = Math.round(t * COUNTRY_FADE_MAX);
409
+ const color = fade > 0 ? mix(countryColor, palette.bg, fade) : countryColor;
357
410
  // Always the full country name — never an ISO abbreviation. If the name
358
411
  // doesn't fit the footprint, drop the label rather than abbreviate.
359
412
  const text = c.name;
360
- const tw = labelWidth(text, 0);
361
- // Approximate fit (Decision 4): name fits inside the footprint bbox. NOT
362
- // true point-in-polygon — cartographic labels routinely overrun coastlines.
363
- if (tw > w || FONT + 2 * PADY > h) continue;
413
+ const tw = labelWidth(text, 0, fontSize);
414
+ // Approximate fit (Decision 4): the (scaled) name fits inside the footprint
415
+ // bbox. NOT true point-in-polygon — cartographic labels routinely overrun
416
+ // coastlines. The bigger the font, the bigger the box it must clear.
417
+ if (tw > w || fontSize + 2 * PADY > h) continue;
364
418
  candidates.push({
365
419
  text,
366
420
  lines: [text],
@@ -368,9 +422,14 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
368
422
  cy: c.anchor[1],
369
423
  italic: false,
370
424
  letterSpacing: 0,
371
- color: countryColor,
372
- // Always after every water body (+1e6); larger area = earlier.
373
- sort: 1_000_000 + ci++,
425
+ color,
426
+ fontSize,
427
+ // Band 1 (orientation-value ranking): above MINOR water (band 2, 2000+) but
428
+ // below MAJOR water — oceans + major seas (band 0, ≤~16). So a big country
429
+ // (US, Canada, Russia) outranks a minor sea/bay (Sargasso, Bahía de
430
+ // Campeche) yet never displaces an ocean. Larger area = earlier within the
431
+ // band (`ci` is the area-desc rank index).
432
+ sort: 1000 + ci++,
374
433
  });
375
434
  }
376
435
 
@@ -378,9 +437,24 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
378
437
  candidates.sort((a, b) => a.sort - b.sort);
379
438
  const placed: PlacedLabel[] = [];
380
439
  const placedRects: LabelRect[] = [];
440
+ // Guarantee country/state room: water can otherwise monopolise a small budget
441
+ // (a coastal view borders many oceans/seas), so reserve up to 2 slots for
442
+ // countries whenever any country candidate exists. No effect on pure-water
443
+ // views (`countryCount` 0 ⇒ cap = budget). Major water still leads by sort, so
444
+ // this only trims the LAST water bodies that would have crowded out a country.
445
+ const countryCount = candidates.reduce((n, c) => n + (c.italic ? 0 : 1), 0);
446
+ const waterCap = budget - Math.min(2, countryCount);
447
+ let waterPlaced = 0;
381
448
  for (const cand of candidates) {
382
449
  if (placed.length >= budget) break;
383
- const rect = rectAround(cand.cx, cand.cy, cand.lines, cand.letterSpacing);
450
+ if (cand.italic && waterPlaced >= waterCap) continue;
451
+ const rect = rectAround(
452
+ cand.cx,
453
+ cand.cy,
454
+ cand.lines,
455
+ cand.letterSpacing,
456
+ cand.fontSize
457
+ );
384
458
  if (!rectFits(rect, width, height)) continue;
385
459
  // Water labels must sit over OPEN WATER and NEVER touch land — sample a grid
386
460
  // over every wrapped line (each line's own horizontal extent at five points);
@@ -408,6 +482,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
408
482
  if (collides(rect)) continue;
409
483
  if (placedRects.some((r) => overlapsPadded(rect, r, CONTEXT_PAD))) continue;
410
484
  placedRects.push(rect);
485
+ if (cand.italic) waterPlaced++;
411
486
  placed.push({
412
487
  x: cand.cx,
413
488
  y: cand.cy,
@@ -419,6 +494,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
419
494
  // cleanly on the basemap without one.
420
495
  halo: false,
421
496
  haloColor,
497
+ fontSize: cand.fontSize,
422
498
  italic: cand.italic,
423
499
  letterSpacing: cand.letterSpacing,
424
500
  ...(cand.lines.length > 1 ? { lines: cand.lines } : {}),
@@ -1 +1 @@
1
- {"assets":{"gazetteer.json":{"bytes":130767,"gzBytes":56261,"sha256":"5ad56e5ba0b3a4f9a6dc8bd3bf8b0fda0e7b86cbe4d85d231114f5dd967d65f7"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":90845,"gzBytes":26493,"sha256":"a698b3f296e61712fb39b3d8d42ec7c4699f8aadecb549367feb7d09f7785580"},"na-lakes.json":{"bytes":39387,"gzBytes":11281,"sha256":"2a41c04969209380d544a09efe354277e12d704458af95955201eb4f698d16c6"},"na-land.json":{"bytes":114082,"gzBytes":32375,"sha256":"7b94c9bb4e809c22813da5ae939e1ff6a781fd77a04d9c1585a9a82d2a195388"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"water-bodies.json":{"bytes":4854,"gzBytes":2123,"sha256":"6d1a407a376c63518329c52189e2887053c4b61062af0597e060050ae8469635"},"world-coarse.json":{"bytes":55436,"gzBytes":18397,"sha256":"5cb42e3c8975dde56504ca5c68ece0a1e71d0929680b5fc8cdab758c8666dbf8"},"world-detail.json":{"bytes":163562,"gzBytes":46767,"sha256":"39f1736eaabe9e21190972be3157822be22ee84fdc41751237f2b516f09a7586"}},"counts":{"countries":175,"gazetteerAliases":8,"gazetteerCities":2119,"mountainRanges":205,"usStates":56,"waterBodies":113},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json":{"bytes":6648697,"sha256":"93c8fdf0e591e113f449d0d466e15c7a9841b9b6571c7afe41f95ba51b322452"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson":{"bytes":13287234,"sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson":{"bytes":5583870,"sha256":"b7b26e50ea917d3696aec87f932def2bf5f890f5770e441d59c162c6f4c92a77"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson":{"bytes":534055,"sha256":"b9c3f7f557d0ff5217906adc82b66ecdac14aa7438df7e518cf6675d037bceb8"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson":{"bytes":1163418,"sha256":"6fe58083e0cc5c7fad9e396970e28a8580bbd8770cfa4d1d7b5a34423e912f97"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5549002,"sha256":"d20e28b2f610da34c21fd82ff6a8e4d24ebe67eba2dccf65bd2c4332ff0f380a"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-06-02 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"marineCoarse":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson","version":"natural-earth 110m (nvkelso vector snapshot)"},"marineDetail":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"mountainRanges":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"naLakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json","version":"natural-earth 10m (martynafford snapshot)"},"naLand":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
1
+ {"assets":{"airport-collisions.json":{"bytes":29,"gzBytes":49,"sha256":"a7d0107c5fcc6d787e010ea100ce1946bb724f7ae1a79eb1b862a8cf822ed1d8"},"airports.json":{"bytes":104083,"gzBytes":38159,"sha256":"c17294d4e3fd259e02c63c49c36c02d9ff34f4462103ff54516d29e93667cd76"},"gazetteer.json":{"bytes":130767,"gzBytes":56261,"sha256":"5ad56e5ba0b3a4f9a6dc8bd3bf8b0fda0e7b86cbe4d85d231114f5dd967d65f7"},"lakes.json":{"bytes":6315,"gzBytes":1487,"sha256":"5840ffd49b8dbf30183a9534a72adf80b6e77ceec224665393fa94e956220323"},"mountain-ranges.json":{"bytes":90845,"gzBytes":26493,"sha256":"a698b3f296e61712fb39b3d8d42ec7c4699f8aadecb549367feb7d09f7785580"},"na-lakes.json":{"bytes":39387,"gzBytes":11281,"sha256":"2a41c04969209380d544a09efe354277e12d704458af95955201eb4f698d16c6"},"na-land.json":{"bytes":114082,"gzBytes":32375,"sha256":"7b94c9bb4e809c22813da5ae939e1ff6a781fd77a04d9c1585a9a82d2a195388"},"region-names.json":{"bytes":11667,"gzBytes":2235,"sha256":"059662d30b6ee8572c5943096905e05218e5f337e6973a9d43d6b41b7313a9ac"},"rivers.json":{"bytes":6707,"gzBytes":2158,"sha256":"3912508469099b1c37360c5505ea033c4ffa30ce95f7428e668e9d824cb81407"},"us-states.json":{"bytes":23313,"gzBytes":7413,"sha256":"0fe3a8937bc7566192662439f29a7866e8823d687290bcb003433ad5edd86567"},"water-bodies.json":{"bytes":4854,"gzBytes":2123,"sha256":"6d1a407a376c63518329c52189e2887053c4b61062af0597e060050ae8469635"},"world-coarse.json":{"bytes":55436,"gzBytes":18397,"sha256":"5cb42e3c8975dde56504ca5c68ece0a1e71d0929680b5fc8cdab758c8666dbf8"},"world-detail.json":{"bytes":163562,"gzBytes":46767,"sha256":"39f1736eaabe9e21190972be3157822be22ee84fdc41751237f2b516f09a7586"}},"counts":{"airportCollisions":2,"airports":1565,"countries":175,"gazetteerAliases":8,"gazetteerCities":2119,"mountainRanges":205,"usStates":56,"waterBodies":113},"generatedBy":"scripts/build-map-data.mjs","sourceHashes":{"file:scripts/airports-snapshot.csv":{"bytes":308536,"sha256":"58b6e45bb91045c2897f1e7e14430aecc3b5eca911bcb389855495ff66ab9599"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json":{"bytes":6648697,"sha256":"93c8fdf0e591e113f449d0d466e15c7a9841b9b6571c7afe41f95ba51b322452"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json":{"bytes":27711,"sha256":"6f315b60488e0cf5da9c360e3ce593babf64c2f44cc21e2820c536f7a2aff606"},"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json":{"bytes":54146,"sha256":"959e13128e4eb5a6ee530b8270c5017bcee9149ce48a97f6fe7fee1fce600b5d"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson":{"bytes":13287234,"sha256":"239eec57ac17f100a11e2536cffc56752c318b50ae765b0918ff7aab4ce8f255"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson":{"bytes":5583870,"sha256":"b7b26e50ea917d3696aec87f932def2bf5f890f5770e441d59c162c6f4c92a77"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson":{"bytes":534055,"sha256":"b9c3f7f557d0ff5217906adc82b66ecdac14aa7438df7e518cf6675d037bceb8"},"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson":{"bytes":1163418,"sha256":"6fe58083e0cc5c7fad9e396970e28a8580bbd8770cfa4d1d7b5a34423e912f97"},"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json":{"bytes":114554,"sha256":"d76b391ccfa8bff601d51e3e3da5d43a89fa46cd5caca72ce731b383be5596d0"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json":{"bytes":107761,"sha256":"2516c915867c7baf18ddec727aec46c315541a07cfb3d79a6559b05d5e94eee8"},"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json":{"bytes":756420,"sha256":"04342cdc1e3016bcd7db1630de95684d67b79fe3c8c460321e87aef469502394"},"https://download.geonames.org/export/dump/cities5000.zip":{"bytes":5549002,"sha256":"d20e28b2f610da34c21fd82ff6a8e4d24ebe67eba2dccf65bd2c4332ff0f380a"}},"sources":{"geonames":{"citiesUrl":"https://download.geonames.org/export/dump/cities5000.zip","license":"CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/","modificationDateRange":"2006-01-17..2026-06-02 (filtered subset)"},"lakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_lakes.json","version":"natural-earth 110m (martynafford snapshot)"},"marineCoarse":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_110m_geography_marine_polys.geojson","version":"natural-earth 110m (nvkelso vector snapshot)"},"marineDetail":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_50m_geography_marine_polys.geojson","version":"natural-earth 50m (nvkelso vector snapshot)"},"mountainRanges":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_geography_regions_polys.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"naLakes":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/10m/physical/ne_10m_lakes.json","version":"natural-earth 10m (martynafford snapshot)"},"naLand":{"url":"https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@master/geojson/ne_10m_admin_0_countries.geojson","version":"natural-earth 10m (nvkelso vector snapshot)"},"ourairports":{"license":"Public Domain — https://ourairports.com/data/","note":"Lean committed slice (scheduled_service=yes + 3-letter iata_code). Regenerate is a deliberate, reviewed bump (ADR-4).","snapshot":"scripts/airports-snapshot.csv","source":"https://davidmegginson.github.io/ourairports-data/airports.csv"},"rivers":{"url":"https://cdn.jsdelivr.net/gh/martynafford/natural-earth-geojson@master/110m/physical/ne_110m_rivers_lake_centerlines.json","version":"natural-earth 110m (martynafford snapshot)"},"usAtlas":{"url":"https://cdn.jsdelivr.net/npm/us-atlas@3.0.1/states-10m.json","version":"3.0.1"},"worldCoarse":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json","version":"2.0.2"},"worldDetail":{"url":"https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-50m.json","version":"2.0.2"}},"tooling":{"mapshaper":"0.7.22"}}
@@ -0,0 +1 @@
1
+ {"collisions":["aba","ufa"]}