@diagrammo/dgmo 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4182 -2704
  3. package/dist/advanced.d.cts +266 -58
  4. package/dist/advanced.d.ts +266 -58
  5. package/dist/advanced.js +4182 -2698
  6. package/dist/auto.cjs +4042 -2581
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4042 -2581
  9. package/dist/cli.cjs +172 -170
  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 +4067 -2583
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4067 -2583
  18. package/dist/internal.cjs +4182 -2704
  19. package/dist/internal.d.cts +266 -58
  20. package/dist/internal.d.ts +266 -58
  21. package/dist/internal.js +4182 -2698
  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 +1 -1
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -0
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion.ts +26 -12
  48. package/src/cycle/layout.ts +55 -72
  49. package/src/cycle/renderer.ts +11 -6
  50. package/src/d3.ts +78 -117
  51. package/src/diagnostics.ts +16 -0
  52. package/src/echarts.ts +46 -33
  53. package/src/editor/keywords.ts +4 -0
  54. package/src/er/layout.ts +114 -22
  55. package/src/er/parser.ts +28 -0
  56. package/src/er/renderer.ts +55 -2
  57. package/src/er/types.ts +3 -0
  58. package/src/gantt/renderer.ts +46 -38
  59. package/src/gantt/resolver.ts +9 -2
  60. package/src/graph/edge-spline.ts +29 -0
  61. package/src/graph/flowchart-parser.ts +34 -1
  62. package/src/graph/flowchart-renderer.ts +78 -64
  63. package/src/graph/layout.ts +206 -23
  64. package/src/graph/notes.ts +21 -0
  65. package/src/graph/state-parser.ts +26 -1
  66. package/src/graph/state-renderer.ts +78 -64
  67. package/src/graph/types.ts +13 -0
  68. package/src/index.ts +1 -1
  69. package/src/infra/layout.ts +46 -26
  70. package/src/infra/renderer.ts +16 -7
  71. package/src/journey-map/layout.ts +38 -49
  72. package/src/journey-map/renderer.ts +22 -45
  73. package/src/kanban/renderer.ts +15 -6
  74. package/src/label-layout.ts +3 -3
  75. package/src/map/completion.ts +77 -22
  76. package/src/map/context-labels.ts +57 -12
  77. package/src/map/data/PROVENANCE.json +1 -1
  78. package/src/map/data/airport-collisions.json +1 -0
  79. package/src/map/data/airports.json +1 -0
  80. package/src/map/data/types.ts +19 -0
  81. package/src/map/layout.ts +1196 -90
  82. package/src/map/legend-band.ts +2 -2
  83. package/src/map/load-data.ts +10 -1
  84. package/src/map/parser.ts +61 -32
  85. package/src/map/renderer.ts +284 -12
  86. package/src/map/resolved-types.ts +15 -1
  87. package/src/map/resolver.ts +132 -12
  88. package/src/map/types.ts +28 -8
  89. package/src/migrate/embedded.ts +9 -7
  90. package/src/mindmap/text-wrap.ts +13 -14
  91. package/src/org/layout.ts +19 -17
  92. package/src/org/renderer.ts +11 -4
  93. package/src/palettes/color-utils.ts +82 -21
  94. package/src/palettes/index.ts +0 -19
  95. package/src/palettes/registry.ts +1 -1
  96. package/src/palettes/types.ts +2 -2
  97. package/src/pert/layout.ts +48 -40
  98. package/src/pert/renderer.ts +30 -43
  99. package/src/pyramid/renderer.ts +4 -5
  100. package/src/raci/renderer.ts +34 -68
  101. package/src/render.ts +1 -1
  102. package/src/ring/renderer.ts +1 -2
  103. package/src/sequence/parser.ts +100 -22
  104. package/src/sequence/renderer.ts +75 -50
  105. package/src/sitemap/layout.ts +27 -19
  106. package/src/sitemap/renderer.ts +12 -5
  107. package/src/tech-radar/renderer.ts +11 -35
  108. package/src/utils/arrow-markers.ts +51 -0
  109. package/src/utils/fit-canvas.ts +64 -0
  110. package/src/utils/legend-constants.ts +8 -54
  111. package/src/utils/legend-d3.ts +10 -7
  112. package/src/utils/legend-layout.ts +7 -4
  113. package/src/utils/legend-types.ts +10 -4
  114. package/src/utils/note-box/constants.ts +25 -0
  115. package/src/utils/note-box/index.ts +11 -0
  116. package/src/utils/note-box/metrics.ts +90 -0
  117. package/src/utils/note-box/svg.ts +331 -0
  118. package/src/utils/notes/bounds.ts +30 -0
  119. package/src/utils/notes/build.ts +131 -0
  120. package/src/utils/notes/index.ts +18 -0
  121. package/src/utils/notes/model.ts +19 -0
  122. package/src/utils/notes/parse.ts +131 -0
  123. package/src/utils/notes/place.ts +177 -0
  124. package/src/utils/notes/resolve.ts +88 -0
  125. package/src/utils/number-format.ts +36 -0
  126. package/src/utils/parsing.ts +41 -0
  127. package/src/utils/reserved-key-registry.ts +4 -0
  128. package/src/utils/text-measure.ts +122 -0
  129. package/src/wireframe/layout.ts +4 -2
  130. package/src/wireframe/renderer.ts +8 -6
  131. package/src/palettes/dracula.ts +0 -68
  132. package/src/palettes/gruvbox.ts +0 -85
  133. package/src/palettes/monokai.ts +0 -68
  134. package/src/palettes/one-dark.ts +0 -70
  135. package/src/palettes/rose-pine.ts +0 -84
  136. 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 {
@@ -60,6 +60,17 @@ const CONTEXT_PAD = 4; // extra gap enforced between two context labels
60
60
  const EDGE_CLAMP_MARGIN = 8; // px inset for edge-clamped ocean labels
61
61
  const EDGE_CLAMP_OVERSHOOT = 0.35; // max off-frame overshoot (× dim) to still clamp
62
62
 
63
+ // Country labels scale with their projected footprint: a big landmass (Canada,
64
+ // Mexico on a US view) reads as a large, FADED backdrop name; a small one stays
65
+ // at the base font, fully muted. Size metric is the footprint's linear extent
66
+ // (√bbox-area) as a fraction of the canvas's linear extent (√canvas-area), so
67
+ // the ramp is resolution-independent. Below MIN ⇒ base font / no extra fade;
68
+ // at/above MAX ⇒ max font / max fade; linear between.
69
+ const COUNTRY_FONT_MAX = 22; // px ceiling for the largest footprint
70
+ const COUNTRY_SIZE_FRAC_MIN = 0.06; // footprint linear-frac at base font
71
+ const COUNTRY_SIZE_FRAC_MAX = 0.32; // footprint linear-frac at max font
72
+ const COUNTRY_FADE_MAX = 45; // % blend toward bg at max font (subdue big names)
73
+
63
74
  // Water-kind priority within a tier (oceans first, then seas, then the rest) so
64
75
  // a thin budget always spends on the highest-orientation-value names.
65
76
  const KIND_ORDER: Record<WaterKind, number> = {
@@ -136,10 +147,14 @@ function insideViewport(
136
147
  * the per-gap `letter-spacing` the renderer applies to water names, so without
137
148
  * this the fit/clamp math under-measures by ~`(len-1)*spacing` and the label
138
149
  * clips at the canvas edge. */
139
- export function labelWidth(text: string, letterSpacing: number): number {
150
+ export function labelWidth(
151
+ text: string,
152
+ letterSpacing: number,
153
+ font: number = FONT
154
+ ): number {
140
155
  const spacing =
141
156
  letterSpacing > 0 ? Math.max(0, text.length - 1) * letterSpacing : 0;
142
- return measureLegendText(text, FONT) + spacing + 2 * PADX;
157
+ return measureLegendText(text, font) + spacing + 2 * PADX;
143
158
  }
144
159
 
145
160
  /** Wrap a multi-word name into balanced lines, biased to wrap READILY — water
@@ -188,10 +203,12 @@ function rectAround(
188
203
  cx: number,
189
204
  cy: number,
190
205
  lines: readonly string[],
191
- letterSpacing: number
206
+ letterSpacing: number,
207
+ font: number = FONT
192
208
  ): LabelRect {
193
- const w = Math.max(...lines.map((l) => labelWidth(l, letterSpacing)));
194
- const h = (lines.length - 1) * LINE_HEIGHT + FONT + 2 * PADY;
209
+ const lineHeight = font + 2; // MUST match the renderer's per-line stride
210
+ const w = Math.max(...lines.map((l) => labelWidth(l, letterSpacing, font)));
211
+ const h = (lines.length - 1) * lineHeight + font + 2 * PADY;
195
212
  return { x: cx - w / 2, y: cy - h / 2, w, h };
196
213
  }
197
214
 
@@ -252,6 +269,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
252
269
  italic: boolean;
253
270
  letterSpacing: number;
254
271
  color: string;
272
+ fontSize: number;
255
273
  sort: number; // priority key (lower first)
256
274
  };
257
275
  const candidates: Candidate[] = [];
@@ -328,6 +346,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
328
346
  italic: true,
329
347
  letterSpacing: WATER_LETTER_SPACING,
330
348
  color: waterColor,
349
+ fontSize: FONT, // water names keep the base font (no footprint to scale on)
331
350
  // Water before any country (×1000), then by tier, then kind, then name.
332
351
  sort: tier * 10 + KIND_ORDER[kind],
333
352
  });
@@ -345,22 +364,40 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
345
364
  })
346
365
  .filter((r) => Number.isFinite(r.area) && r.area > 0)
347
366
  .sort((a, b) => b.area - a.area);
367
+ // Canvas linear extent — the denominator for the footprint size ramp below.
368
+ const canvasLinear = Math.sqrt(Math.max(1, width * height));
348
369
  let ci = 0;
349
370
  for (const r of ranked) {
350
- const { c, w, h } = r;
371
+ const { c, w, h, area } = r;
351
372
  // F2: an antimeridian-crossing / global-smear country yields a near-full-
352
373
  // canvas bbox while its real landmass is split — the `path.centroid` anchor
353
374
  // is then unreliable (mid-map, wrong basin). Drop such over-wide candidates
354
375
  // rather than spend a top-priority slot on a mispositioned name.
355
376
  if (w > width * 0.66 || h > height * 0.66) continue;
356
377
  if (!insideViewport(c.anchor, width, height)) continue;
378
+ // Footprint-driven scale (Decision: big landmass = large, faded backdrop
379
+ // name). t∈[0,1] over the [MIN,MAX] linear-fraction band; font ramps up and
380
+ // colour fades toward bg in lockstep so a bigger name is also a quieter one.
381
+ const sizeFrac = Math.sqrt(area) / canvasLinear;
382
+ const t = Math.min(
383
+ 1,
384
+ Math.max(
385
+ 0,
386
+ (sizeFrac - COUNTRY_SIZE_FRAC_MIN) /
387
+ (COUNTRY_SIZE_FRAC_MAX - COUNTRY_SIZE_FRAC_MIN)
388
+ )
389
+ );
390
+ const fontSize = Math.round(FONT + t * (COUNTRY_FONT_MAX - FONT));
391
+ const fade = Math.round(t * COUNTRY_FADE_MAX);
392
+ const color = fade > 0 ? mix(countryColor, palette.bg, fade) : countryColor;
357
393
  // Always the full country name — never an ISO abbreviation. If the name
358
394
  // doesn't fit the footprint, drop the label rather than abbreviate.
359
395
  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;
396
+ const tw = labelWidth(text, 0, fontSize);
397
+ // Approximate fit (Decision 4): the (scaled) name fits inside the footprint
398
+ // bbox. NOT true point-in-polygon — cartographic labels routinely overrun
399
+ // coastlines. The bigger the font, the bigger the box it must clear.
400
+ if (tw > w || fontSize + 2 * PADY > h) continue;
364
401
  candidates.push({
365
402
  text,
366
403
  lines: [text],
@@ -368,7 +405,8 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
368
405
  cy: c.anchor[1],
369
406
  italic: false,
370
407
  letterSpacing: 0,
371
- color: countryColor,
408
+ color,
409
+ fontSize,
372
410
  // Always after every water body (+1e6); larger area = earlier.
373
411
  sort: 1_000_000 + ci++,
374
412
  });
@@ -380,7 +418,13 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
380
418
  const placedRects: LabelRect[] = [];
381
419
  for (const cand of candidates) {
382
420
  if (placed.length >= budget) break;
383
- const rect = rectAround(cand.cx, cand.cy, cand.lines, cand.letterSpacing);
421
+ const rect = rectAround(
422
+ cand.cx,
423
+ cand.cy,
424
+ cand.lines,
425
+ cand.letterSpacing,
426
+ cand.fontSize
427
+ );
384
428
  if (!rectFits(rect, width, height)) continue;
385
429
  // Water labels must sit over OPEN WATER and NEVER touch land — sample a grid
386
430
  // over every wrapped line (each line's own horizontal extent at five points);
@@ -419,6 +463,7 @@ export function placeContextLabels(args: ContextLabelArgs): PlacedLabel[] {
419
463
  // cleanly on the basemap without one.
420
464
  halo: false,
421
465
  haloColor,
466
+ fontSize: cand.fontSize,
422
467
  italic: cand.italic,
423
468
  letterSpacing: cand.letterSpacing,
424
469
  ...(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"]}