@diagrammo/dgmo 0.61.0 → 0.62.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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/advanced.d.ts +382 -396
  3. package/dist/advanced.js +135 -21
  4. package/dist/auto.js +143 -142
  5. package/dist/block.js +6 -5
  6. package/dist/{chart-meta-gUwAzi7N.d.ts → chart-meta-D98NbYDp.d.ts} +1 -1
  7. package/dist/chart-meta.d.ts +2 -2
  8. package/dist/chart-meta.js +1 -1
  9. package/dist/{charts-d3-MN6QCYKU.js → charts-d3-3WMDA5Z5.js} +3 -3
  10. package/dist/{chunk-OPBZYISB.js → chunk-37EHGTQ2.js} +2204 -2018
  11. package/dist/{chunk-ZDFIY6WX.js → chunk-3PVUGECM.js} +1 -2
  12. package/dist/{chunk-6CWNVQPW.js → chunk-4C3BTTOL.js} +2 -2
  13. package/dist/{chunk-4B5YISOR.js → chunk-536EBUPG.js} +16 -1
  14. package/dist/{chunk-VHO3YBTB.js → chunk-CVVICCSB.js} +24 -2
  15. package/dist/{chunk-BSJIPMPG.js → chunk-DHIBVN2D.js} +28 -17
  16. package/dist/{chunk-MOT3NXB5.js → chunk-FVYHDZ6Q.js} +2 -2
  17. package/dist/{chunk-S23HOUJS.js → chunk-JU2VMFYC.js} +2 -2
  18. package/dist/{chunk-5PWIV7ZF.js → chunk-LFAQ4BLZ.js} +2 -2
  19. package/dist/{chunk-DNSEG6P2.js → chunk-PJDX4IKR.js} +2 -2
  20. package/dist/{chunk-2WPLOS4Q.js → chunk-USHDV2AF.js} +2 -2
  21. package/dist/{registry-Ch0Umdi1.d.ts → dgmo-router-Dd5PTjxl.d.ts} +98 -98
  22. package/dist/{dgmo-router-UVYAJCAF.js → dgmo-router-Z5N6QLIC.js} +2 -2
  23. package/dist/{dimensions-QCPOPPKE.js → dimensions-ES6O2T4H.js} +4 -4
  24. package/dist/element.js +144 -143
  25. package/dist/index.d.ts +21 -10
  26. package/dist/index.js +10 -20
  27. package/dist/{renderer-IYMA5W7X.js → renderer-A5GFW2XH.js} +4 -4
  28. package/dist/{renderer-R6OKKSVR.js → renderer-Q2YHQQSN.js} +4 -4
  29. package/dist/{resolver-W3WKLWDP.js → resolver-ZLYSMILY.js} +3 -3
  30. package/dist/{themes-DC6u80yr.d.ts → themes-DiUAG4Bf.d.ts} +37 -2
  31. package/docs/ai-integration.md +5 -5
  32. package/package.json +2 -21
  33. package/.claude/commands/dgmo-codebase-report.md +0 -172
  34. package/.claude/commands/dgmo-diagram-this.md +0 -60
  35. package/.claude/commands/dgmo-document-project.md +0 -128
  36. package/.claude/commands/dgmo.md +0 -313
  37. package/.cursorrules +0 -587
  38. package/.github/copilot-instructions.md +0 -597
  39. package/.windsurfrules +0 -587
  40. package/SKILL.md +0 -573
  41. package/dist/auto.d.ts +0 -60
  42. package/dist/auto.mjs +0 -88466
  43. package/dist/chunk-6MTWIQVO.js +0 -121
  44. package/dist/cli.cjs +0 -489
  45. package/dist/element.d.ts +0 -68
  46. package/dist/element.mjs +0 -88332
  47. package/dist/load-data-EHDDZ5ZS.js +0 -8
@@ -4,11 +4,11 @@ import {
4
4
  pointInGeometry
5
5
  } from "./chunk-4QV26TEG.js";
6
6
  import {
7
+ createLabelPlacement,
7
8
  rectCircleOverlap,
8
9
  rectsOverlap,
9
- segmentRectOverlap,
10
- segmentsCross
11
- } from "./chunk-VHO3YBTB.js";
10
+ segmentRectOverlap
11
+ } from "./chunk-CVVICCSB.js";
12
12
  import {
13
13
  TITLE_FONT_SIZE,
14
14
  TITLE_Y
@@ -92,7 +92,7 @@ function mapLegendBox(legend, opts) {
92
92
 
93
93
  // src/map/layout.ts
94
94
  import {
95
- geoPath,
95
+ geoPath as geoPath2,
96
96
  geoNaturalEarth1,
97
97
  geoEqualEarth,
98
98
  geoEquirectangular,
@@ -424,1607 +424,2249 @@ function placeContextLabels(args) {
424
424
  return placed;
425
425
  }
426
426
 
427
- // src/map/layout.ts
428
- var FIT_PAD = 24;
429
- var PATH_DIGITS = 1;
430
- var THIN_TOL = 0.6;
431
- var ALBERS_SKEW_MAX = 1.25;
432
- function geoThin() {
433
- const tol2 = THIN_TOL * THIN_TOL;
434
- return geoTransform({
435
- lineStart() {
436
- const t = this;
437
- t._has = false;
438
- t._pending = false;
439
- t.stream.lineStart();
440
- },
441
- point(x, y) {
442
- const t = this;
443
- t._lx = x;
444
- t._ly = y;
445
- if (t._has) {
446
- const dx = x - t._ex;
447
- const dy = y - t._ey;
448
- if (dx * dx + dy * dy < tol2) {
449
- t._pending = true;
450
- return;
451
- }
427
+ // src/map/city-dots.ts
428
+ var CITY_DOT_SPACING = 12;
429
+ var CITY_DOT_CAP = 220;
430
+ var SPACING_SQ = CITY_DOT_SPACING * CITY_DOT_SPACING;
431
+ var CITY_DOT_R_MIN = 0.7;
432
+ var CITY_DOT_R_MAX = 2.6;
433
+ var CITY_POP_MIN = 5e4;
434
+ var CITY_POP_MAX = 15e6;
435
+ var LOG_MIN = Math.log10(CITY_POP_MIN);
436
+ var LOG_SPAN = Math.log10(CITY_POP_MAX) - LOG_MIN;
437
+ function cityDotRadius(pop) {
438
+ if (!(pop > CITY_POP_MIN)) return CITY_DOT_R_MIN;
439
+ const t = Math.min(1, (Math.log10(pop) - LOG_MIN) / LOG_SPAN);
440
+ return CITY_DOT_R_MIN + t * (CITY_DOT_R_MAX - CITY_DOT_R_MIN);
441
+ }
442
+ function layoutCityDots(args) {
443
+ const { cities, occupied, project, width, height } = args;
444
+ const cityDots = [];
445
+ const placed = occupied.map((p) => ({
446
+ x: p.x,
447
+ y: p.y
448
+ }));
449
+ const sorted = [...cities].sort((a, b) => b[3] - a[3]);
450
+ for (const c of sorted) {
451
+ if (cityDots.length >= CITY_DOT_CAP) break;
452
+ const lat = c[0];
453
+ const lon = c[1];
454
+ const p = project(lon, lat);
455
+ if (!p) continue;
456
+ const [px, py] = p;
457
+ if (px < 0 || px > width || py < 0 || py > height) continue;
458
+ let tooClose = false;
459
+ for (const q of placed) {
460
+ const dx = q.x - px;
461
+ const dy = q.y - py;
462
+ if (dx * dx + dy * dy < SPACING_SQ) {
463
+ tooClose = true;
464
+ break;
452
465
  }
453
- t.stream.point(x, y);
454
- t._ex = x;
455
- t._ey = y;
456
- t._has = true;
457
- t._pending = false;
458
- },
459
- lineEnd() {
460
- const t = this;
461
- if (t._pending) t.stream.point(t._lx, t._ly);
462
- t._pending = false;
463
- t.stream.lineEnd();
464
466
  }
467
+ if (tooClose) continue;
468
+ placed.push({ x: px, y: py });
469
+ cityDots.push({ cx: px, cy: py, r: cityDotRadius(c[3]) });
470
+ }
471
+ return cityDots;
472
+ }
473
+
474
+ // src/map/edge-labels.ts
475
+ var T_LIST = [0.5, 0.42, 0.58, 0.34, 0.66, 0.28, 0.72];
476
+ var LEG_PATH_RE = /^M(-?[\d.]+),(-?[\d.]+)(?:L(-?[\d.]+),(-?[\d.]+)|Q(-?[\d.]+),(-?[\d.]+) (-?[\d.]+),(-?[\d.]+))$/;
477
+ function layoutEdgeLabels(args) {
478
+ const {
479
+ legs,
480
+ committed,
481
+ poiScreen,
482
+ markers,
483
+ labelW,
484
+ labelH,
485
+ minStroke,
486
+ fillAt,
487
+ labelOnFill,
488
+ width,
489
+ height
490
+ } = args;
491
+ const chosen = [];
492
+ if (!legs.some((lg) => lg.label !== void 0)) return chosen;
493
+ const committedBoxes = committed.filter((l) => !l.hidden).map((l) => {
494
+ const w = labelW(l.text);
495
+ const x = l.anchor === "start" ? l.x : l.anchor === "end" ? l.x - w : l.x - w / 2;
496
+ return { x, y: l.y - labelH / 2, w, h: labelH };
465
497
  });
498
+ const placedEdge = [];
499
+ for (let i = 0; i < legs.length; i++) {
500
+ const lg = legs[i];
501
+ if (lg.label === void 0) continue;
502
+ const a = poiScreen.get(lg.fromId);
503
+ const b = poiScreen.get(lg.toId);
504
+ if (!a || !b) continue;
505
+ const w = labelW(lg.label);
506
+ const dx = b.cx - a.cx;
507
+ const dy = b.cy - a.cy;
508
+ const len = Math.hypot(dx, dy) || 1;
509
+ const nx = -dy / len;
510
+ const ny = dx / len;
511
+ const perp = labelH + 2;
512
+ const stroke = lg.width ?? minStroke;
513
+ const clearW = stroke / 2 + labelH / 2 + 3;
514
+ const offList = stroke >= 4 ? [clearW, -clearW, clearW + perp, -(clearW + perp), 0] : [0, perp, -perp, 2 * perp, -2 * perp];
515
+ const pm = LEG_PATH_RE.exec(lg.d);
516
+ const pointAt = (t) => {
517
+ if (pm?.[5] !== void 0) {
518
+ const u = 1 - t;
519
+ return [
520
+ u * u * +pm[1] + 2 * u * t * +pm[5] + t * t * +pm[7],
521
+ u * u * +pm[2] + 2 * u * t * +pm[6] + t * t * +pm[8]
522
+ ];
523
+ }
524
+ if (pm?.[3] !== void 0)
525
+ return [
526
+ +pm[1] + (+pm[3] - +pm[1]) * t,
527
+ +pm[2] + (+pm[4] - +pm[2]) * t
528
+ ];
529
+ return [a.cx + dx * t, a.cy + dy * t];
530
+ };
531
+ const candidates = [];
532
+ for (const off of offList)
533
+ for (const t of T_LIST) {
534
+ const [bx, by] = pointAt(t);
535
+ candidates.push([bx + nx * off, by + ny * off - 4]);
536
+ }
537
+ const clean = ([cx2, cy2]) => {
538
+ const rect2 = { x: cx2 - w / 2, y: cy2 - labelH / 2, w, h: labelH };
539
+ if (rect2.x < 0 || rect2.x + w > width || rect2.y < 0 || rect2.y + rect2.h > height)
540
+ return false;
541
+ if (markers.some((m) => rectCircleOverlap(rect2, m))) return false;
542
+ if (committedBoxes.some((o) => rectsOverlap(o, rect2))) return false;
543
+ if (placedEdge.some((o) => rectsOverlap(o, rect2))) return false;
544
+ return true;
545
+ };
546
+ const mid = pointAt(0.5);
547
+ const [cx, cy] = candidates.find(clean) ?? [mid[0], mid[1] - 4];
548
+ const style = labelOnFill(fillAt(cx, cy));
549
+ legs[i] = {
550
+ ...lg,
551
+ labelX: cx,
552
+ labelY: cy,
553
+ labelColor: style.color,
554
+ labelHalo: style.halo,
555
+ labelHaloColor: style.haloColor
556
+ };
557
+ const rect = { x: cx - w / 2, y: cy - labelH / 2, w, h: labelH };
558
+ placedEdge.push(rect);
559
+ chosen.push(rect);
560
+ }
561
+ return chosen;
466
562
  }
467
- var RAMP_FLOOR = 15;
468
- var R_DEFAULT = 6;
469
- var R_MIN = 4;
470
- var R_MAX = 22;
471
- var POI_FILL_OPACITY_MAX = 0.92;
472
- var POI_FILL_OPACITY_MIN = 0.55;
563
+
564
+ // src/map/connectors.ts
473
565
  var W_MIN = 1.25;
474
566
  var W_MAX = 8;
475
- var FONT2 = 11;
476
- var WORLD_LABEL_ANCHORS = {
477
- US: [-98.5, 39.5],
478
- // CONUS geographic centre (near Lebanon, Kansas)
479
- // Russia crosses the antimeridian (Chukotka at ~170°W), so on a non-global
480
- // (e.g. Europe) projection its geometry smears across the whole frame and the
481
- // area-weighted centroid lands mid-map (over Europe) — useless as a label
482
- // anchor. Pin it to European Russia (~Volga) so a Europe view labels visible
483
- // western Russia on its eastern margin; on a world view this still sits over
484
- // Russian land. (See the curated-anchor smear-gate bypass in context-labels.)
485
- RU: [45, 58]
486
- };
487
- var MAX_CLUSTER_EXTENT_FACTOR = 0.18;
488
- var MAX_COLUMN_ROWS = 7;
489
- var REGION_LABEL_HALO_RATIO = 4.5;
490
- var LAND_TINT_LIGHT = 12;
491
- var LAND_TINT_DARK = 24;
492
- var TAG_TINT_LIGHT = 60;
493
- var TAG_TINT_DARK = 68;
494
- var WATER_TINT_LIGHT = 24;
495
- var WATER_TINT_DARK = 24;
496
- var RIVER_WIDTH = 1.3;
497
- var COMPACT_WIDTH_PX = 480;
498
- var RELIEF_MIN_AREA = 12;
499
- var RELIEF_MIN_DIM = 2;
500
- var RELIEF_HATCH_SPACING = 1.5;
501
- var RELIEF_HATCH_WIDTH = 0.2;
502
- var RELIEF_HATCH_STRENGTH = 26;
503
- var COASTLINE_RING_COUNT = 5;
504
- var COASTLINE_D0 = 16e-4;
505
- var COASTLINE_STEP = 28e-4;
506
- var COASTLINE_THICKNESS = 14e-4;
507
- var COASTLINE_OPACITY_NEAR = 0.5;
508
- var COASTLINE_OPACITY_FAR = 0.1;
509
- var COASTLINE_MIN_EXTENT = 6e-4;
510
- var COASTLINE_MIN_EXTENT_GLOBAL = 6e-4;
511
- var COASTLINE_STROKE_MIX = 32;
512
- var FOREIGN_TINT_LIGHT = 30;
513
- var FOREIGN_TINT_DARK = 62;
514
- var MUTED_FOREIGN_LIGHT = 28;
515
- var MUTED_FOREIGN_DARK = 16;
516
- var COLO_R = 9;
517
- var GOLDEN_ANGLE = 2.399963229728653;
518
- var STACK_OVERLAP = 1;
519
- var STACK_RING_MAX = 8;
520
- var STACK_RING_GAP = 4;
521
567
  var FAN_STEP = 16;
522
568
  var ARC_CURVE_FRAC = 0.18;
523
- function geomObject(topo) {
524
- const key = Object.keys(topo.objects)[0];
525
- return topo.objects[key];
526
- }
527
- var decodeCache = /* @__PURE__ */ new WeakMap();
528
- function mergeFeatures(a, b) {
529
- const polysOf = (f) => {
530
- const g = f.geometry;
531
- if (!g) return null;
532
- if (g.type === "Polygon") return [g.coordinates];
533
- if (g.type === "MultiPolygon") return g.coordinates;
534
- return null;
535
- };
536
- const pa = polysOf(a);
537
- const pb = polysOf(b);
538
- if (!pa || !pb) return a;
539
- return {
540
- ...a,
541
- geometry: { type: "MultiPolygon", coordinates: [...pa, ...pb] }
542
- };
569
+ var RIM_GAP = 1.5;
570
+ var LEG_SAMPLES = 25;
571
+ var POI_CLEARANCE = 10;
572
+ var LEG_CLEARANCE = 7;
573
+ var SHARED_END_RADIUS = 36;
574
+ function bowMagnitude(mx, my, nx, ny, offset, len, away) {
575
+ if (offset !== 0) return offset;
576
+ const base = len * ARC_CURVE_FRAC;
577
+ if (!away) return base;
578
+ const dot = nx * (mx - away.x) + ny * (my - away.y);
579
+ return dot < 0 ? -base : base;
543
580
  }
544
- function decodeLayer(topo) {
545
- const cached = decodeCache.get(topo);
546
- if (cached) return cached;
547
- const out = /* @__PURE__ */ new Map();
548
- for (const g of geomObject(topo).geometries) {
549
- const f = feature(topo, g);
550
- if (!f.geometry) continue;
551
- const tagged = { ...f, id: g.id };
552
- const existing = out.get(g.id);
553
- out.set(g.id, existing ? mergeFeatures(existing, tagged) : tagged);
581
+ function legPath(a, b, curved, offset, away) {
582
+ const mx = (a.cx + b.cx) / 2;
583
+ const my = (a.cy + b.cy) / 2;
584
+ const dx = b.cx - a.cx;
585
+ const dy = b.cy - a.cy;
586
+ const len = Math.hypot(dx, dy) || 1;
587
+ const trimA = Math.min(a.r + RIM_GAP, len * 0.45);
588
+ const trimB = Math.min(b.r + RIM_GAP, len * 0.45);
589
+ if (!curved && offset === 0) {
590
+ const ux = dx / len;
591
+ const uy = dy / len;
592
+ const ax2 = a.cx + ux * trimA;
593
+ const ay2 = a.cy + uy * trimA;
594
+ const bx2 = b.cx - ux * trimB;
595
+ const by2 = b.cy - uy * trimB;
596
+ return `M${ax2},${ay2}L${bx2},${by2}`;
554
597
  }
555
- decodeCache.set(topo, out);
556
- return out;
598
+ const nx = -dy / len;
599
+ const ny = dx / len;
600
+ const bow = bowMagnitude(mx, my, nx, ny, offset, len, away);
601
+ const px = mx + nx * bow;
602
+ const py = my + ny * bow;
603
+ const ta = Math.hypot(px - a.cx, py - a.cy) || 1;
604
+ const tb = Math.hypot(b.cx - px, b.cy - py) || 1;
605
+ const ax = a.cx + (px - a.cx) / ta * trimA;
606
+ const ay = a.cy + (py - a.cy) / ta * trimA;
607
+ const bx = b.cx - (b.cx - px) / tb * trimB;
608
+ const by = b.cy - (b.cy - py) / tb * trimB;
609
+ return `M${ax},${ay}Q${px},${py} ${bx},${by}`;
557
610
  }
558
- function countryLabelPositions(args) {
559
- const { geometry, bounds, project, width, height, curated } = args;
560
- const w0 = bounds[0][0];
561
- const s0 = bounds[0][1];
562
- const e0 = bounds[1][0];
563
- const n0 = bounds[1][1];
564
- if (![w0, s0, e0, n0].every(Number.isFinite) || e0 <= w0 || n0 <= s0) {
565
- return mkCurated(curated, project);
566
- }
567
- const N = COUNTRY_POS_GRID;
568
- const onLand = [];
569
- const kept = [];
570
- for (let i = 0; i < N; i++) {
571
- onLand[i] = [];
572
- const lon = w0 + (i + 0.5) / N * (e0 - w0);
573
- for (let j = 0; j < N; j++) {
574
- const lat = s0 + (j + 0.5) / N * (n0 - s0);
575
- const land = pointInGeometry(geometry, lon, lat);
576
- onLand[i][j] = land;
577
- if (!land) continue;
578
- const p = project(lon, lat);
579
- if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
580
- if (p[0] < 0 || p[0] > width || p[1] < 0 || p[1] > height) continue;
581
- kept.push({ i, j, lon, lat, sx: p[0], sy: p[1] });
611
+ function legLabelPoint(a, b, curved, offset, away) {
612
+ const mx = (a.cx + b.cx) / 2;
613
+ const my = (a.cy + b.cy) / 2;
614
+ if (!curved && offset === 0) return { x: mx, y: my - 4 };
615
+ const dx = b.cx - a.cx;
616
+ const dy = b.cy - a.cy;
617
+ const len = Math.hypot(dx, dy) || 1;
618
+ const nx = -dy / len;
619
+ const ny = dx / len;
620
+ const bow = bowMagnitude(mx, my, nx, ny, offset, len, away);
621
+ const off = bow * 0.5 + Math.sign(bow || 1) * 8;
622
+ return { x: mx + nx * off, y: my + ny * off };
623
+ }
624
+ function buildConnectorLegs(args) {
625
+ const { resolved, poiScreen, palette, width, height, fillAt, labelOnFill } = args;
626
+ const legs = [];
627
+ const lineColor = (tags) => {
628
+ for (const group of resolved.tagGroups) {
629
+ const val = tags[tagAttrKey(group.name)];
630
+ if (!val) continue;
631
+ const entry = group.entries.find(
632
+ (e) => e.value.toLowerCase() === val.toLowerCase()
633
+ );
634
+ if (entry?.color) return entry.color;
582
635
  }
583
- }
584
- if (!kept.length) return mkCurated(curated, project);
585
- const cx = kept.reduce((s, c) => s + c.sx, 0) / kept.length;
586
- const cy = kept.reduce((s, c) => s + c.sy, 0) / kept.length;
587
- const interiorness = (c) => {
588
- let n = 0;
589
- for (let di = -1; di <= 1; di++)
590
- for (let dj = -1; dj <= 1; dj++) {
591
- if (di === 0 && dj === 0) continue;
592
- const ni = c.i + di;
593
- const nj = c.j + dj;
594
- if (ni >= 0 && ni < N && nj >= 0 && nj < N && onLand[ni][nj]) n++;
636
+ return null;
637
+ };
638
+ const placedLegSamples = [];
639
+ const sampleLeg = (a, b, curved, bow) => {
640
+ const pts = [];
641
+ const dx = b.cx - a.cx;
642
+ const dy = b.cy - a.cy;
643
+ const len = Math.hypot(dx, dy) || 1;
644
+ const px = (a.cx + b.cx) / 2 + -dy / len * bow;
645
+ const py = (a.cy + b.cy) / 2 + dx / len * bow;
646
+ for (let i = 0; i < LEG_SAMPLES; i++) {
647
+ const t = i / (LEG_SAMPLES - 1);
648
+ if (!curved && bow === 0) {
649
+ pts.push({ x: a.cx + dx * t, y: a.cy + dy * t });
650
+ } else {
651
+ const u = 1 - t;
652
+ pts.push({
653
+ x: u * u * a.cx + 2 * u * t * px + t * t * b.cx,
654
+ y: u * u * a.cy + 2 * u * t * py + t * t * b.cy
655
+ });
595
656
  }
596
- return n;
657
+ }
658
+ return pts;
597
659
  };
598
- const dist2ToCentre = (c) => (c.sx - cx) ** 2 + (c.sy - cy) ** 2;
599
- const centreLon = kept.reduce((s, c) => s + c.lon, 0) / kept.length;
600
- const centreLat = kept.reduce((s, c) => s + c.lat, 0) / kept.length;
601
- const centreScreen = project(centreLon, centreLat);
602
- const lead = pointInGeometry(geometry, centreLon, centreLat) && centreScreen && Number.isFinite(centreScreen[0]) && Number.isFinite(centreScreen[1]) && centreScreen[0] >= 0 && centreScreen[0] <= width && centreScreen[1] >= 0 && centreScreen[1] <= height ? [
603
- {
604
- lonLat: [centreLon, centreLat],
605
- screen: [centreScreen[0], centreScreen[1]]
660
+ const legPenalty = (pts, fromId, toId) => {
661
+ let pen = 0;
662
+ for (const [id, p] of poiScreen) {
663
+ if (id === fromId || id === toId) continue;
664
+ let dmin = Infinity;
665
+ for (const s of pts) {
666
+ const d = Math.hypot(s.x - p.cx, s.y - p.cy);
667
+ if (d < dmin) dmin = d;
668
+ }
669
+ const clear = p.r + POI_CLEARANCE;
670
+ if (dmin < clear) pen += (clear - dmin) * 12;
606
671
  }
607
- ] : [];
608
- const grid = [...kept].sort(
609
- (a, b) => interiorness(b) - interiorness(a) || dist2ToCentre(a) - dist2ToCentre(b)
610
- ).map((c) => ({
611
- lonLat: [c.lon, c.lat],
612
- screen: [c.sx, c.sy]
613
- }));
614
- const curatedPos = curated ? mkCurated(curated, project) : [];
615
- return [...curatedPos, ...lead, ...grid].slice(0, MAX_COUNTRY_POSITIONS);
616
- }
617
- function mkCurated(curated, project) {
618
- if (!curated) return [];
619
- const p = project(curated[0], curated[1]);
620
- if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) return [];
621
- return [{ lonLat: [curated[0], curated[1]], screen: [p[0], p[1]] }];
622
- }
623
- var usConusProjection = () => geoConicEqualArea().parallels([29.5, 45.5]).rotate([96, 0]);
624
- var alaskaProjection = () => geoConicEqualArea().rotate([154, 0]).center([-2, 58.5]).parallels([55, 65]);
625
- var hawaiiProjection = () => geoMercator();
626
- function projectionFor(family, extent) {
627
- switch (family) {
628
- case "albers-usa":
629
- return usConusProjection();
630
- case "conic-equal-area": {
631
- const s = extent[0][1];
632
- const n = extent[1][1];
633
- return geoConicEqualArea().parallels([s + (n - s) / 6, s + (n - s) * 5 / 6]).center([0, (s + n) / 2]);
634
- }
635
- case "mercator":
636
- return geoMercator();
637
- case "equal-earth":
638
- return geoEqualEarth();
639
- case "equirectangular":
640
- return geoEquirectangular();
641
- case "natural-earth":
642
- return geoNaturalEarth1();
643
- default:
644
- return geoEquirectangular();
645
- }
646
- }
647
- var INSET_STATES = /* @__PURE__ */ new Set(["US-AK", "US-HI"]);
648
- var inAlaska = (lon, lat) => lat >= 51 && (lon <= -129 || lon >= 172);
649
- var inHawaii = (lon, lat) => lat >= 18 && lat <= 23 && lon >= -161 && lon <= -154;
650
- var FOREIGN_BORDER = {
651
- CA: [
652
- "US-AK",
653
- "US-WA",
654
- "US-ID",
655
- "US-MT",
656
- "US-ND",
657
- "US-MN",
658
- "US-MI",
659
- "US-NY",
660
- "US-VT",
661
- "US-NH",
662
- "US-ME"
663
- ],
664
- MX: ["US-CA", "US-AZ", "US-NM", "US-TX"]
665
- };
666
- var US_NON_CONUS = /* @__PURE__ */ new Set([
667
- "US-AK",
668
- "US-HI",
669
- "US-AS",
670
- "US-GU",
671
- "US-MP",
672
- "US-PR",
673
- "US-VI"
674
- ]);
675
- function mapBackgroundColor(palette, isDark = false, _dataActive = false) {
676
- return mix(
677
- palette.colors.blue,
678
- palette.bg,
679
- isDark ? WATER_TINT_DARK : WATER_TINT_LIGHT
680
- );
681
- }
682
- function mapNeutralLandColor(palette, isDark, _dataActive = false) {
683
- return mix(
684
- palette.colors.green,
685
- palette.bg,
686
- isDark ? LAND_TINT_DARK : LAND_TINT_LIGHT
687
- );
688
- }
689
- function buildMapProjection(resolved, data) {
690
- const wantsUsStates = resolved.basemaps.subdivisions.includes("us-states");
691
- const usCrisp = (resolved.projection === "albers-usa" || resolved.projection === "mercator") && wantsUsStates && !!data.naLand;
692
- const worldTopo = usCrisp ? data.worldDetail : resolved.basemaps.world === "detail" ? data.worldDetail : data.worldCoarse;
693
- const worldLayer = new Map(decodeLayer(worldTopo));
694
- if (usCrisp && data.naLand) {
695
- const [nbW, nbS, nbE, nbN] = [-140, 10, -52, 66];
696
- const crisp = decodeLayer(data.naLand);
697
- for (const [iso, cf] of crisp) {
698
- const base = worldLayer.get(iso);
699
- if (!base) continue;
700
- const [[bw, bs], [be, bn]] = geoBounds(base);
701
- if (bw >= nbW && be <= nbE && bs >= nbS && bn <= nbN)
702
- worldLayer.set(iso, { ...cf, properties: base.properties });
672
+ for (const other of placedLegSamples) {
673
+ const shared = [fromId, toId].filter((id) => id === other.fromId || id === other.toId).map((id) => poiScreen.get(id)).filter((p) => !!p);
674
+ for (const s of pts) {
675
+ if (shared.some(
676
+ (sp) => Math.hypot(s.x - sp.cx, s.y - sp.cy) < SHARED_END_RADIUS
677
+ )) {
678
+ continue;
679
+ }
680
+ let dmin = Infinity;
681
+ for (const o of other.pts) {
682
+ const d = Math.hypot(s.x - o.x, s.y - o.y);
683
+ if (d < dmin) dmin = d;
684
+ }
685
+ if (dmin < LEG_CLEARANCE) pen += 6;
686
+ }
703
687
  }
704
- }
705
- const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
706
- const extentOutline = () => {
707
- const [[w, s], [e, n]] = resolved.extent;
708
- const N = 16;
709
- const coords = [];
710
- for (let i = 0; i <= N; i++) {
711
- const t = i / N;
712
- const lon = w + (e - w) * t;
713
- const lat = s + (n - s) * t;
714
- coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
688
+ for (const s of pts) {
689
+ if (s.x < 0 || s.x > width || s.y < 0 || s.y > height) pen += 20;
715
690
  }
716
- return {
717
- type: "Feature",
718
- properties: {},
719
- geometry: { type: "MultiPoint", coordinates: coords }
720
- };
691
+ return pen;
721
692
  };
722
- let fitFeatures;
723
- if (resolved.projection === "albers-usa" && usLayer) {
724
- fitFeatures = [...usLayer.entries()].filter(([iso]) => !US_NON_CONUS.has(iso)).map(([, f]) => f);
725
- const neighborPoints = resolved.pois.filter((p) => !inAlaska(p.lon, p.lat) && !inHawaii(p.lon, p.lat)).map((p) => [p.lon, p.lat]);
726
- if (neighborPoints.length > 0) {
727
- fitFeatures.push({
728
- type: "Feature",
729
- properties: {},
730
- geometry: { type: "MultiPoint", coordinates: neighborPoints }
731
- });
732
- }
733
- for (const r of resolved.regions) {
734
- if (r.layer === "country" && (r.iso === "CA" || r.iso === "MX")) {
735
- const cf = worldLayer.get(r.iso);
736
- if (cf) fitFeatures.push(cf);
693
+ const chooseArcBow = (a, b, fromId, toId, away) => {
694
+ const mx = (a.cx + b.cx) / 2;
695
+ const my = (a.cy + b.cy) / 2;
696
+ const dx = b.cx - a.cx;
697
+ const dy = b.cy - a.cy;
698
+ const len = Math.hypot(dx, dy) || 1;
699
+ const nx = -dy / len;
700
+ const ny = dx / len;
701
+ const def = bowMagnitude(mx, my, nx, ny, 0, len, away);
702
+ const candidates = [
703
+ def,
704
+ -def,
705
+ def * 1.6,
706
+ -def * 1.6,
707
+ def * 2.2,
708
+ -def * 2.2
709
+ ];
710
+ let best = def;
711
+ let bestPen = Infinity;
712
+ for (const c of candidates) {
713
+ const pen = legPenalty(sampleLeg(a, b, true, c), fromId, toId);
714
+ if (pen < bestPen - 1e-6) {
715
+ best = c;
716
+ bestPen = pen;
737
717
  }
718
+ if (bestPen === 0) break;
738
719
  }
739
- } else {
740
- fitFeatures = [extentOutline()];
741
- }
742
- const fitTarget = { type: "FeatureCollection", features: fitFeatures };
743
- const projection = projectionFor(resolved.projection, resolved.extent);
744
- if (resolved.projection !== "albers-usa") {
745
- let centerLon = (resolved.extent[0][0] + resolved.extent[1][0]) / 2;
746
- if (centerLon > 180) centerLon -= 360;
747
- projection.rotate([-centerLon, 0]);
748
- }
749
- const fitGB = geoBounds(fitTarget);
750
- const fitIsGlobal = fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
751
- return {
752
- projection,
753
- fitTarget,
754
- fitIsGlobal,
755
- worldLayer,
756
- usLayer,
757
- usCrisp,
758
- wantsUsStates,
759
- worldTopo
720
+ return best;
760
721
  };
761
- }
762
- function parsePathRings(d) {
763
- const rings = [];
764
- let cur = [];
765
- const re = /([MLZ])([^MLZ]*)/g;
766
- let m;
767
- while (m = re.exec(d)) {
768
- if (m[1] === "Z") {
769
- if (cur.length) rings.push(cur);
770
- cur = [];
771
- continue;
772
- }
773
- if (m[1] === "M" && cur.length) {
774
- rings.push(cur);
775
- cur = [];
776
- }
777
- const nums = m[2].split(/[ ,]+/).map(Number);
778
- for (let i = 0; i + 1 < nums.length; i += 2) {
779
- const x = nums[i];
780
- const y = nums[i + 1];
781
- if (Number.isFinite(x) && Number.isFinite(y)) cur.push([x, y]);
722
+ const routeLegVals = resolved.routes.flatMap((rt) => rt.legs).map((l) => Number(l.value)).filter((n) => Number.isFinite(n) && n > 0);
723
+ const rlMin = routeLegVals.length ? Math.min(...routeLegVals) : 0;
724
+ const rlMax = routeLegVals.length ? Math.max(...routeLegVals) : 0;
725
+ const routeWidthFor = (v) => {
726
+ if (!Number.isFinite(v) || v <= 0 || rlMax <= 0) return W_MIN;
727
+ const t = rlMax > rlMin ? (v - rlMin) / (rlMax - rlMin) : 1;
728
+ return W_MIN + t * (W_MAX - W_MIN);
729
+ };
730
+ for (const rt of resolved.routes) {
731
+ const stopPts = rt.stopIds.map((id) => poiScreen.get(id)).filter((p) => !!p);
732
+ const center = stopPts.length >= 3 ? {
733
+ x: stopPts.reduce((s, p) => s + p.cx, 0) / stopPts.length,
734
+ y: stopPts.reduce((s, p) => s + p.cy, 0) / stopPts.length
735
+ } : void 0;
736
+ for (const leg of rt.legs) {
737
+ const a = poiScreen.get(leg.fromId);
738
+ const b = poiScreen.get(leg.toId);
739
+ if (!a || !b) continue;
740
+ const curvedLeg = leg.style === "arc";
741
+ const chosenBow = curvedLeg ? chooseArcBow(a, b, leg.fromId, leg.toId, center) : 0;
742
+ const lp = legLabelPoint(a, b, curvedLeg, chosenBow, center);
743
+ const bow = {
744
+ curved: curvedLeg,
745
+ offset: chosenBow,
746
+ center,
747
+ labelX: lp.x,
748
+ labelY: lp.y
749
+ };
750
+ placedLegSamples.push({
751
+ fromId: leg.fromId,
752
+ toId: leg.toId,
753
+ pts: sampleLeg(a, b, curvedLeg, chosenBow)
754
+ });
755
+ const routeLabelStyle = leg.label !== void 0 ? labelOnFill(fillAt(bow.labelX, bow.labelY)) : void 0;
756
+ const routeVal = Number(leg.value);
757
+ legs.push({
758
+ d: legPath(a, b, bow.curved, bow.offset, bow.center),
759
+ width: routeWidthFor(routeVal),
760
+ color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
761
+ arrow: true,
762
+ fromId: leg.fromId,
763
+ toId: leg.toId,
764
+ ...Number.isFinite(routeVal) && routeVal > 0 && { value: routeVal },
765
+ ...Object.keys(leg.tags).length > 0 && { tags: leg.tags },
766
+ lineNumber: leg.lineNumber,
767
+ ...leg.label !== void 0 && {
768
+ label: leg.label,
769
+ labelX: bow.labelX,
770
+ labelY: bow.labelY,
771
+ labelColor: routeLabelStyle.color,
772
+ labelHalo: routeLabelStyle.halo,
773
+ labelHaloColor: routeLabelStyle.haloColor
774
+ }
775
+ });
782
776
  }
783
777
  }
784
- if (cur.length) rings.push(cur);
785
- return rings;
786
- }
787
- function dropAntimeridianWrapSlivers(d, width, height) {
788
- const rings = parsePathRings(d);
789
- if (rings.length <= 1) return d;
790
- const eps = 0.75;
791
- const minArea = 3e-3 * width * height;
792
- const ringArea = (r) => {
793
- let s = 0;
794
- for (let i = 0; i < r.length; i++) {
795
- const a = r[i];
796
- const b = r[(i + 1) % r.length];
797
- s += a[0] * b[1] - b[0] * a[1];
798
- }
799
- return Math.abs(s) / 2;
778
+ const weightVals = resolved.edges.map((e) => Number(e.meta["width"])).filter((n) => Number.isFinite(n) && n > 0);
779
+ const wMin = weightVals.length ? Math.min(...weightVals) : 0;
780
+ const wMax = weightVals.length ? Math.max(...weightVals) : 0;
781
+ const widthFor = (e) => {
782
+ const v = Number(e.meta["width"]);
783
+ if (!Number.isFinite(v) || v <= 0 || wMax <= 0) return W_MIN;
784
+ const t = wMax > wMin ? (v - wMin) / (wMax - wMin) : 1;
785
+ return W_MIN + t * (W_MAX - W_MIN);
800
786
  };
801
- const areas = rings.map(ringArea);
802
- const maxArea = Math.max(...areas);
803
- const onVEdge = (a, b) => Math.abs(a[0]) <= eps && Math.abs(b[0]) <= eps || Math.abs(a[0] - width) <= eps && Math.abs(b[0] - width) <= eps;
804
- let dropped = false;
805
- const kept = rings.filter((r, idx) => {
806
- if (areas[idx] >= maxArea || areas[idx] >= minArea) return true;
807
- const touches = r.some((p, i) => onVEdge(p, r[(i + 1) % r.length]));
808
- if (touches) {
809
- dropped = true;
810
- return false;
811
- }
812
- return true;
813
- });
814
- if (!dropped) return d;
815
- return kept.map(
816
- (r) => r.map((p, i) => (i ? "L" : "M") + p[0] + "," + p[1]).join("") + "Z"
817
- ).join("");
818
- }
819
- function albersSkewFallback(resolved, data, width, height) {
820
- if (resolved.projection !== "albers-usa") return false;
821
- if (!resolved.basemaps.subdivisions.includes("us-states")) return false;
822
- if (!(width > 0) || !(height > 0)) return false;
823
- const akRef = resolved.regions.some((r) => r.iso === "US-AK") || resolved.pois.some((p) => inAlaska(p.lon, p.lat));
824
- const hiRef = resolved.regions.some((r) => r.iso === "US-HI") || resolved.pois.some((p) => inHawaii(p.lon, p.lat));
825
- if (akRef || hiRef) return false;
826
- const { projection, fitTarget, usLayer } = buildMapProjection(resolved, data);
827
- if (!usLayer || ![...usLayer.keys()].some((iso) => !US_NON_CONUS.has(iso)))
828
- return false;
829
- projection.fitSize([1e3, 1e3], fitTarget);
830
- const b = geoPath(projection).bounds(fitTarget);
831
- const conusAspect = (b[1][0] - b[0][0]) / (b[1][1] - b[0][1]);
832
- if (!(conusAspect > 0)) return false;
833
- const canvasAspect = width / height;
834
- const skew = Math.max(canvasAspect / conusAspect, conusAspect / canvasAspect);
835
- return skew > ALBERS_SKEW_MAX;
836
- }
837
- var REGION_GEO_MEMO = /* @__PURE__ */ new WeakMap();
838
- var REGION_GEO_MAX_FITS = 8;
839
- function regionGeoBucket(resolved, projKey) {
840
- let byFit = REGION_GEO_MEMO.get(resolved);
841
- if (!byFit) {
842
- byFit = /* @__PURE__ */ new Map();
843
- REGION_GEO_MEMO.set(resolved, byFit);
787
+ const pairGroups = /* @__PURE__ */ new Map();
788
+ for (const e of resolved.edges) {
789
+ const key = [e.fromId, e.toId].sort().join(" ");
790
+ const arr = pairGroups.get(key);
791
+ if (arr) arr.push(e);
792
+ else pairGroups.set(key, [e]);
844
793
  }
845
- let bucket = byFit.get(projKey);
846
- if (!bucket) {
847
- bucket = /* @__PURE__ */ new Map();
848
- byFit.set(projKey, bucket);
849
- if (byFit.size > REGION_GEO_MAX_FITS) {
850
- const oldest = byFit.keys().next().value;
851
- if (oldest !== void 0) byFit.delete(oldest);
852
- }
794
+ for (const groupEdges of pairGroups.values()) {
795
+ const ordered = [...groupEdges].sort((a, b) => a.lineNumber - b.lineNumber);
796
+ const n = ordered.length;
797
+ ordered.forEach((e, i) => {
798
+ const a = poiScreen.get(e.fromId);
799
+ const b = poiScreen.get(e.toId);
800
+ if (!a || !b) return;
801
+ const fanOffset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
802
+ const curved = e.style === "arc" || n > 1;
803
+ const edgeBow = curved && n === 1 ? chooseArcBow(a, b, e.fromId, e.toId) : fanOffset;
804
+ const lp = legLabelPoint(a, b, curved, edgeBow);
805
+ const bow = {
806
+ curved,
807
+ offset: edgeBow,
808
+ labelX: lp.x,
809
+ labelY: lp.y
810
+ };
811
+ placedLegSamples.push({
812
+ fromId: e.fromId,
813
+ toId: e.toId,
814
+ pts: sampleLeg(a, b, curved, edgeBow)
815
+ });
816
+ const edgeLabelStyle = e.label !== void 0 ? labelOnFill(fillAt(bow.labelX, bow.labelY)) : void 0;
817
+ const edgeVal = Number(e.meta["width"]);
818
+ legs.push({
819
+ d: legPath(a, b, bow.curved, bow.offset),
820
+ width: widthFor(e),
821
+ color: lineColor(e.tags) ?? mix(palette.text, palette.bg, 66),
822
+ arrow: e.directed,
823
+ fromId: e.fromId,
824
+ toId: e.toId,
825
+ ...Number.isFinite(edgeVal) && edgeVal > 0 && { value: edgeVal },
826
+ ...Object.keys(e.tags).length > 0 && { tags: e.tags },
827
+ lineNumber: e.lineNumber,
828
+ ...e.label !== void 0 && {
829
+ label: e.label,
830
+ labelX: bow.labelX,
831
+ labelY: bow.labelY,
832
+ labelColor: edgeLabelStyle.color,
833
+ labelHalo: edgeLabelStyle.halo,
834
+ labelHaloColor: edgeLabelStyle.haloColor
835
+ }
836
+ });
837
+ });
853
838
  }
854
- return bucket;
839
+ return legs;
855
840
  }
856
- function layoutMap(resolvedIn, data, size, opts) {
857
- const { palette, isDark } = opts;
858
- const { width, height } = size;
859
- let resolved = resolvedIn;
860
- if (albersSkewFallback(resolved, data, width, height)) {
861
- resolved = { ...resolved, projection: "conic-equal-area" };
862
- }
841
+
842
+ // src/map/insets.ts
843
+ import { geoPath } from "d3-geo";
844
+ var PAD = 8;
845
+ var GAP = 12;
846
+ var BW = 8;
847
+ function layoutInsets(args) {
863
848
  const {
864
849
  projection,
865
- fitTarget,
866
- fitIsGlobal,
867
- worldLayer,
868
850
  usLayer,
869
- usCrisp,
870
- worldTopo
871
- } = buildMapProjection(resolved, data);
872
- const usContext = usLayer !== null;
873
- const regionStroke = isDark ? mix(palette.border, palette.text, 65) : mix(palette.text, palette.bg, 78);
874
- const values = resolved.regions.filter((r) => r.value !== void 0).map((r) => r.value);
875
- const rampMin = values.length > 0 ? Math.min(...values) : 0;
876
- const rampMax = Math.max(...values);
877
- const rampHue = resolveColor(resolved.directives.regionMetricColor ?? "", palette) ?? palette.colors.red;
878
- const rampLow = resolved.directives.regionMetricLowColor ? resolveColor(resolved.directives.regionMetricLowColor, palette) ?? void 0 : void 0;
879
- const hasRamp = values.length > 0;
880
- const VALUE_NAME = hasRamp ? resolved.directives.regionMetric?.trim() || "Value" : null;
881
- const matchColorGroup = (v) => {
882
- const lv = v.trim().toLowerCase();
883
- if (lv === "none") return null;
884
- if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
885
- const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
886
- return tg ? tg.name : v;
851
+ worldLayer,
852
+ regionById,
853
+ width,
854
+ height,
855
+ fitPad,
856
+ pathDigits,
857
+ nonConus,
858
+ colorizeActive,
859
+ colorByIso,
860
+ neutralFill,
861
+ foreignFill,
862
+ regionStroke,
863
+ colorizeStroke,
864
+ regionFill,
865
+ alaskaProjection: alaskaProjection2,
866
+ hawaiiProjection: hawaiiProjection2,
867
+ akRef,
868
+ hiRef
869
+ } = args;
870
+ const insets = [];
871
+ const insetRegions = [];
872
+ const insetLabelSeeds = [];
873
+ const yB = height - fitPad;
874
+ const coast = /* @__PURE__ */ new Map();
875
+ const addPt = (lon, lat) => {
876
+ const p = projection([lon, lat]);
877
+ if (!p) return;
878
+ const bi = Math.floor(p[0] / BW);
879
+ const cur = coast.get(bi);
880
+ if (cur === void 0 || p[1] > cur) coast.set(bi, p[1]);
887
881
  };
888
- const fillGroupNames = /* @__PURE__ */ new Set();
889
- for (const g of resolved.tagGroups) {
890
- const k = g.name.toLowerCase();
891
- if (resolved.regions.some((r) => r.tags[k]) || resolved.pois.some((p) => p.tags[k]))
892
- fillGroupNames.add(g.name);
893
- }
894
- const firstFillGroup = resolved.tagGroups.find((g) => fillGroupNames.has(g.name))?.name ?? null;
895
- const override = opts.activeGroup;
896
- let activeGroup;
897
- if (override !== void 0) {
898
- activeGroup = override === null ? null : matchColorGroup(override);
899
- } else if (resolved.directives.activeTag !== void 0) {
900
- activeGroup = matchColorGroup(resolved.directives.activeTag);
901
- } else {
902
- activeGroup = VALUE_NAME ?? firstFillGroup ?? resolved.tagGroups[0]?.name ?? null;
882
+ const walk = (co) => {
883
+ if (Array.isArray(co) && typeof co[0] === "number")
884
+ addPt(co[0], co[1]);
885
+ else if (Array.isArray(co)) for (const c of co) walk(c);
886
+ };
887
+ for (const [iso, f] of usLayer) {
888
+ if (nonConus.has(iso)) continue;
889
+ walk(f.geometry.coordinates);
903
890
  }
904
- const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
905
- const mutedBasemap = activeIsScore || activeGroup !== null && fillGroupNames.has(activeGroup);
906
- const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
907
- const water = mapBackgroundColor(palette, isDark, mutedBasemap);
908
- const lakeStroke = mix(regionStroke, water, 45);
909
- const foreignFill = mix(
910
- palette.colors.gray,
911
- palette.bg,
912
- mutedBasemap ? isDark ? MUTED_FOREIGN_DARK : MUTED_FOREIGN_LIGHT : isDark ? FOREIGN_TINT_DARK : FOREIGN_TINT_LIGHT
913
- );
914
- const contrastFloorTone = (tone, ...substrates) => {
915
- const clears = (t) => substrates.every((s) => contrastRatio(t, s) >= REGION_LABEL_HALO_RATIO);
916
- if (clears(tone)) return tone;
917
- for (let s = 10; s <= 100; s += 10) {
918
- const t = mix(palette.text, tone, s);
919
- if (clears(t)) return t;
891
+ const at = (x) => {
892
+ const bi = Math.floor(x / BW);
893
+ let y = -Infinity;
894
+ for (let k = bi - 1; k <= bi + 1; k++) {
895
+ const v = coast.get(k);
896
+ if (v !== void 0 && v > y) y = v;
897
+ }
898
+ return y;
899
+ };
900
+ const coastFloor = (x0, xr) => {
901
+ const n = 24;
902
+ let maxY = -Infinity;
903
+ for (let i = 0; i <= n; i++) {
904
+ const y = at(x0 + (xr - x0) * i / n);
905
+ if (y > maxY) maxY = y;
906
+ }
907
+ return maxY;
908
+ };
909
+ const placeInset = (iso, proj, boxX, iwReq) => {
910
+ const f = usLayer.get(iso);
911
+ if (!f) return boxX;
912
+ const x0 = boxX;
913
+ const iw = Math.min(iwReq, width - fitPad - x0 - 2 * PAD);
914
+ if (iw < 24) return boxX;
915
+ const xr = x0 + iw + 2 * PAD;
916
+ const floor = coastFloor(x0, xr);
917
+ const topGuess = floor > -Infinity ? floor + GAP : yB - height * 0.42;
918
+ proj.fitWidth(iw, f);
919
+ const bb = geoPath(proj).bounds(f);
920
+ const sh = Number.isFinite(bb[0][0]) ? bb[1][1] - bb[0][1] : iw;
921
+ const needH = sh + 2 * PAD;
922
+ let topFit = topGuess;
923
+ const bottom = Math.min(topFit + needH, yB);
924
+ if (bottom - topFit < needH) topFit = bottom - needH;
925
+ proj.fitExtent(
926
+ [
927
+ [x0 + PAD, topFit + PAD],
928
+ [xr - PAD, bottom - PAD]
929
+ ],
930
+ f
931
+ );
932
+ const insetPath = geoPath(proj).digits(pathDigits);
933
+ const d = insetPath(f) ?? "";
934
+ if (!d) return xr;
935
+ let contextLand;
936
+ if (iso === "US-AK") {
937
+ const can = worldLayer.get("CA");
938
+ const cd = can ? insetPath(can) ?? "" : "";
939
+ if (cd)
940
+ contextLand = {
941
+ d: cd,
942
+ fill: colorizeActive ? colorByIso.get("CA") ?? foreignFill : foreignFill
943
+ };
920
944
  }
921
- return palette.text;
945
+ const r = regionById.get(iso);
946
+ let fill = colorizeActive ? colorByIso.get(iso) ?? neutralFill : neutralFill;
947
+ let lineNumber = -1;
948
+ if (r?.layer === "us-state") {
949
+ fill = regionFill(r);
950
+ lineNumber = r.lineNumber;
951
+ }
952
+ insets.push({
953
+ x: x0,
954
+ y: topFit,
955
+ w: xr - x0,
956
+ h: bottom - topFit,
957
+ points: [
958
+ [x0, topFit],
959
+ [xr, topFit],
960
+ [xr, bottom],
961
+ [x0, bottom]
962
+ ],
963
+ // The FITTED inset projection (just fit to this box) — captured so the
964
+ // geo-query can invert pixels inside the frame back to AK/HI coords.
965
+ projection: proj,
966
+ ...contextLand && { contextLand }
967
+ });
968
+ insetRegions.push({
969
+ id: iso,
970
+ d,
971
+ fill,
972
+ stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
973
+ lineNumber,
974
+ layer: "us-state",
975
+ ...r?.value !== void 0 && { value: r.value },
976
+ ...r && Object.keys(r.tags).length > 0 && { tags: r.tags }
977
+ });
978
+ const ctr = geoPath(proj).centroid(f);
979
+ if (Number.isFinite(ctr[0])) {
980
+ const name = f.properties?.name ?? iso;
981
+ insetLabelSeeds.push({ x: ctr[0], y: ctr[1], iso, name, lineNumber });
982
+ }
983
+ return xr;
922
984
  };
923
- const backdropLandTone = contrastFloorTone(
924
- palette.textMuted,
985
+ let akRight = fitPad;
986
+ if (akRef)
987
+ akRight = placeInset("US-AK", alaskaProjection2(), fitPad, width * 0.18);
988
+ if (hiRef)
989
+ placeInset(
990
+ "US-HI",
991
+ hawaiiProjection2(),
992
+ akRef ? akRight + 24 : fitPad,
993
+ width * 0.12
994
+ );
995
+ return { insets, insetRegions, insetLabelSeeds };
996
+ }
997
+
998
+ // src/map/label-metrics.ts
999
+ var FONT2 = 11;
1000
+ var LABEL_PADX = 6;
1001
+ var LABEL_PADY = 3;
1002
+ var VALUE_GAP = 1;
1003
+ var VALUE_FONT_FRAC = 0.82;
1004
+ var REGION_FONT_MAX_ORIENT = 22;
1005
+ var REGION_SIZE_FRAC_MIN = 0.06;
1006
+ var REGION_SIZE_FRAC_MAX = 0.32;
1007
+ var SUBJECT_FONT_MAX = 18;
1008
+ var SUBJECT_MIN_PROMINENCE = 13;
1009
+ var SUBJECT_LEADER_FONT = 15;
1010
+ function createLabelMetrics(size) {
1011
+ const canvasLinear = Math.sqrt(Math.max(1, size.width * size.height));
1012
+ const labelW = (text, font = FONT2) => measureLegendText(text, font) + 2 * LABEL_PADX;
1013
+ return {
1014
+ labelW,
1015
+ labelH: FONT2 + 2 * LABEL_PADY,
1016
+ // `font` defaults to the base size (every existing call is byte-identical);
1017
+ // the post-placement growth pass passes a larger size to test an upscaled fit.
1018
+ stackW: (text, valueText, font = FONT2) => Math.max(
1019
+ labelW(text, font),
1020
+ valueText ? measureLegendText(valueText, Math.round(font * VALUE_FONT_FRAC)) + 2 * LABEL_PADX : 0
1021
+ ),
1022
+ stackH: (hasValue, font = FONT2) => {
1023
+ const lh = font + 2 * LABEL_PADY;
1024
+ return hasValue ? lh + VALUE_GAP + Math.round(font * VALUE_FONT_FRAC) : lh;
1025
+ },
1026
+ sizeT: (boxW, boxH) => {
1027
+ const frac = Math.sqrt(Math.max(0, boxW * boxH)) / canvasLinear;
1028
+ return Math.min(
1029
+ 1,
1030
+ Math.max(
1031
+ 0,
1032
+ (frac - REGION_SIZE_FRAC_MIN) / (REGION_SIZE_FRAC_MAX - REGION_SIZE_FRAC_MIN)
1033
+ )
1034
+ );
1035
+ }
1036
+ };
1037
+ }
1038
+
1039
+ // src/map/region-labels.ts
1040
+ function placeRegionLabels(args) {
1041
+ const {
1042
+ resolved,
1043
+ palette,
1044
+ width,
1045
+ height,
1046
+ path,
1047
+ project,
1048
+ worldLayer,
1049
+ usLayer,
1050
+ regionById,
1051
+ regions,
1052
+ pois,
1053
+ insetLabelSeeds,
1054
+ topReserved,
1055
+ topPad,
1056
+ markers,
1057
+ collides,
925
1058
  neutralFill,
926
- water
927
- );
928
- const backdropWaterTone = contrastFloorTone(
929
- mix(palette.colors.blue, palette.textMuted, 50),
930
1059
  water,
931
- neutralFill
932
- );
933
- const hasDirectColor = resolved.regions.some((r) => r.color !== void 0);
934
- const colorizeActive = resolved.directives.noColorize !== true && !hasRamp && !hasDirectColor && fillGroupNames.size === 0;
935
- const colorByIso = /* @__PURE__ */ new Map();
936
- if (colorizeActive) {
937
- const adjacency = /* @__PURE__ */ new Map();
938
- const addEdges = (src) => {
939
- for (const [iso, ns] of src) {
940
- const cur = adjacency.get(iso);
941
- if (cur) cur.push(...ns);
942
- else adjacency.set(iso, [...ns]);
943
- }
944
- };
945
- addEdges(buildAdjacency(worldTopo));
946
- if (usLayer) {
947
- addEdges(buildAdjacency(data.usStates));
948
- for (const [country, states] of Object.entries(FOREIGN_BORDER)) {
949
- const cn = adjacency.get(country);
950
- if (!cn) continue;
951
- for (const st of states) {
952
- const sn = adjacency.get(st);
953
- if (!sn) continue;
954
- cn.push(st);
955
- sn.push(country);
956
- }
1060
+ foreignFill,
1061
+ backdropLandTone,
1062
+ regionFill,
1063
+ fillAt,
1064
+ regionValueStr,
1065
+ isCompact,
1066
+ usChoroplethZoom,
1067
+ metrics,
1068
+ pushRegionLabel,
1069
+ regionLabelRect,
1070
+ labels,
1071
+ labeledRegionIds,
1072
+ regionLabelGuards
1073
+ } = args;
1074
+ const { labelW, labelH, stackW, stackH, sizeT } = metrics;
1075
+ const frameContainers = new Set(resolved.poiFrameContainers);
1076
+ const entries = regions.map((r) => {
1077
+ const isContainer = frameContainers.has(r.id);
1078
+ if (r.layer === "base" && !isContainer || r.label === void 0)
1079
+ return null;
1080
+ const isUsState = r.layer === "us-state" || r.id.startsWith("US-");
1081
+ const f = isUsState ? usLayer?.get(r.id) : worldLayer.get(r.id);
1082
+ if (!f) return null;
1083
+ const [[x0, y0], [x1, y1]] = path.bounds(f);
1084
+ const boxW = x1 - x0;
1085
+ const boxH = y1 - y0;
1086
+ const abbrev = isUsState && !usChoroplethZoom ? r.id.replace(/^US-/, "") : void 0;
1087
+ const candidates = abbrev !== void 0 ? isCompact ? [abbrev, r.label] : isContainer ? [r.label] : [r.label, abbrev] : [r.label];
1088
+ const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : void 0;
1089
+ const c = anchor ? project(anchor[0], anchor[1]) : path.centroid(f);
1090
+ if (!c || !Number.isFinite(c[0])) return null;
1091
+ return { r, c, boxW, boxH, area: boxW * boxH, candidates };
1092
+ }).filter((e) => e !== null).sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
1093
+ const isEmptyFill = (f) => f === water || f === neutralFill || f === foreignFill;
1094
+ const POI_LABEL_PAD = 14;
1095
+ const poiObstacles = pois.map((p) => ({
1096
+ x: p.cx - p.r - POI_LABEL_PAD,
1097
+ y: p.cy - p.r - POI_LABEL_PAD,
1098
+ w: 2 * (p.r + POI_LABEL_PAD),
1099
+ h: 2 * (p.r + POI_LABEL_PAD)
1100
+ }));
1101
+ const placement = createLabelPlacement({
1102
+ reserved: topReserved,
1103
+ obstacles: poiObstacles,
1104
+ markers
1105
+ });
1106
+ const SHORT_HOP_MAX = Math.max(46, Math.min(width, height) * 0.11);
1107
+ const STEP = 4;
1108
+ const HOP_GAP = 9;
1109
+ const CLEAR_STEP = 6;
1110
+ const CLEAR_MAX = SHORT_HOP_MAX * 3;
1111
+ const CLEAR_TOL = CLEAR_STEP;
1112
+ const clearanceAt = (px, py, ox, oy) => {
1113
+ const baseAng = Math.atan2(oy, ox);
1114
+ let minClear = CLEAR_MAX;
1115
+ for (let k = -2; k <= 2; k++) {
1116
+ const a = baseAng + k * Math.PI / 4;
1117
+ const rx = Math.cos(a);
1118
+ const ry = Math.sin(a);
1119
+ let d = CLEAR_STEP;
1120
+ for (; d <= CLEAR_MAX; d += CLEAR_STEP) {
1121
+ const qx = px + rx * d;
1122
+ const qy = py + ry * d;
1123
+ if (qx < FIT_PAD || qy < topPad || qx > width - FIT_PAD || qy > height - FIT_PAD)
1124
+ break;
1125
+ if (fillAt(qx, qy) !== water) break;
957
1126
  }
1127
+ if (d < minClear) minClear = d;
958
1128
  }
959
- const { byIso, huesNeeded } = assignColors(
960
- [...adjacency.keys()],
961
- adjacency
962
- );
963
- const tints = politicalTints(palette, huesNeeded, isDark);
964
- for (const [iso, idx] of byIso) colorByIso.set(iso, tints[idx]);
965
- }
966
- const colorizeStroke = (fill) => mix(fill, palette.text, 35);
967
- const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
968
- const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
969
- const fillForValue = (s) => {
970
- const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
971
- if (rampLow !== void 0)
972
- return valueRampColor(rampLow, rampHue, t, { isDark });
973
- const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
974
- return mix(rampHue, rampBase, pct);
975
- };
976
- const tagFill = (tags, groupName) => {
977
- if (!groupName) return null;
978
- const group = resolved.tagGroups.find(
979
- (g) => g.name.toLowerCase() === groupName.toLowerCase()
980
- );
981
- if (!group) return null;
982
- const val = tags[tagAttrKey(group.name)] ?? group.defaultValue;
983
- if (!val) return null;
984
- const entry = group.entries.find(
985
- (e) => e.value.toLowerCase() === val.toLowerCase()
986
- );
987
- if (!entry?.color) return null;
988
- return mix(
989
- entry.color,
990
- palette.bg,
991
- isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT
992
- );
993
- };
994
- const directFill = (name) => {
995
- const hex = name ? resolveColor(name, palette) : null;
996
- if (!hex) return null;
997
- return mix(hex, palette.bg, isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT);
1129
+ return minClear;
998
1130
  };
999
- const regionFill = (r) => {
1000
- const direct = directFill(r.color);
1001
- if (direct) return direct;
1002
- if (activeIsScore) {
1003
- return r.value !== void 0 ? fillForValue(r.value) : neutralFill;
1131
+ const tryShortHopCallout = (cx, cy, fill, name, value, font = FONT2, gap = HOP_GAP, maxLen = SHORT_HOP_MAX) => {
1132
+ const chipW = stackW(name, value, font);
1133
+ const chipH = stackH(value !== void 0, font);
1134
+ const D = Math.SQRT1_2;
1135
+ const dirs = [
1136
+ [0, -1],
1137
+ // north
1138
+ [0, 1],
1139
+ // south
1140
+ [1, 0],
1141
+ // east
1142
+ [-1, 0],
1143
+ // west
1144
+ [D, -D],
1145
+ // NE
1146
+ [D, D],
1147
+ // SE
1148
+ [-D, -D],
1149
+ // NW
1150
+ [-D, D]
1151
+ // SW
1152
+ ];
1153
+ let best = null;
1154
+ for (const [dx, dy] of dirs) {
1155
+ let ex = cx;
1156
+ let ey = cy;
1157
+ let steps = 0;
1158
+ const maxSteps = Math.ceil(maxLen / STEP) + 1;
1159
+ while (steps < maxSteps && fillAt(ex, ey) === fill) {
1160
+ ex += dx * STEP;
1161
+ ey += dy * STEP;
1162
+ steps++;
1163
+ }
1164
+ if (fillAt(ex, ey) === fill) continue;
1165
+ const halfAlong = (Math.abs(dx) * chipW + Math.abs(dy) * chipH) / 2;
1166
+ const ccx = ex + dx * (gap + halfAlong);
1167
+ const ccy = ey + dy * (gap + halfAlong);
1168
+ const rect = {
1169
+ x: ccx - chipW / 2,
1170
+ y: ccy - chipH / 2,
1171
+ w: chipW,
1172
+ h: chipH
1173
+ };
1174
+ if (rect.x < FIT_PAD || rect.y < topPad || rect.x + rect.w > width - FIT_PAD || rect.y + rect.h > height - FIT_PAD)
1175
+ continue;
1176
+ const sx = [rect.x + 2, ccx, rect.x + rect.w - 2];
1177
+ const sy = [rect.y + 2, ccy, rect.y + rect.h - 2];
1178
+ let onEmpty = true;
1179
+ for (const px of sx)
1180
+ for (const py of sy)
1181
+ if (!isEmptyFill(fillAt(px, py))) {
1182
+ onEmpty = false;
1183
+ break;
1184
+ }
1185
+ if (!onEmpty) continue;
1186
+ if (placement.hitsPlaced(rect)) continue;
1187
+ if (placement.hitsObstacle(rect)) continue;
1188
+ if (placement.hitsMarker(rect)) continue;
1189
+ const innerX = ccx - dx * (chipW / 2);
1190
+ const innerY = ccy - dy * (chipH / 2);
1191
+ const len = Math.hypot(innerX - cx, innerY - cy);
1192
+ if (len > maxLen) continue;
1193
+ const leader = [cx, cy, innerX, innerY];
1194
+ if (placement.leaderCrosses(leader)) continue;
1195
+ if (placement.leaderHitsPlaced(leader)) continue;
1196
+ const clear = clearanceAt(ccx, ccy, dx, dy);
1197
+ const better = best === null || clear > best.clear + CLEAR_TOL || clear >= best.clear - CLEAR_TOL && len < best.len;
1198
+ if (better)
1199
+ best = {
1200
+ x: ccx,
1201
+ y: ccy,
1202
+ rect,
1203
+ leader,
1204
+ len,
1205
+ clear
1206
+ };
1004
1207
  }
1005
- if (colorizeActive) return (r.iso && colorByIso.get(r.iso)) ?? neutralFill;
1006
- return tagFill(r.tags, activeGroup) ?? neutralFill;
1208
+ return best ? { x: best.x, y: best.y, rect: best.rect, leader: best.leader } : null;
1007
1209
  };
1008
- const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
1009
- let legend = null;
1010
- if (!resolved.directives.noLegend) {
1011
- const legendTagGroups = resolved.tagGroups.map((g) => ({
1012
- name: g.name,
1013
- entries: g.entries.map((e) => ({ value: e.value, color: e.color }))
1014
- }));
1015
- if (legendTagGroups.length > 0 || hasRamp) {
1016
- legend = {
1017
- tagGroups: legendTagGroups,
1018
- activeGroup,
1019
- ...hasRamp && {
1020
- ramp: {
1021
- ...resolved.directives.regionMetric !== void 0 && {
1022
- metric: resolved.directives.regionMetric
1210
+ for (const { r, c, boxW, boxH, candidates } of entries) {
1211
+ const valStr = regionValueStr(r.value);
1212
+ const fitsRegions = (rect) => !placement.hitsPlaced(rect);
1213
+ const fitsPois = (rect) => !placement.hitsObstacle(rect);
1214
+ const isSubject = regionById.has(r.id);
1215
+ const extremesOnFill = (ax, ay, text) => {
1216
+ const halfW = measureLegendText(text, FONT2) / 2;
1217
+ return fillAt(ax - halfW, ay) === r.fill && fillAt(ax + halfW, ay) === r.fill;
1218
+ };
1219
+ if (isSubject && valStr === void 0 && r.label !== void 0) {
1220
+ const name = candidates[0];
1221
+ let best = -1;
1222
+ for (let f = SUBJECT_FONT_MAX; f >= FONT2; f--) {
1223
+ if (measureLegendText(name, f) > boxW || f + 2 * LABEL_PADY > boxH)
1224
+ continue;
1225
+ const rect = regionLabelRect(c[0], c[1], name, void 0, f);
1226
+ if (!fitsRegions(rect) || !fitsPois(rect) || collides(rect)) continue;
1227
+ const halfW = measureLegendText(name, f) / 2;
1228
+ if (fillAt(c[0] - halfW, c[1]) !== r.fill || fillAt(c[0] + halfW, c[1]) !== r.fill)
1229
+ continue;
1230
+ best = f;
1231
+ break;
1232
+ }
1233
+ if (best >= SUBJECT_MIN_PROMINENCE) {
1234
+ const rect = regionLabelRect(c[0], c[1], name, void 0, best);
1235
+ placement.commit(rect);
1236
+ pushRegionLabel(
1237
+ c[0],
1238
+ c[1],
1239
+ name,
1240
+ r.fill,
1241
+ r.lineNumber,
1242
+ void 0,
1243
+ best
1244
+ );
1245
+ labeledRegionIds.add(r.id);
1246
+ regionLabelGuards.push({ label: labels[labels.length - 1], rect });
1247
+ } else {
1248
+ const hop = tryShortHopCallout(
1249
+ c[0],
1250
+ c[1],
1251
+ r.fill,
1252
+ r.label,
1253
+ void 0,
1254
+ SUBJECT_LEADER_FONT,
1255
+ HOP_GAP + SUBJECT_LEADER_FONT,
1256
+ SHORT_HOP_MAX * 2.2
1257
+ );
1258
+ if (hop) {
1259
+ placement.commit(hop.rect, hop.leader);
1260
+ const dark = mix(r.fill, palette.text, 60);
1261
+ labels.push({
1262
+ x: hop.x,
1263
+ y: hop.y,
1264
+ text: r.label,
1265
+ anchor: "middle",
1266
+ color: palette.text,
1267
+ halo: false,
1268
+ haloColor: palette.bg,
1269
+ fontSize: SUBJECT_LEADER_FONT,
1270
+ leader: {
1271
+ x1: hop.leader[0],
1272
+ y1: hop.leader[1],
1273
+ x2: hop.leader[2],
1274
+ y2: hop.leader[3]
1023
1275
  },
1024
- min: rampMin,
1025
- max: rampMax,
1026
- low: rampLow ?? rampLowFloor,
1027
- high: rampHue
1028
- }
1276
+ leaderColor: dark,
1277
+ calloutDot: { x: c[0], y: c[1], color: dark },
1278
+ lineNumber: r.lineNumber
1279
+ });
1280
+ labeledRegionIds.add(r.id);
1029
1281
  }
1030
- };
1282
+ }
1283
+ continue;
1031
1284
  }
1032
- }
1033
- const shownTitle = resolved.directives.noTitle ? null : resolved.title;
1034
- const TITLE_GAP = 16;
1035
- let topPad = FIT_PAD;
1036
- if (shownTitle && resolved.pois.length > 0) {
1037
- const bannerBottom = (resolved.subtitle ? TITLE_Y + TITLE_FONT_SIZE : TITLE_Y) + TITLE_FONT_SIZE / 2;
1038
- topPad = Math.max(FIT_PAD, bannerBottom + TITLE_GAP);
1039
- }
1040
- const legendBand = mapLegendBand(legend, {
1041
- width,
1042
- mode: opts.legendMode ?? "preview",
1043
- hasTitle: Boolean(shownTitle),
1044
- hasSubtitle: Boolean(resolved.subtitle)
1045
- });
1046
- if (legendBand > topPad) topPad = legendBand;
1047
- const topReserved = [];
1048
- if (shownTitle) {
1049
- const lines = resolved.subtitle ? 2 : 1;
1050
- const tw = Math.max(
1051
- measureLegendText(shownTitle, TITLE_FONT_SIZE),
1052
- resolved.subtitle ? measureLegendText(resolved.subtitle, TITLE_FONT_SIZE) : 0
1053
- ) + 2 * TITLE_FONT_SIZE;
1054
- topReserved.push({
1055
- x: width / 2 - tw / 2,
1056
- y: TITLE_Y - TITLE_FONT_SIZE,
1057
- w: tw,
1058
- h: TITLE_FONT_SIZE * (lines + 0.5)
1059
- });
1060
- }
1061
- const legendBox = mapLegendBox(legend, {
1062
- width,
1063
- mode: opts.legendMode ?? "preview",
1064
- hasTitle: Boolean(shownTitle),
1065
- hasSubtitle: Boolean(resolved.subtitle)
1066
- });
1067
- if (legendBox)
1068
- topReserved.push({
1069
- x: legendBox.x,
1070
- y: legendBox.y,
1071
- w: legendBox.width,
1072
- h: legendBox.height
1073
- });
1074
- const reserve = opts._calloutReserve;
1075
- const fitLeft = FIT_PAD + (reserve?.left ?? 0);
1076
- const fitRight = width - FIT_PAD - (reserve?.right ?? 0);
1077
- const fitTop = topPad + (reserve?.top ?? 0);
1078
- const fitBottom = height - FIT_PAD - (reserve?.bottom ?? 0);
1079
- const fitBox = [
1080
- [fitLeft, fitTop],
1081
- [Math.max(fitLeft + 1, fitRight), Math.max(fitTop + 1, fitBottom)]
1082
- ];
1083
- projection.fitExtent(fitBox, fitTarget);
1084
- if (!fitIsGlobal && resolved.projection !== "albers-usa" && resolved.regions.length > 0) {
1085
- let yMin = Infinity;
1086
- let yMax = -Infinity;
1087
- for (const r of resolved.regions) {
1088
- const bb = r.iso ? featureBboxPrimary(data.worldCoarse, r.iso) : null;
1089
- if (!bb) continue;
1090
- for (const lon of [bb[0][0], bb[1][0]]) {
1091
- for (const lat of [bb[0][1], bb[1][1]]) {
1092
- const p = projection([lon, lat]);
1093
- if (p && Number.isFinite(p[1])) {
1094
- if (p[1] < yMin) yMin = p[1];
1095
- if (p[1] > yMax) yMax = p[1];
1285
+ const seekAnchors = [
1286
+ { x: c[0], y: c[1], guard: false }
1287
+ ];
1288
+ if (valStr) {
1289
+ seekAnchors.push(
1290
+ { x: c[0], y: c[1] + boxH * 0.26, guard: true },
1291
+ { x: c[0], y: c[1] - boxH * 0.26, guard: true },
1292
+ { x: c[0] + boxW * 0.26, y: c[1], guard: true },
1293
+ { x: c[0] - boxW * 0.26, y: c[1], guard: true },
1294
+ { x: c[0] + boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
1295
+ { x: c[0] - boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
1296
+ { x: c[0] + boxW * 0.22, y: c[1] - boxH * 0.22, guard: true },
1297
+ { x: c[0] - boxW * 0.22, y: c[1] - boxH * 0.22, guard: true }
1298
+ );
1299
+ }
1300
+ let chosen;
1301
+ for (const a of seekAnchors) {
1302
+ if (a.guard && fillAt(a.x, a.y) !== r.fill) continue;
1303
+ for (const t of candidates) {
1304
+ const nameRect = regionLabelRect(a.x, a.y, t);
1305
+ if (valStr && stackW(t, valStr) <= boxW && stackH(true) <= boxH) {
1306
+ const stackRect = regionLabelRect(a.x, a.y, t, valStr);
1307
+ if (fitsRegions(stackRect) && fitsPois(nameRect) && extremesOnFill(a.x, a.y, t)) {
1308
+ chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
1309
+ break;
1310
+ }
1311
+ }
1312
+ if (labelW(t) <= boxW && labelH <= boxH) {
1313
+ if (fitsRegions(nameRect) && fitsPois(nameRect) && (valStr === void 0 || extremesOnFill(a.x, a.y, t))) {
1314
+ chosen = { text: t, ax: a.x, ay: a.y };
1315
+ break;
1096
1316
  }
1097
1317
  }
1098
1318
  }
1319
+ if (chosen) break;
1099
1320
  }
1100
- if (yMin < yMax) {
1101
- const boxTop = fitTop;
1102
- const boxBottom = fitBottom;
1103
- let dy = (boxTop + boxBottom) / 2 - (yMin + yMax) / 2;
1104
- const minDy = boxTop - yMin;
1105
- const maxDy = boxBottom - yMax;
1106
- if (minDy <= maxDy) dy = Math.max(minDy, Math.min(maxDy, dy));
1107
- const [tx, ty] = projection.translate();
1108
- projection.translate([tx, ty + dy]);
1321
+ if (chosen === void 0 && r.label !== void 0 && isSubject) {
1322
+ const hop = tryShortHopCallout(c[0], c[1], r.fill, r.label, valStr);
1323
+ if (hop) {
1324
+ placement.commit(hop.rect, hop.leader);
1325
+ const dark = mix(r.fill, palette.text, 60);
1326
+ labels.push({
1327
+ x: hop.x,
1328
+ y: hop.y,
1329
+ text: r.label,
1330
+ anchor: "middle",
1331
+ color: palette.text,
1332
+ halo: false,
1333
+ haloColor: palette.bg,
1334
+ ...valStr !== void 0 && { valueLine: valStr },
1335
+ leader: {
1336
+ x1: hop.leader[0],
1337
+ y1: hop.leader[1],
1338
+ x2: hop.leader[2],
1339
+ y2: hop.leader[3]
1340
+ },
1341
+ leaderColor: dark,
1342
+ calloutDot: { x: c[0], y: c[1], color: dark },
1343
+ lineNumber: r.lineNumber
1344
+ });
1345
+ labeledRegionIds.add(r.id);
1346
+ }
1347
+ continue;
1109
1348
  }
1110
- }
1111
- if (!fitIsGlobal && resolved.projection === "albers-usa") {
1112
- const cb = geoPath(projection).bounds(fitTarget);
1113
- if (Number.isFinite(cb[0][1])) {
1114
- const slack = fitBottom - fitTop - (cb[1][1] - cb[0][1]);
1115
- if (slack > 0) {
1116
- const currentTopGap = cb[0][1] - fitTop;
1117
- const dy = slack * 0.3 - currentTopGap;
1118
- const [tx, ty] = projection.translate();
1119
- projection.translate([tx, ty + dy]);
1349
+ if (chosen === void 0) continue;
1350
+ const isOrient = r.value === void 0 && r.layer === "base";
1351
+ let font = FONT2;
1352
+ if (isOrient) {
1353
+ const growT = sizeT(boxW, boxH);
1354
+ const desiredFont = Math.round(
1355
+ FONT2 + growT * (REGION_FONT_MAX_ORIENT - FONT2)
1356
+ );
1357
+ const hasVal = chosen.valueLine !== void 0;
1358
+ const extremesClean = (f) => {
1359
+ const halfW = measureLegendText(chosen.text, f) / 2;
1360
+ return [chosen.ax - halfW, chosen.ax + halfW].every((sx) => {
1361
+ const fAt = fillAt(sx, chosen.ay);
1362
+ return fAt === r.fill || isEmptyFill(fAt);
1363
+ });
1364
+ };
1365
+ let fit = -1;
1366
+ for (let f = desiredFont; f >= FONT2; f--) {
1367
+ if (stackW(chosen.text, chosen.valueLine, f) > boxW || stackH(hasVal, f) > boxH)
1368
+ continue;
1369
+ const gRect = regionLabelRect(
1370
+ chosen.ax,
1371
+ chosen.ay,
1372
+ chosen.text,
1373
+ chosen.valueLine,
1374
+ f
1375
+ );
1376
+ const gName = regionLabelRect(
1377
+ chosen.ax,
1378
+ chosen.ay,
1379
+ chosen.text,
1380
+ void 0,
1381
+ f
1382
+ );
1383
+ if (!fitsRegions(gRect) || !fitsPois(gName) || collides(gName))
1384
+ continue;
1385
+ if (!extremesClean(f)) continue;
1386
+ fit = f;
1387
+ break;
1388
+ }
1389
+ if (fit < 0) {
1390
+ if (!frameContainers.has(r.id)) continue;
1391
+ font = FONT2;
1392
+ } else {
1393
+ font = fit;
1120
1394
  }
1121
1395
  }
1396
+ const rRect = regionLabelRect(
1397
+ chosen.ax,
1398
+ chosen.ay,
1399
+ chosen.text,
1400
+ chosen.valueLine,
1401
+ font
1402
+ );
1403
+ placement.commit(rRect);
1404
+ pushRegionLabel(
1405
+ chosen.ax,
1406
+ chosen.ay,
1407
+ chosen.text,
1408
+ r.fill,
1409
+ r.lineNumber,
1410
+ chosen.valueLine,
1411
+ font,
1412
+ isOrient ? backdropLandTone : void 0
1413
+ );
1414
+ labeledRegionIds.add(r.id);
1415
+ regionLabelGuards.push({
1416
+ label: labels[labels.length - 1],
1417
+ rect: rRect
1418
+ });
1122
1419
  }
1123
- let path;
1124
- let project;
1125
- let stretchParams = null;
1126
- if (fitIsGlobal && !opts.preferContain) {
1127
- const cb = geoPath(projection).bounds(fitTarget);
1128
- const bx0 = cb[0][0];
1129
- const by0 = cb[0][1];
1130
- const cw = cb[1][0] - bx0;
1131
- const ch = cb[1][1] - by0;
1132
- const topReserve = resolved.title && resolved.pois.length > 0 || legendBand > 0 ? topPad : 0;
1133
- const ox = 0;
1134
- const oy = topReserve;
1135
- const sx = cw > 0 ? width / cw : 1;
1136
- const sy = ch > 0 ? (height - topReserve) / ch : 1;
1137
- stretchParams = { sx, sy, ox, oy, bx0, by0 };
1138
- const stretch = (x, y) => [
1139
- ox + (x - bx0) * sx,
1140
- oy + (y - by0) * sy
1141
- ];
1142
- const baseProjection = projection;
1143
- const tx = geoTransform({
1144
- point(x, y) {
1145
- const [px, py] = stretch(x, y);
1146
- this.stream.point(px, py);
1147
- }
1420
+ for (const seed of insetLabelSeeds) {
1421
+ const text = isCompact ? seed.iso.replace(/^US-/, "") : seed.name;
1422
+ const src = regionById.get(seed.iso);
1423
+ const valStr = regionValueStr(src?.value);
1424
+ pushRegionLabel(
1425
+ seed.x,
1426
+ seed.y,
1427
+ text,
1428
+ src ? regionFill(src) : neutralFill,
1429
+ seed.lineNumber,
1430
+ valStr
1431
+ );
1432
+ labeledRegionIds.add(seed.iso);
1433
+ regionLabelGuards.push({
1434
+ label: labels[labels.length - 1],
1435
+ rect: regionLabelRect(seed.x, seed.y, text, valStr)
1148
1436
  });
1149
- const thin = geoThin();
1150
- path = geoPath({
1151
- stream: (s) => baseProjection.stream(
1152
- tx.stream(
1153
- thin.stream(s)
1154
- )
1155
- )
1156
- }).digits(PATH_DIGITS);
1157
- project = (lon, lat) => {
1158
- const p = baseProjection([lon, lat]);
1159
- return p ? stretch(p[0], p[1]) : null;
1160
- };
1161
- } else {
1162
- projection.clipExtent([
1163
- [0, 0],
1164
- [width, height]
1165
- ]);
1166
- const thin = geoThin();
1167
- path = geoPath({
1168
- stream: (s) => projection.stream(
1169
- thin.stream(s)
1170
- )
1171
- }).digits(PATH_DIGITS);
1172
- project = (lon, lat) => projection([lon, lat]) ?? null;
1173
1437
  }
1174
- const regionGeo = regionGeoBucket(
1175
- resolvedIn,
1176
- JSON.stringify([
1177
- width,
1178
- height,
1179
- [
1180
- [-100, 40],
1181
- [-120, 37],
1182
- [-80, 40],
1183
- [0, 0],
1184
- [60, 30],
1185
- [-60, -30],
1186
- [150, 60],
1187
- [-150, -60],
1188
- [100, -40],
1189
- [-30, 50]
1190
- ].map(([lo, la]) => project(lo, la))
1191
- ])
1192
- );
1193
- const insets = [];
1194
- const insetRegions = [];
1195
- const insetLabelSeeds = [];
1196
- const akRef = resolved.regions.some((r) => r.iso === "US-AK") || resolved.pois.some((p) => inAlaska(p.lon, p.lat));
1197
- const hiRef = resolved.regions.some((r) => r.iso === "US-HI") || resolved.pois.some((p) => inHawaii(p.lon, p.lat));
1198
- if (resolved.projection === "albers-usa" && usLayer && (akRef || hiRef)) {
1199
- const PAD = 8;
1200
- const GAP = 12;
1201
- const yB = height - FIT_PAD;
1202
- const BW = 8;
1203
- const coast = /* @__PURE__ */ new Map();
1204
- const addPt = (lon, lat) => {
1205
- const p = projection([lon, lat]);
1206
- if (!p) return;
1207
- const bi = Math.floor(p[0] / BW);
1208
- const cur = coast.get(bi);
1209
- if (cur === void 0 || p[1] > cur) coast.set(bi, p[1]);
1210
- };
1211
- const walk = (co) => {
1212
- if (Array.isArray(co) && typeof co[0] === "number")
1213
- addPt(co[0], co[1]);
1214
- else if (Array.isArray(co)) for (const c of co) walk(c);
1215
- };
1216
- for (const [iso, f] of usLayer) {
1217
- if (US_NON_CONUS.has(iso)) continue;
1218
- walk(f.geometry.coordinates);
1219
- }
1220
- const at = (x) => {
1221
- const bi = Math.floor(x / BW);
1222
- let y = -Infinity;
1223
- for (let k = bi - 1; k <= bi + 1; k++) {
1224
- const v = coast.get(k);
1225
- if (v !== void 0 && v > y) y = v;
1226
- }
1227
- return y;
1228
- };
1229
- const coastFloor = (x0, xr) => {
1230
- const n = 24;
1231
- let maxY = -Infinity;
1232
- for (let i = 0; i <= n; i++) {
1233
- const y = at(x0 + (xr - x0) * i / n);
1234
- if (y > maxY) maxY = y;
1235
- }
1236
- return maxY;
1237
- };
1238
- const placeInset = (iso, proj, boxX, iwReq) => {
1239
- const f = usLayer.get(iso);
1240
- if (!f) return boxX;
1241
- const x0 = boxX;
1242
- const iw = Math.min(iwReq, width - FIT_PAD - x0 - 2 * PAD);
1243
- if (iw < 24) return boxX;
1244
- const xr = x0 + iw + 2 * PAD;
1245
- const floor = coastFloor(x0, xr);
1246
- const topGuess = floor > -Infinity ? floor + GAP : yB - height * 0.42;
1247
- proj.fitWidth(iw, f);
1248
- const bb = geoPath(proj).bounds(f);
1249
- const sh = Number.isFinite(bb[0][0]) ? bb[1][1] - bb[0][1] : iw;
1250
- const needH = sh + 2 * PAD;
1251
- let topFit = topGuess;
1252
- const bottom = Math.min(topFit + needH, yB);
1253
- if (bottom - topFit < needH) topFit = bottom - needH;
1254
- proj.fitExtent(
1255
- [
1256
- [x0 + PAD, topFit + PAD],
1257
- [xr - PAD, bottom - PAD]
1258
- ],
1259
- f
1260
- );
1261
- const insetPath = geoPath(proj).digits(PATH_DIGITS);
1262
- const d = insetPath(f) ?? "";
1263
- if (!d) return xr;
1264
- let contextLand;
1265
- if (iso === "US-AK") {
1266
- const can = worldLayer.get("CA");
1267
- const cd = can ? insetPath(can) ?? "" : "";
1268
- if (cd)
1269
- contextLand = {
1270
- d: cd,
1271
- fill: colorizeActive ? colorByIso.get("CA") ?? foreignFill : foreignFill
1272
- };
1273
- }
1274
- const r = regionById.get(iso);
1275
- let fill = colorizeActive ? colorByIso.get(iso) ?? neutralFill : neutralFill;
1276
- let lineNumber = -1;
1277
- if (r?.layer === "us-state") {
1278
- fill = regionFill(r);
1279
- lineNumber = r.lineNumber;
1280
- }
1281
- insets.push({
1282
- x: x0,
1283
- y: topFit,
1284
- w: xr - x0,
1285
- h: bottom - topFit,
1286
- points: [
1287
- [x0, topFit],
1288
- [xr, topFit],
1289
- [xr, bottom],
1290
- [x0, bottom]
1291
- ],
1292
- // The FITTED inset projection (just fit to this box) — captured so the
1293
- // geo-query can invert pixels inside the frame back to AK/HI coords.
1294
- projection: proj,
1295
- ...contextLand && { contextLand }
1296
- });
1297
- insetRegions.push({
1298
- id: iso,
1299
- d,
1300
- fill,
1301
- stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
1302
- lineNumber,
1303
- layer: "us-state",
1304
- ...r?.value !== void 0 && { value: r.value },
1305
- ...r && Object.keys(r.tags).length > 0 && { tags: r.tags }
1306
- });
1307
- const ctr = geoPath(proj).centroid(f);
1308
- if (Number.isFinite(ctr[0])) {
1309
- const name = f.properties?.name ?? iso;
1310
- insetLabelSeeds.push({ x: ctr[0], y: ctr[1], iso, name, lineNumber });
1438
+ }
1439
+
1440
+ // src/map/layout.ts
1441
+ var FIT_PAD = 24;
1442
+ var PATH_DIGITS = 1;
1443
+ var THIN_TOL = 0.6;
1444
+ var ALBERS_SKEW_MAX = 1.25;
1445
+ function geoThin() {
1446
+ const tol2 = THIN_TOL * THIN_TOL;
1447
+ return geoTransform({
1448
+ lineStart() {
1449
+ const t = this;
1450
+ t._has = false;
1451
+ t._pending = false;
1452
+ t.stream.lineStart();
1453
+ },
1454
+ point(x, y) {
1455
+ const t = this;
1456
+ t._lx = x;
1457
+ t._ly = y;
1458
+ if (t._has) {
1459
+ const dx = x - t._ex;
1460
+ const dy = y - t._ey;
1461
+ if (dx * dx + dy * dy < tol2) {
1462
+ t._pending = true;
1463
+ return;
1464
+ }
1311
1465
  }
1312
- return xr;
1313
- };
1314
- let akRight = FIT_PAD;
1315
- if (akRef)
1316
- akRight = placeInset("US-AK", alaskaProjection(), FIT_PAD, width * 0.18);
1317
- if (hiRef)
1318
- placeInset(
1319
- "US-HI",
1320
- hawaiiProjection(),
1321
- akRef ? akRight + 24 : FIT_PAD,
1322
- width * 0.12
1323
- );
1324
- }
1325
- const conusFit = resolved.projection === "albers-usa" && !!usLayer;
1326
- const classifyExtent = conusFit ? geoBounds(fitTarget) : resolved.extent;
1327
- const dLonSpan = classifyExtent[1][0] - classifyExtent[0][0];
1328
- const dLatSpan = classifyExtent[1][1] - classifyExtent[0][1];
1329
- const isGlobalView = dLonSpan >= 270 || dLatSpan >= 130;
1330
- const vLonCenter = (classifyExtent[0][0] + classifyExtent[1][0]) / 2;
1331
- const normLon = (lon) => {
1332
- let L = lon;
1333
- while (L < vLonCenter - 180) L += 360;
1334
- while (L > vLonCenter + 180) L -= 360;
1335
- return L;
1336
- };
1337
- const ringOverlapsView = (ring) => {
1338
- let loMin = Infinity, loMax = -Infinity, rawMin = Infinity, rawMax = -Infinity;
1339
- const lons = [];
1340
- for (const [rawLon] of ring) {
1341
- const lon = normLon(rawLon);
1342
- lons.push(lon);
1343
- if (lon < loMin) loMin = lon;
1344
- if (lon > loMax) loMax = lon;
1345
- if (rawLon < rawMin) rawMin = rawLon;
1346
- if (rawLon > rawMax) rawMax = rawLon;
1466
+ t.stream.point(x, y);
1467
+ t._ex = x;
1468
+ t._ey = y;
1469
+ t._has = true;
1470
+ t._pending = false;
1471
+ },
1472
+ lineEnd() {
1473
+ const t = this;
1474
+ if (t._pending) t.stream.point(t._lx, t._ly);
1475
+ t._pending = false;
1476
+ t.stream.lineEnd();
1347
1477
  }
1348
- lons.sort((a, b) => a - b);
1349
- let maxGap = 0;
1350
- for (let i = 1; i < lons.length; i++)
1351
- maxGap = Math.max(maxGap, lons[i] - lons[i - 1]);
1352
- if (lons.length > 1)
1353
- maxGap = Math.max(maxGap, lons[0] + 360 - lons[lons.length - 1]);
1354
- const occupiedArc = 360 - maxGap;
1355
- if (occupiedArc > 270) return false;
1356
- if (rawMax - rawMin > 180 && occupiedArc < 90) return false;
1357
- let px0 = Infinity, py0 = Infinity, px1 = -Infinity, py1 = -Infinity, anyFinite = false;
1358
- for (const [lon, lat] of ring) {
1478
+ });
1479
+ }
1480
+ var RAMP_FLOOR = 15;
1481
+ var R_DEFAULT = 6;
1482
+ var R_MIN = 4;
1483
+ var R_MAX = 22;
1484
+ var POI_FILL_OPACITY_MAX = 0.92;
1485
+ var POI_FILL_OPACITY_MIN = 0.55;
1486
+ var WORLD_LABEL_ANCHORS = {
1487
+ US: [-98.5, 39.5],
1488
+ // CONUS geographic centre (near Lebanon, Kansas)
1489
+ // Russia crosses the antimeridian (Chukotka at ~170°W), so on a non-global
1490
+ // (e.g. Europe) projection its geometry smears across the whole frame and the
1491
+ // area-weighted centroid lands mid-map (over Europe) — useless as a label
1492
+ // anchor. Pin it to European Russia (~Volga) so a Europe view labels visible
1493
+ // western Russia on its eastern margin; on a world view this still sits over
1494
+ // Russian land. (See the curated-anchor smear-gate bypass in context-labels.)
1495
+ RU: [45, 58]
1496
+ };
1497
+ var MAX_CLUSTER_EXTENT_FACTOR = 0.18;
1498
+ var MAX_COLUMN_ROWS = 7;
1499
+ var REGION_LABEL_HALO_RATIO = 4.5;
1500
+ var LAND_TINT_LIGHT = 12;
1501
+ var LAND_TINT_DARK = 24;
1502
+ var TAG_TINT_LIGHT = 60;
1503
+ var TAG_TINT_DARK = 68;
1504
+ var WATER_TINT_LIGHT = 24;
1505
+ var WATER_TINT_DARK = 24;
1506
+ var RIVER_WIDTH = 1.3;
1507
+ var COMPACT_WIDTH_PX = 480;
1508
+ var RELIEF_MIN_AREA = 12;
1509
+ var RELIEF_MIN_DIM = 2;
1510
+ var RELIEF_HATCH_SPACING = 1.5;
1511
+ var RELIEF_HATCH_WIDTH = 0.2;
1512
+ var RELIEF_HATCH_STRENGTH = 26;
1513
+ var COASTLINE_RING_COUNT = 5;
1514
+ var COASTLINE_D0 = 16e-4;
1515
+ var COASTLINE_STEP = 28e-4;
1516
+ var COASTLINE_THICKNESS = 14e-4;
1517
+ var COASTLINE_OPACITY_NEAR = 0.5;
1518
+ var COASTLINE_OPACITY_FAR = 0.1;
1519
+ var COASTLINE_MIN_EXTENT = 6e-4;
1520
+ var COASTLINE_MIN_EXTENT_GLOBAL = 6e-4;
1521
+ var COASTLINE_STROKE_MIX = 32;
1522
+ var FOREIGN_TINT_LIGHT = 30;
1523
+ var FOREIGN_TINT_DARK = 62;
1524
+ var MUTED_FOREIGN_LIGHT = 28;
1525
+ var MUTED_FOREIGN_DARK = 16;
1526
+ var COLO_R = 9;
1527
+ var GOLDEN_ANGLE = 2.399963229728653;
1528
+ var STACK_OVERLAP = 1;
1529
+ var STACK_RING_MAX = 8;
1530
+ var STACK_RING_GAP = 4;
1531
+ function geomObject(topo) {
1532
+ const key = Object.keys(topo.objects)[0];
1533
+ return topo.objects[key];
1534
+ }
1535
+ var decodeCache = /* @__PURE__ */ new WeakMap();
1536
+ function mergeFeatures(a, b) {
1537
+ const polysOf = (f) => {
1538
+ const g = f.geometry;
1539
+ if (!g) return null;
1540
+ if (g.type === "Polygon") return [g.coordinates];
1541
+ if (g.type === "MultiPolygon") return g.coordinates;
1542
+ return null;
1543
+ };
1544
+ const pa = polysOf(a);
1545
+ const pb = polysOf(b);
1546
+ if (!pa || !pb) return a;
1547
+ return {
1548
+ ...a,
1549
+ geometry: { type: "MultiPolygon", coordinates: [...pa, ...pb] }
1550
+ };
1551
+ }
1552
+ function decodeLayer(topo) {
1553
+ const cached = decodeCache.get(topo);
1554
+ if (cached) return cached;
1555
+ const out = /* @__PURE__ */ new Map();
1556
+ for (const g of geomObject(topo).geometries) {
1557
+ const f = feature(topo, g);
1558
+ if (!f.geometry) continue;
1559
+ const tagged = { ...f, id: g.id };
1560
+ const existing = out.get(g.id);
1561
+ out.set(g.id, existing ? mergeFeatures(existing, tagged) : tagged);
1562
+ }
1563
+ decodeCache.set(topo, out);
1564
+ return out;
1565
+ }
1566
+ function countryLabelPositions(args) {
1567
+ const { geometry, bounds, project, width, height, curated } = args;
1568
+ const w0 = bounds[0][0];
1569
+ const s0 = bounds[0][1];
1570
+ const e0 = bounds[1][0];
1571
+ const n0 = bounds[1][1];
1572
+ if (![w0, s0, e0, n0].every(Number.isFinite) || e0 <= w0 || n0 <= s0) {
1573
+ return mkCurated(curated, project);
1574
+ }
1575
+ const N = COUNTRY_POS_GRID;
1576
+ const onLand = [];
1577
+ const kept = [];
1578
+ for (let i = 0; i < N; i++) {
1579
+ onLand[i] = [];
1580
+ const lon = w0 + (i + 0.5) / N * (e0 - w0);
1581
+ for (let j = 0; j < N; j++) {
1582
+ const lat = s0 + (j + 0.5) / N * (n0 - s0);
1583
+ const land = pointInGeometry(geometry, lon, lat);
1584
+ onLand[i][j] = land;
1585
+ if (!land) continue;
1359
1586
  const p = project(lon, lat);
1360
1587
  if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
1361
- anyFinite = true;
1362
- if (p[0] < px0) px0 = p[0];
1363
- if (p[0] > px1) px1 = p[0];
1364
- if (p[1] < py0) py0 = p[1];
1365
- if (p[1] > py1) py1 = p[1];
1588
+ if (p[0] < 0 || p[0] > width || p[1] < 0 || p[1] > height) continue;
1589
+ kept.push({ i, j, lon, lat, sx: p[0], sy: p[1] });
1366
1590
  }
1367
- if (!anyFinite) return false;
1368
- return !(px1 < 0 || px0 > width || py1 < 0 || py0 > height);
1591
+ }
1592
+ if (!kept.length) return mkCurated(curated, project);
1593
+ const cx = kept.reduce((s, c) => s + c.sx, 0) / kept.length;
1594
+ const cy = kept.reduce((s, c) => s + c.sy, 0) / kept.length;
1595
+ const interiorness = (c) => {
1596
+ let n = 0;
1597
+ for (let di = -1; di <= 1; di++)
1598
+ for (let dj = -1; dj <= 1; dj++) {
1599
+ if (di === 0 && dj === 0) continue;
1600
+ const ni = c.i + di;
1601
+ const nj = c.j + dj;
1602
+ if (ni >= 0 && ni < N && nj >= 0 && nj < N && onLand[ni][nj]) n++;
1603
+ }
1604
+ return n;
1369
1605
  };
1370
- const cullFeatureToView = (f) => {
1371
- if (isGlobalView) return f;
1372
- const g = f.geometry;
1373
- if (!g) return f;
1374
- if (g.type === "Polygon") {
1375
- const ring = g.coordinates[0];
1376
- return ringOverlapsView(ring) ? f : null;
1606
+ const dist2ToCentre = (c) => (c.sx - cx) ** 2 + (c.sy - cy) ** 2;
1607
+ const centreLon = kept.reduce((s, c) => s + c.lon, 0) / kept.length;
1608
+ const centreLat = kept.reduce((s, c) => s + c.lat, 0) / kept.length;
1609
+ const centreScreen = project(centreLon, centreLat);
1610
+ const lead = pointInGeometry(geometry, centreLon, centreLat) && centreScreen && Number.isFinite(centreScreen[0]) && Number.isFinite(centreScreen[1]) && centreScreen[0] >= 0 && centreScreen[0] <= width && centreScreen[1] >= 0 && centreScreen[1] <= height ? [
1611
+ {
1612
+ lonLat: [centreLon, centreLat],
1613
+ screen: [centreScreen[0], centreScreen[1]]
1377
1614
  }
1378
- if (g.type === "MultiPolygon") {
1379
- const polys = g.coordinates;
1380
- const keep = polys.filter(
1381
- (p) => ringOverlapsView(p[0])
1382
- );
1383
- if (!keep.length) return null;
1384
- if (keep.length === polys.length) return f;
1385
- return { ...f, geometry: { ...g, coordinates: keep } };
1615
+ ] : [];
1616
+ const grid = [...kept].sort(
1617
+ (a, b) => interiorness(b) - interiorness(a) || dist2ToCentre(a) - dist2ToCentre(b)
1618
+ ).map((c) => ({
1619
+ lonLat: [c.lon, c.lat],
1620
+ screen: [c.sx, c.sy]
1621
+ }));
1622
+ const curatedPos = curated ? mkCurated(curated, project) : [];
1623
+ return [...curatedPos, ...lead, ...grid].slice(0, MAX_COUNTRY_POSITIONS);
1624
+ }
1625
+ function mkCurated(curated, project) {
1626
+ if (!curated) return [];
1627
+ const p = project(curated[0], curated[1]);
1628
+ if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) return [];
1629
+ return [{ lonLat: [curated[0], curated[1]], screen: [p[0], p[1]] }];
1630
+ }
1631
+ var usConusProjection = () => geoConicEqualArea().parallels([29.5, 45.5]).rotate([96, 0]);
1632
+ var alaskaProjection = () => geoConicEqualArea().rotate([154, 0]).center([-2, 58.5]).parallels([55, 65]);
1633
+ var hawaiiProjection = () => geoMercator();
1634
+ function projectionFor(family, extent) {
1635
+ switch (family) {
1636
+ case "albers-usa":
1637
+ return usConusProjection();
1638
+ case "conic-equal-area": {
1639
+ const s = extent[0][1];
1640
+ const n = extent[1][1];
1641
+ return geoConicEqualArea().parallels([s + (n - s) / 6, s + (n - s) * 5 / 6]).center([0, (s + n) / 2]);
1642
+ }
1643
+ case "mercator":
1644
+ return geoMercator();
1645
+ case "equal-earth":
1646
+ return geoEqualEarth();
1647
+ case "equirectangular":
1648
+ return geoEquirectangular();
1649
+ case "natural-earth":
1650
+ return geoNaturalEarth1();
1651
+ default:
1652
+ return geoEquirectangular();
1653
+ }
1654
+ }
1655
+ var INSET_STATES = /* @__PURE__ */ new Set(["US-AK", "US-HI"]);
1656
+ var inAlaska = (lon, lat) => lat >= 51 && (lon <= -129 || lon >= 172);
1657
+ var inHawaii = (lon, lat) => lat >= 18 && lat <= 23 && lon >= -161 && lon <= -154;
1658
+ var FOREIGN_BORDER = {
1659
+ CA: [
1660
+ "US-AK",
1661
+ "US-WA",
1662
+ "US-ID",
1663
+ "US-MT",
1664
+ "US-ND",
1665
+ "US-MN",
1666
+ "US-MI",
1667
+ "US-NY",
1668
+ "US-VT",
1669
+ "US-NH",
1670
+ "US-ME"
1671
+ ],
1672
+ MX: ["US-CA", "US-AZ", "US-NM", "US-TX"]
1673
+ };
1674
+ var US_NON_CONUS = /* @__PURE__ */ new Set([
1675
+ "US-AK",
1676
+ "US-HI",
1677
+ "US-AS",
1678
+ "US-GU",
1679
+ "US-MP",
1680
+ "US-PR",
1681
+ "US-VI"
1682
+ ]);
1683
+ function mapBackgroundColor(palette, isDark = false, _dataActive = false) {
1684
+ return mix(
1685
+ palette.colors.blue,
1686
+ palette.bg,
1687
+ isDark ? WATER_TINT_DARK : WATER_TINT_LIGHT
1688
+ );
1689
+ }
1690
+ function mapNeutralLandColor(palette, isDark, _dataActive = false) {
1691
+ return mix(
1692
+ palette.colors.green,
1693
+ palette.bg,
1694
+ isDark ? LAND_TINT_DARK : LAND_TINT_LIGHT
1695
+ );
1696
+ }
1697
+ function buildMapProjection(resolved, data) {
1698
+ const wantsUsStates = resolved.basemaps.subdivisions.includes("us-states");
1699
+ const usCrisp = (resolved.projection === "albers-usa" || resolved.projection === "mercator") && wantsUsStates && !!data.naLand;
1700
+ const worldTopo = usCrisp ? data.worldDetail : resolved.basemaps.world === "detail" ? data.worldDetail : data.worldCoarse;
1701
+ const worldLayer = new Map(decodeLayer(worldTopo));
1702
+ if (usCrisp && data.naLand) {
1703
+ const [nbW, nbS, nbE, nbN] = [-140, 10, -52, 66];
1704
+ const crisp = decodeLayer(data.naLand);
1705
+ for (const [iso, cf] of crisp) {
1706
+ const base = worldLayer.get(iso);
1707
+ if (!base) continue;
1708
+ const [[bw, bs], [be, bn]] = geoBounds(base);
1709
+ if (bw >= nbW && be <= nbE && bs >= nbS && bn <= nbN)
1710
+ worldLayer.set(iso, { ...cf, properties: base.properties });
1711
+ }
1712
+ }
1713
+ const usLayer = wantsUsStates ? decodeLayer(data.usStates) : null;
1714
+ const extentOutline = () => {
1715
+ const [[w, s], [e, n]] = resolved.extent;
1716
+ const N = 16;
1717
+ const coords = [];
1718
+ for (let i = 0; i <= N; i++) {
1719
+ const t = i / N;
1720
+ const lon = w + (e - w) * t;
1721
+ const lat = s + (n - s) * t;
1722
+ coords.push([lon, s], [lon, n], [w, lat], [e, lat]);
1386
1723
  }
1387
- return f;
1724
+ return {
1725
+ type: "Feature",
1726
+ properties: {},
1727
+ geometry: { type: "MultiPoint", coordinates: coords }
1728
+ };
1388
1729
  };
1389
- const SEAM_SLIVER_MAX_SPAN = 100;
1390
- const ringIsFrameFiller = (ring) => {
1391
- const lons = ring.map(([lon]) => lon).sort((a, b) => a - b);
1392
- if (lons.length < 2) return false;
1393
- let maxGap = -1;
1394
- let gapIdx = 0;
1395
- for (let i = 1; i < lons.length; i++) {
1396
- const g = lons[i] - lons[i - 1];
1397
- if (g > maxGap) {
1398
- maxGap = g;
1399
- gapIdx = i;
1730
+ let fitFeatures;
1731
+ if (resolved.projection === "albers-usa" && usLayer) {
1732
+ fitFeatures = [...usLayer.entries()].filter(([iso]) => !US_NON_CONUS.has(iso)).map(([, f]) => f);
1733
+ const neighborPoints = resolved.pois.filter((p) => !inAlaska(p.lon, p.lat) && !inHawaii(p.lon, p.lat)).map((p) => [p.lon, p.lat]);
1734
+ if (neighborPoints.length > 0) {
1735
+ fitFeatures.push({
1736
+ type: "Feature",
1737
+ properties: {},
1738
+ geometry: { type: "MultiPoint", coordinates: neighborPoints }
1739
+ });
1740
+ }
1741
+ for (const r of resolved.regions) {
1742
+ if (r.layer === "country" && (r.iso === "CA" || r.iso === "MX")) {
1743
+ const cf = worldLayer.get(r.iso);
1744
+ if (cf) fitFeatures.push(cf);
1400
1745
  }
1401
1746
  }
1402
- const wrapGap = lons[0] + 360 - lons[lons.length - 1];
1403
- if (wrapGap >= maxGap) return false;
1404
- const span = 360 - maxGap;
1405
- const east = lons[gapIdx - 1] + 360;
1406
- return east > 180 && span < SEAM_SLIVER_MAX_SPAN;
1747
+ } else {
1748
+ fitFeatures = [extentOutline()];
1749
+ }
1750
+ const fitTarget = { type: "FeatureCollection", features: fitFeatures };
1751
+ const projection = projectionFor(resolved.projection, resolved.extent);
1752
+ if (resolved.projection !== "albers-usa") {
1753
+ let centerLon = (resolved.extent[0][0] + resolved.extent[1][0]) / 2;
1754
+ if (centerLon > 180) centerLon -= 360;
1755
+ projection.rotate([-centerLon, 0]);
1756
+ }
1757
+ const fitGB = geoBounds(fitTarget);
1758
+ const fitIsGlobal = fitGB[1][0] - fitGB[0][0] >= 270 || fitGB[1][1] - fitGB[0][1] >= 130;
1759
+ return {
1760
+ projection,
1761
+ fitTarget,
1762
+ fitIsGlobal,
1763
+ worldLayer,
1764
+ usLayer,
1765
+ usCrisp,
1766
+ wantsUsStates,
1767
+ worldTopo
1407
1768
  };
1408
- const dropFrameFillers = (f) => {
1409
- const g = f.geometry;
1410
- if (!g) return f;
1411
- if (g.type === "Polygon") {
1412
- const ring = g.coordinates[0];
1413
- return ringIsFrameFiller(ring) ? null : f;
1769
+ }
1770
+ function parsePathRings(d) {
1771
+ const rings = [];
1772
+ let cur = [];
1773
+ const re = /([MLZ])([^MLZ]*)/g;
1774
+ let m;
1775
+ while (m = re.exec(d)) {
1776
+ if (m[1] === "Z") {
1777
+ if (cur.length) rings.push(cur);
1778
+ cur = [];
1779
+ continue;
1414
1780
  }
1415
- if (g.type === "MultiPolygon") {
1416
- const polys = g.coordinates;
1417
- const keep = polys.filter(
1418
- (p) => !ringIsFrameFiller(p[0])
1419
- );
1420
- if (!keep.length) return null;
1421
- if (keep.length === polys.length) return f;
1422
- return { ...f, geometry: { ...g, coordinates: keep } };
1781
+ if (m[1] === "M" && cur.length) {
1782
+ rings.push(cur);
1783
+ cur = [];
1423
1784
  }
1424
- return f;
1785
+ const nums = m[2].split(/[ ,]+/).map(Number);
1786
+ for (let i = 0; i + 1 < nums.length; i += 2) {
1787
+ const x = nums[i];
1788
+ const y = nums[i + 1];
1789
+ if (Number.isFinite(x) && Number.isFinite(y)) cur.push([x, y]);
1790
+ }
1791
+ }
1792
+ if (cur.length) rings.push(cur);
1793
+ return rings;
1794
+ }
1795
+ function dropAntimeridianWrapSlivers(d, width, height) {
1796
+ const rings = parsePathRings(d);
1797
+ if (rings.length <= 1) return d;
1798
+ const eps = 0.75;
1799
+ const minArea = 3e-3 * width * height;
1800
+ const ringArea = (r) => {
1801
+ let s = 0;
1802
+ for (let i = 0; i < r.length; i++) {
1803
+ const a = r[i];
1804
+ const b = r[(i + 1) % r.length];
1805
+ s += a[0] * b[1] - b[0] * a[1];
1806
+ }
1807
+ return Math.abs(s) / 2;
1425
1808
  };
1426
- const regions = [];
1427
- const pushRegionLayer = (layerFeatures, layerKind, shouldCull) => {
1428
- for (const [iso, f] of layerFeatures) {
1429
- if (layerKind === "us-state" && usContext && resolved.projection === "albers-usa" && INSET_STATES.has(iso))
1430
- continue;
1431
- if (layerKind === "country" && usContext && iso === "US") continue;
1432
- if (layerKind === "country" && iso === "AQ" && !regionById.has("AQ"))
1433
- continue;
1434
- const r = regionById.get(iso);
1435
- const geoK = layerKind + ":" + iso;
1436
- let geo = regionGeo.get(geoK);
1437
- if (geo === void 0) {
1438
- const viewF = shouldCull ? cullFeatureToView(f) : dropFrameFillers(f);
1439
- if (!viewF) {
1440
- regionGeo.set(geoK, null);
1441
- continue;
1442
- }
1443
- const raw = path(viewF) ?? "";
1444
- const d0 = fitIsGlobal ? dropAntimeridianWrapSlivers(raw, width, height) : raw;
1445
- if (!d0) {
1446
- regionGeo.set(geoK, null);
1447
- continue;
1448
- }
1449
- const anchor = WORLD_LABEL_ANCHORS[iso];
1450
- const cc = anchor ? project(anchor[0], anchor[1]) : path.centroid(viewF);
1451
- const ok = cc != null && Number.isFinite(cc[0]) && Number.isFinite(cc[1]);
1452
- geo = { d: d0, cx: ok ? cc[0] : void 0, cy: ok ? cc[1] : void 0 };
1453
- regionGeo.set(geoK, geo);
1454
- } else if (geo === null) {
1455
- continue;
1456
- }
1457
- const d = geo.d;
1458
- const hasCentroid = geo.cx !== void 0 && geo.cy !== void 0;
1459
- const isThisLayer = r?.layer === layerKind;
1460
- const isForeign = layerKind === "country" && usContext && iso !== "US";
1461
- const baseFill = isForeign ? foreignFill : neutralFill;
1462
- let fill = colorizeActive ? colorByIso.get(iso) ?? baseFill : baseFill;
1463
- let label;
1464
- let lineNumber = -1;
1465
- let layer = "base";
1466
- if (isThisLayer) {
1467
- fill = regionFill(r);
1468
- lineNumber = r.lineNumber;
1469
- layer = layerKind;
1470
- label = r.name;
1471
- } else {
1472
- label = f.properties?.name;
1473
- }
1474
- regions.push({
1475
- id: iso,
1476
- d,
1477
- fill,
1478
- stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
1479
- lineNumber,
1480
- layer,
1481
- ...label !== void 0 && { label },
1482
- ...hasCentroid && { labelX: geo.cx, labelY: geo.cy },
1483
- ...isThisLayer && r.value !== void 0 && { value: r.value },
1484
- ...isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }
1485
- });
1809
+ const areas = rings.map(ringArea);
1810
+ const maxArea = Math.max(...areas);
1811
+ const onVEdge = (a, b) => Math.abs(a[0]) <= eps && Math.abs(b[0]) <= eps || Math.abs(a[0] - width) <= eps && Math.abs(b[0] - width) <= eps;
1812
+ let dropped = false;
1813
+ const kept = rings.filter((r, idx) => {
1814
+ if (areas[idx] >= maxArea || areas[idx] >= minArea) return true;
1815
+ const touches = r.some((p, i) => onVEdge(p, r[(i + 1) % r.length]));
1816
+ if (touches) {
1817
+ dropped = true;
1818
+ return false;
1819
+ }
1820
+ return true;
1821
+ });
1822
+ if (!dropped) return d;
1823
+ return kept.map(
1824
+ (r) => r.map((p, i) => (i ? "L" : "M") + p[0] + "," + p[1]).join("") + "Z"
1825
+ ).join("");
1826
+ }
1827
+ function albersSkewFallback(resolved, data, width, height) {
1828
+ if (resolved.projection !== "albers-usa") return false;
1829
+ if (!resolved.basemaps.subdivisions.includes("us-states")) return false;
1830
+ if (!(width > 0) || !(height > 0)) return false;
1831
+ const akRef = resolved.regions.some((r) => r.iso === "US-AK") || resolved.pois.some((p) => inAlaska(p.lon, p.lat));
1832
+ const hiRef = resolved.regions.some((r) => r.iso === "US-HI") || resolved.pois.some((p) => inHawaii(p.lon, p.lat));
1833
+ if (akRef || hiRef) return false;
1834
+ const { projection, fitTarget, usLayer } = buildMapProjection(resolved, data);
1835
+ if (!usLayer || ![...usLayer.keys()].some((iso) => !US_NON_CONUS.has(iso)))
1836
+ return false;
1837
+ projection.fitSize([1e3, 1e3], fitTarget);
1838
+ const b = geoPath2(projection).bounds(fitTarget);
1839
+ const conusAspect = (b[1][0] - b[0][0]) / (b[1][1] - b[0][1]);
1840
+ if (!(conusAspect > 0)) return false;
1841
+ const canvasAspect = width / height;
1842
+ const skew = Math.max(canvasAspect / conusAspect, conusAspect / canvasAspect);
1843
+ return skew > ALBERS_SKEW_MAX;
1844
+ }
1845
+ var REGION_GEO_MEMO = /* @__PURE__ */ new WeakMap();
1846
+ var REGION_GEO_MAX_FITS = 8;
1847
+ function regionGeoBucket(resolved, projKey) {
1848
+ let byFit = REGION_GEO_MEMO.get(resolved);
1849
+ if (!byFit) {
1850
+ byFit = /* @__PURE__ */ new Map();
1851
+ REGION_GEO_MEMO.set(resolved, byFit);
1852
+ }
1853
+ let bucket = byFit.get(projKey);
1854
+ if (!bucket) {
1855
+ bucket = /* @__PURE__ */ new Map();
1856
+ byFit.set(projKey, bucket);
1857
+ if (byFit.size > REGION_GEO_MAX_FITS) {
1858
+ const oldest = byFit.keys().next().value;
1859
+ if (oldest !== void 0) byFit.delete(oldest);
1486
1860
  }
1861
+ }
1862
+ return bucket;
1863
+ }
1864
+ function layoutMap(resolvedIn, data, size, opts) {
1865
+ const { palette, isDark } = opts;
1866
+ const { width, height } = size;
1867
+ let resolved = resolvedIn;
1868
+ if (albersSkewFallback(resolved, data, width, height)) {
1869
+ resolved = { ...resolved, projection: "conic-equal-area" };
1870
+ }
1871
+ const {
1872
+ projection,
1873
+ fitTarget,
1874
+ fitIsGlobal,
1875
+ worldLayer,
1876
+ usLayer,
1877
+ usCrisp,
1878
+ worldTopo
1879
+ } = buildMapProjection(resolved, data);
1880
+ const usContext = usLayer !== null;
1881
+ const regionStroke = isDark ? mix(palette.border, palette.text, 65) : mix(palette.text, palette.bg, 78);
1882
+ const values = resolved.regions.filter((r) => r.value !== void 0).map((r) => r.value);
1883
+ const rampMin = values.length > 0 ? Math.min(...values) : 0;
1884
+ const rampMax = Math.max(...values);
1885
+ const rampHue = resolveColor(resolved.directives.regionMetricColor ?? "", palette) ?? palette.colors.red;
1886
+ const rampLow = resolved.directives.regionMetricLowColor ? resolveColor(resolved.directives.regionMetricLowColor, palette) ?? void 0 : void 0;
1887
+ const hasRamp = values.length > 0;
1888
+ const VALUE_NAME = hasRamp ? resolved.directives.regionMetric?.trim() || "Value" : null;
1889
+ const matchColorGroup = (v) => {
1890
+ const lv = v.trim().toLowerCase();
1891
+ if (lv === "none") return null;
1892
+ if (lv === VALUE_NAME?.toLowerCase()) return VALUE_NAME;
1893
+ const tg = resolved.tagGroups.find((g) => g.name.toLowerCase() === lv);
1894
+ return tg ? tg.name : v;
1487
1895
  };
1488
- pushRegionLayer(worldLayer, "country", !isGlobalView);
1489
- if (usLayer) pushRegionLayer(usLayer, "us-state", !conusFit && !isGlobalView);
1490
- const lakesTopo = usCrisp && data.naLakes ? data.naLakes : data.lakes;
1491
- if (lakesTopo) {
1492
- for (const [, f] of decodeLayer(lakesTopo)) {
1493
- const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
1494
- if (!viewF) continue;
1495
- const d = path(viewF) ?? "";
1496
- if (!d) continue;
1497
- regions.push({
1498
- id: "lake",
1499
- d,
1500
- fill: water,
1501
- stroke: lakeStroke,
1502
- lineNumber: -1,
1503
- layer: "base"
1504
- });
1505
- }
1896
+ const fillGroupNames = /* @__PURE__ */ new Set();
1897
+ for (const g of resolved.tagGroups) {
1898
+ const k = g.name.toLowerCase();
1899
+ if (resolved.regions.some((r) => r.tags[k]) || resolved.pois.some((p) => p.tags[k]))
1900
+ fillGroupNames.add(g.name);
1506
1901
  }
1507
- const pointInRings = (px, py, rings) => {
1508
- let inside = false;
1509
- for (const ring of rings) {
1510
- for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
1511
- const [xi, yi] = ring[i];
1512
- const [xj, yj] = ring[j];
1513
- if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi)
1514
- inside = !inside;
1515
- }
1902
+ const firstFillGroup = resolved.tagGroups.find((g) => fillGroupNames.has(g.name))?.name ?? null;
1903
+ const override = opts.activeGroup;
1904
+ let activeGroup;
1905
+ if (override !== void 0) {
1906
+ activeGroup = override === null ? null : matchColorGroup(override);
1907
+ } else if (resolved.directives.activeTag !== void 0) {
1908
+ activeGroup = matchColorGroup(resolved.directives.activeTag);
1909
+ } else {
1910
+ activeGroup = VALUE_NAME ?? firstFillGroup ?? resolved.tagGroups[0]?.name ?? null;
1911
+ }
1912
+ const activeIsScore = VALUE_NAME !== null && activeGroup === VALUE_NAME;
1913
+ const mutedBasemap = activeIsScore || activeGroup !== null && fillGroupNames.has(activeGroup);
1914
+ const neutralFill = mapNeutralLandColor(palette, isDark, mutedBasemap);
1915
+ const water = mapBackgroundColor(palette, isDark, mutedBasemap);
1916
+ const lakeStroke = mix(regionStroke, water, 45);
1917
+ const foreignFill = mix(
1918
+ palette.colors.gray,
1919
+ palette.bg,
1920
+ mutedBasemap ? isDark ? MUTED_FOREIGN_DARK : MUTED_FOREIGN_LIGHT : isDark ? FOREIGN_TINT_DARK : FOREIGN_TINT_LIGHT
1921
+ );
1922
+ const contrastFloorTone = (tone, ...substrates) => {
1923
+ const clears = (t) => substrates.every((s) => contrastRatio(t, s) >= REGION_LABEL_HALO_RATIO);
1924
+ if (clears(tone)) return tone;
1925
+ for (let s = 10; s <= 100; s += 10) {
1926
+ const t = mix(palette.text, tone, s);
1927
+ if (clears(t)) return t;
1516
1928
  }
1517
- return inside;
1929
+ return palette.text;
1518
1930
  };
1519
- const fillHitTargets = [...regions, ...insetRegions].map((r) => {
1520
- const rings = parsePathRings(r.d);
1521
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1522
- for (const ring of rings)
1523
- for (const p of ring) {
1524
- if (p[0] < minX) minX = p[0];
1525
- if (p[0] > maxX) maxX = p[0];
1526
- if (p[1] < minY) minY = p[1];
1527
- if (p[1] > maxY) maxY = p[1];
1931
+ const backdropLandTone = contrastFloorTone(
1932
+ palette.textMuted,
1933
+ neutralFill,
1934
+ water
1935
+ );
1936
+ const backdropWaterTone = contrastFloorTone(
1937
+ mix(palette.colors.blue, palette.textMuted, 50),
1938
+ water,
1939
+ neutralFill
1940
+ );
1941
+ const hasDirectColor = resolved.regions.some((r) => r.color !== void 0);
1942
+ const colorizeActive = resolved.directives.noColorize !== true && !hasRamp && !hasDirectColor && fillGroupNames.size === 0;
1943
+ const colorByIso = /* @__PURE__ */ new Map();
1944
+ if (colorizeActive) {
1945
+ const adjacency = /* @__PURE__ */ new Map();
1946
+ const addEdges = (src) => {
1947
+ for (const [iso, ns] of src) {
1948
+ const cur = adjacency.get(iso);
1949
+ if (cur) cur.push(...ns);
1950
+ else adjacency.set(iso, [...ns]);
1528
1951
  }
1529
- r.bbox = [minX, minY, maxX, maxY];
1530
- r.rings = rings;
1531
- return { fill: r.fill, rings, minX, minY, maxX, maxY };
1532
- });
1533
- const fillAt = (x, y) => {
1534
- let hit = water;
1535
- for (const t of fillHitTargets) {
1536
- if (x < t.minX || x > t.maxX || y < t.minY || y > t.maxY) continue;
1537
- if (pointInRings(x, y, t.rings)) hit = t.fill;
1538
- }
1539
- return hit;
1540
- };
1541
- const labelOnFill = (fill) => {
1542
- const color = contrastRatio(fill, palette.textOnFillDark) >= contrastRatio(fill, palette.textOnFillLight) ? palette.textOnFillDark : palette.textOnFillLight;
1543
- const haloColor = color === palette.textOnFillLight ? palette.textOnFillDark : palette.textOnFillLight;
1544
- return {
1545
- color,
1546
- halo: contrastRatio(fill, color) < REGION_LABEL_HALO_RATIO,
1547
- haloColor
1548
- };
1549
- };
1550
- const reliefAllowed = resolved.directives.noRelief !== true;
1551
- const relief = [];
1552
- let reliefHatch = null;
1553
- if (reliefAllowed && data.mountainRanges) {
1554
- for (const [, f] of decodeLayer(data.mountainRanges)) {
1555
- const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
1556
- if (!viewF) continue;
1557
- const area = path.area(viewF);
1558
- if (!Number.isFinite(area) || area < RELIEF_MIN_AREA) continue;
1559
- const box = path.bounds(viewF);
1560
- if (box[1][0] - box[0][0] < RELIEF_MIN_DIM || box[1][1] - box[0][1] < RELIEF_MIN_DIM)
1561
- continue;
1562
- const d = path(viewF) ?? "";
1563
- if (!d) continue;
1564
- relief.push({ d });
1565
- }
1566
- if (relief.length) {
1567
- const darkTone = isDark ? palette.bg : palette.text;
1568
- const lightTone = isDark ? palette.text : palette.bg;
1569
- const reliefLandRef = colorizeActive ? isDark ? palette.surface : palette.bg : neutralFill;
1570
- const landLum = relativeLuminance(reliefLandRef);
1571
- const tone = Math.abs(landLum - relativeLuminance(darkTone)) > 0.04 ? darkTone : lightTone;
1572
- reliefHatch = {
1573
- color: mix(tone, reliefLandRef, RELIEF_HATCH_STRENGTH),
1574
- spacing: RELIEF_HATCH_SPACING,
1575
- width: RELIEF_HATCH_WIDTH
1576
- };
1577
- }
1578
- }
1579
- let coastlineStyle = null;
1580
- if (resolved.directives.noCoastline !== true) {
1581
- const minDim = Math.min(width, height);
1582
- coastlineStyle = {
1583
- color: mix(regionStroke, water, COASTLINE_STROKE_MIX),
1584
- // N equal-width rings: distance steps outward by COASTLINE_STEP; opacity
1585
- // fades linearly from NEAR (innermost) to FAR (outermost).
1586
- lines: Array.from({ length: COASTLINE_RING_COUNT }, (_, k) => ({
1587
- d: (COASTLINE_D0 + k * COASTLINE_STEP) * minDim,
1588
- thickness: COASTLINE_THICKNESS * minDim,
1589
- opacity: COASTLINE_OPACITY_NEAR + (COASTLINE_OPACITY_FAR - COASTLINE_OPACITY_NEAR) * k / (COASTLINE_RING_COUNT - 1)
1590
- })),
1591
- minExtent: (isGlobalView ? COASTLINE_MIN_EXTENT_GLOBAL : COASTLINE_MIN_EXTENT) * minDim
1592
1952
  };
1593
- }
1594
- const riverColor = mix(palette.colors.blue, water, 32);
1595
- const rivers = [];
1596
- if (data.rivers) {
1597
- for (const [, f] of decodeLayer(data.rivers)) {
1598
- const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
1599
- if (!viewF) continue;
1600
- const d = path(viewF) ?? "";
1601
- if (!d) continue;
1602
- rivers.push({ d, color: riverColor, width: RIVER_WIDTH });
1953
+ addEdges(buildAdjacency(worldTopo));
1954
+ if (usLayer) {
1955
+ addEdges(buildAdjacency(data.usStates));
1956
+ for (const [country, states] of Object.entries(FOREIGN_BORDER)) {
1957
+ const cn = adjacency.get(country);
1958
+ if (!cn) continue;
1959
+ for (const st of states) {
1960
+ const sn = adjacency.get(st);
1961
+ if (!sn) continue;
1962
+ cn.push(st);
1963
+ sn.push(country);
1964
+ }
1965
+ }
1603
1966
  }
1967
+ const { byIso, huesNeeded } = assignColors(
1968
+ [...adjacency.keys()],
1969
+ adjacency
1970
+ );
1971
+ const tints = politicalTints(palette, huesNeeded, isDark);
1972
+ for (const [iso, idx] of byIso) colorByIso.set(iso, tints[idx]);
1604
1973
  }
1605
- const sizeVals = resolved.pois.map((p) => Number(p.meta["size"])).filter((n) => Number.isFinite(n) && n > 0);
1606
- const sizeMin = sizeVals.length ? Math.min(...sizeVals) : 0;
1607
- const sizeMax = sizeVals.length ? Math.max(...sizeVals) : 0;
1608
- const radiusFor = (p) => {
1609
- const v = Number(p.meta["size"]);
1610
- if (!Number.isFinite(v) || v <= 0 || sizeMax <= 0) return R_DEFAULT;
1611
- const t = sizeMax > sizeMin ? (Math.sqrt(v) - Math.sqrt(sizeMin)) / (Math.sqrt(sizeMax) - Math.sqrt(sizeMin)) : 1;
1612
- return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
1974
+ const colorizeStroke = (fill) => mix(fill, palette.text, 35);
1975
+ const rampBase = isDark ? mix(palette.surface, palette.text, 28) : palette.bg;
1976
+ const rampLowFloor = mix(rampHue, rampBase, RAMP_FLOOR);
1977
+ const fillForValue = (s) => {
1978
+ const t = rampMax > rampMin ? (s - rampMin) / (rampMax - rampMin) : 1;
1979
+ if (rampLow !== void 0)
1980
+ return valueRampColor(rampLow, rampHue, t, { isDark });
1981
+ const pct = RAMP_FLOOR + Math.max(0, Math.min(1, t)) * (100 - RAMP_FLOOR);
1982
+ return mix(rampHue, rampBase, pct);
1613
1983
  };
1614
- const fillOpacityFor = (r) => {
1615
- const t = Math.max(0, Math.min(1, (r - R_MIN) / (R_MAX - R_MIN)));
1616
- return POI_FILL_OPACITY_MAX - t * (POI_FILL_OPACITY_MAX - POI_FILL_OPACITY_MIN);
1984
+ const tagFill = (tags, groupName) => {
1985
+ if (!groupName) return null;
1986
+ const group = resolved.tagGroups.find(
1987
+ (g) => g.name.toLowerCase() === groupName.toLowerCase()
1988
+ );
1989
+ if (!group) return null;
1990
+ const val = tags[tagAttrKey(group.name)] ?? group.defaultValue;
1991
+ if (!val) return null;
1992
+ const entry = group.entries.find(
1993
+ (e) => e.value.toLowerCase() === val.toLowerCase()
1994
+ );
1995
+ if (!entry?.color) return null;
1996
+ return mix(
1997
+ entry.color,
1998
+ palette.bg,
1999
+ isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT
2000
+ );
1617
2001
  };
1618
- const poiFill = (p) => {
1619
- const directHex = p.color ? resolveColor(p.color, palette) : null;
1620
- if (directHex)
1621
- return { fill: directHex, stroke: mix(directHex, palette.text, 18) };
1622
- for (const group of resolved.tagGroups) {
1623
- const val = p.tags[tagAttrKey(group.name)];
1624
- if (!val) continue;
1625
- const entry = group.entries.find(
1626
- (e) => e.value.toLowerCase() === val.toLowerCase()
1627
- );
1628
- const hex = entry?.color;
1629
- if (hex) return { fill: hex, stroke: mix(hex, palette.text, 18) };
1630
- }
1631
- return {
1632
- fill: palette.colors.orange,
1633
- stroke: mix(palette.colors.orange, palette.text, 18)
1634
- };
2002
+ const directFill = (name) => {
2003
+ const hex = name ? resolveColor(name, palette) : null;
2004
+ if (!hex) return null;
2005
+ return mix(hex, palette.bg, isDark ? TAG_TINT_DARK : TAG_TINT_LIGHT);
1635
2006
  };
1636
- const lineColor = (tags) => {
1637
- for (const group of resolved.tagGroups) {
1638
- const val = tags[tagAttrKey(group.name)];
1639
- if (!val) continue;
1640
- const entry = group.entries.find(
1641
- (e) => e.value.toLowerCase() === val.toLowerCase()
1642
- );
1643
- if (entry?.color) return entry.color;
2007
+ const regionFill = (r) => {
2008
+ const direct = directFill(r.color);
2009
+ if (direct) return direct;
2010
+ if (activeIsScore) {
2011
+ return r.value !== void 0 ? fillForValue(r.value) : neutralFill;
1644
2012
  }
1645
- return null;
2013
+ if (colorizeActive) return (r.iso && colorByIso.get(r.iso)) ?? neutralFill;
2014
+ return tagFill(r.tags, activeGroup) ?? neutralFill;
1646
2015
  };
1647
- const routeNumberById = /* @__PURE__ */ new Map();
1648
- const originIds = /* @__PURE__ */ new Set();
1649
- for (const rt of resolved.routes) {
1650
- rt.stopIds.forEach((id, i) => {
1651
- if (i === 0) originIds.add(id);
1652
- if (!routeNumberById.has(id)) routeNumberById.set(id, i + 1);
1653
- });
2016
+ const regionById = new Map(resolved.regions.map((r) => [r.iso, r]));
2017
+ let legend = null;
2018
+ if (!resolved.directives.noLegend) {
2019
+ const legendTagGroups = resolved.tagGroups.map((g) => ({
2020
+ name: g.name,
2021
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color }))
2022
+ }));
2023
+ if (legendTagGroups.length > 0 || hasRamp) {
2024
+ legend = {
2025
+ tagGroups: legendTagGroups,
2026
+ activeGroup,
2027
+ ...hasRamp && {
2028
+ ramp: {
2029
+ ...resolved.directives.regionMetric !== void 0 && {
2030
+ metric: resolved.directives.regionMetric
2031
+ },
2032
+ min: rampMin,
2033
+ max: rampMax,
2034
+ low: rampLow ?? rampLowFloor,
2035
+ high: rampHue
2036
+ }
2037
+ }
2038
+ };
2039
+ }
1654
2040
  }
1655
- const poiScreen = /* @__PURE__ */ new Map();
1656
- const pois = [];
1657
- const orderedPois = [...resolved.pois].sort(
1658
- (a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1)
1659
- );
1660
- const projected = [];
1661
- for (const p of orderedPois) {
1662
- const xy = project(p.lon, p.lat);
1663
- if (xy) projected.push({ p, xy });
2041
+ const shownTitle = resolved.directives.noTitle ? null : resolved.title;
2042
+ const TITLE_GAP = 16;
2043
+ let topPad = FIT_PAD;
2044
+ if (shownTitle && resolved.pois.length > 0) {
2045
+ const bannerBottom = (resolved.subtitle ? TITLE_Y + TITLE_FONT_SIZE : TITLE_Y) + TITLE_FONT_SIZE / 2;
2046
+ topPad = Math.max(FIT_PAD, bannerBottom + TITLE_GAP);
1664
2047
  }
1665
- const placePoi = (e, cx, cy, clusterId) => {
1666
- const { fill, stroke } = poiFill(e.p);
1667
- const r = radiusFor(e.p);
1668
- poiScreen.set(e.p.id, { cx, cy, r });
1669
- const num = routeNumberById.get(e.p.id);
1670
- pois.push({
1671
- id: e.p.id,
1672
- cx,
1673
- cy,
1674
- r,
1675
- fill,
1676
- fillOpacity: fillOpacityFor(r),
1677
- stroke,
1678
- lineNumber: e.p.lineNumber,
1679
- implicit: !!e.p.implicit,
1680
- isOrigin: originIds.has(e.p.id),
1681
- ...num !== void 0 && { routeNumber: num },
1682
- ...Object.keys(e.p.tags).length > 0 && { tags: e.p.tags },
1683
- ...clusterId !== void 0 && { clusterId }
2048
+ const legendBand = mapLegendBand(legend, {
2049
+ width,
2050
+ mode: opts.legendMode ?? "preview",
2051
+ hasTitle: Boolean(shownTitle),
2052
+ hasSubtitle: Boolean(resolved.subtitle)
2053
+ });
2054
+ if (legendBand > topPad) topPad = legendBand;
2055
+ const topReserved = [];
2056
+ if (shownTitle) {
2057
+ const lines = resolved.subtitle ? 2 : 1;
2058
+ const tw = Math.max(
2059
+ measureLegendText(shownTitle, TITLE_FONT_SIZE),
2060
+ resolved.subtitle ? measureLegendText(resolved.subtitle, TITLE_FONT_SIZE) : 0
2061
+ ) + 2 * TITLE_FONT_SIZE;
2062
+ topReserved.push({
2063
+ x: width / 2 - tw / 2,
2064
+ y: TITLE_Y - TITLE_FONT_SIZE,
2065
+ w: tw,
2066
+ h: TITLE_FONT_SIZE * (lines + 0.5)
1684
2067
  });
1685
- };
1686
- const clusters = [];
1687
- const connected = /* @__PURE__ */ new Set();
1688
- for (const e of resolved.edges) {
1689
- connected.add(e.fromId);
1690
- connected.add(e.toId);
1691
2068
  }
1692
- for (const rt of resolved.routes) {
1693
- rt.stopIds.forEach((id) => connected.add(id));
2069
+ const legendBox = mapLegendBox(legend, {
2070
+ width,
2071
+ mode: opts.legendMode ?? "preview",
2072
+ hasTitle: Boolean(shownTitle),
2073
+ hasSubtitle: Boolean(resolved.subtitle)
2074
+ });
2075
+ if (legendBox)
2076
+ topReserved.push({
2077
+ x: legendBox.x,
2078
+ y: legendBox.y,
2079
+ w: legendBox.width,
2080
+ h: legendBox.height
2081
+ });
2082
+ const reserve = opts._calloutReserve;
2083
+ const fitLeft = FIT_PAD + (reserve?.left ?? 0);
2084
+ const fitRight = width - FIT_PAD - (reserve?.right ?? 0);
2085
+ const fitTop = topPad + (reserve?.top ?? 0);
2086
+ const fitBottom = height - FIT_PAD - (reserve?.bottom ?? 0);
2087
+ const fitBox = [
2088
+ [fitLeft, fitTop],
2089
+ [Math.max(fitLeft + 1, fitRight), Math.max(fitTop + 1, fitBottom)]
2090
+ ];
2091
+ projection.fitExtent(fitBox, fitTarget);
2092
+ if (!fitIsGlobal && resolved.projection !== "albers-usa" && resolved.regions.length > 0) {
2093
+ let yMin = Infinity;
2094
+ let yMax = -Infinity;
2095
+ for (const r of resolved.regions) {
2096
+ const bb = r.iso ? featureBboxPrimary(data.worldCoarse, r.iso) : null;
2097
+ if (!bb) continue;
2098
+ for (const lon of [bb[0][0], bb[1][0]]) {
2099
+ for (const lat of [bb[0][1], bb[1][1]]) {
2100
+ const p = projection([lon, lat]);
2101
+ if (p && Number.isFinite(p[1])) {
2102
+ if (p[1] < yMin) yMin = p[1];
2103
+ if (p[1] > yMax) yMax = p[1];
2104
+ }
2105
+ }
2106
+ }
2107
+ }
2108
+ if (yMin < yMax) {
2109
+ const boxTop = fitTop;
2110
+ const boxBottom = fitBottom;
2111
+ let dy = (boxTop + boxBottom) / 2 - (yMin + yMax) / 2;
2112
+ const minDy = boxTop - yMin;
2113
+ const maxDy = boxBottom - yMax;
2114
+ if (minDy <= maxDy) dy = Math.max(minDy, Math.min(maxDy, dy));
2115
+ const [tx, ty] = projection.translate();
2116
+ projection.translate([tx, ty + dy]);
2117
+ }
1694
2118
  }
1695
- const radiusOf = (e) => radiusFor(e.p);
1696
- for (const e of projected) {
1697
- if (connected.has(e.p.id)) placePoi(e, e.xy[0], e.xy[1]);
2119
+ if (!fitIsGlobal && resolved.projection === "albers-usa") {
2120
+ const cb = geoPath2(projection).bounds(fitTarget);
2121
+ if (Number.isFinite(cb[0][1])) {
2122
+ const slack = fitBottom - fitTop - (cb[1][1] - cb[0][1]);
2123
+ if (slack > 0) {
2124
+ const currentTopGap = cb[0][1] - fitTop;
2125
+ const dy = slack * 0.3 - currentTopGap;
2126
+ const [tx, ty] = projection.translate();
2127
+ projection.translate([tx, ty + dy]);
2128
+ }
2129
+ }
1698
2130
  }
1699
- const groups = [];
1700
- for (const e of projected) {
1701
- if (connected.has(e.p.id)) continue;
1702
- const r = radiusOf(e);
1703
- const near = groups.find(
1704
- (g) => g.some(
1705
- (q) => Math.hypot(q.xy[0] - e.xy[0], q.xy[1] - e.xy[1]) < (r + radiusOf(q)) * STACK_OVERLAP
2131
+ let path;
2132
+ let project;
2133
+ let stretchParams = null;
2134
+ if (fitIsGlobal && !opts.preferContain) {
2135
+ const cb = geoPath2(projection).bounds(fitTarget);
2136
+ const bx0 = cb[0][0];
2137
+ const by0 = cb[0][1];
2138
+ const cw = cb[1][0] - bx0;
2139
+ const ch = cb[1][1] - by0;
2140
+ const topReserve = resolved.title && resolved.pois.length > 0 || legendBand > 0 ? topPad : 0;
2141
+ const ox = 0;
2142
+ const oy = topReserve;
2143
+ const sx = cw > 0 ? width / cw : 1;
2144
+ const sy = ch > 0 ? (height - topReserve) / ch : 1;
2145
+ stretchParams = { sx, sy, ox, oy, bx0, by0 };
2146
+ const stretch = (x, y) => [
2147
+ ox + (x - bx0) * sx,
2148
+ oy + (y - by0) * sy
2149
+ ];
2150
+ const baseProjection = projection;
2151
+ const tx = geoTransform({
2152
+ point(x, y) {
2153
+ const [px, py] = stretch(x, y);
2154
+ this.stream.point(px, py);
2155
+ }
2156
+ });
2157
+ const thin = geoThin();
2158
+ path = geoPath2({
2159
+ stream: (s) => baseProjection.stream(
2160
+ tx.stream(
2161
+ thin.stream(s)
2162
+ )
1706
2163
  )
1707
- );
1708
- if (near) near.push(e);
1709
- else groups.push([e]);
2164
+ }).digits(PATH_DIGITS);
2165
+ project = (lon, lat) => {
2166
+ const p = baseProjection([lon, lat]);
2167
+ return p ? stretch(p[0], p[1]) : null;
2168
+ };
2169
+ } else {
2170
+ projection.clipExtent([
2171
+ [0, 0],
2172
+ [width, height]
2173
+ ]);
2174
+ const thin = geoThin();
2175
+ path = geoPath2({
2176
+ stream: (s) => projection.stream(
2177
+ thin.stream(s)
2178
+ )
2179
+ }).digits(PATH_DIGITS);
2180
+ project = (lon, lat) => projection([lon, lat]) ?? null;
1710
2181
  }
1711
- for (const g of groups) {
1712
- if (g.length === 1) {
1713
- placePoi(g[0], g[0].xy[0], g[0].xy[1]);
1714
- continue;
2182
+ const regionGeo = regionGeoBucket(
2183
+ resolvedIn,
2184
+ JSON.stringify([
2185
+ width,
2186
+ height,
2187
+ [
2188
+ [-100, 40],
2189
+ [-120, 37],
2190
+ [-80, 40],
2191
+ [0, 0],
2192
+ [60, 30],
2193
+ [-60, -30],
2194
+ [150, 60],
2195
+ [-150, -60],
2196
+ [100, -40],
2197
+ [-30, 50]
2198
+ ].map(([lo, la]) => project(lo, la))
2199
+ ])
2200
+ );
2201
+ const akRef = resolved.regions.some((r) => r.iso === "US-AK") || resolved.pois.some((p) => inAlaska(p.lon, p.lat));
2202
+ const hiRef = resolved.regions.some((r) => r.iso === "US-HI") || resolved.pois.some((p) => inHawaii(p.lon, p.lat));
2203
+ const { insets, insetRegions, insetLabelSeeds } = resolved.projection === "albers-usa" && usLayer && (akRef || hiRef) ? layoutInsets({
2204
+ resolved,
2205
+ projection,
2206
+ usLayer,
2207
+ worldLayer,
2208
+ regionById,
2209
+ width,
2210
+ height,
2211
+ fitPad: FIT_PAD,
2212
+ pathDigits: PATH_DIGITS,
2213
+ nonConus: US_NON_CONUS,
2214
+ colorizeActive,
2215
+ colorByIso,
2216
+ neutralFill,
2217
+ foreignFill,
2218
+ regionStroke,
2219
+ colorizeStroke,
2220
+ regionFill,
2221
+ alaskaProjection,
2222
+ hawaiiProjection,
2223
+ akRef,
2224
+ hiRef
2225
+ }) : { insets: [], insetRegions: [], insetLabelSeeds: [] };
2226
+ const conusFit = resolved.projection === "albers-usa" && !!usLayer;
2227
+ const classifyExtent = conusFit ? geoBounds(fitTarget) : resolved.extent;
2228
+ const dLonSpan = classifyExtent[1][0] - classifyExtent[0][0];
2229
+ const dLatSpan = classifyExtent[1][1] - classifyExtent[0][1];
2230
+ const isGlobalView = dLonSpan >= 270 || dLatSpan >= 130;
2231
+ const vLonCenter = (classifyExtent[0][0] + classifyExtent[1][0]) / 2;
2232
+ const normLon = (lon) => {
2233
+ let L = lon;
2234
+ while (L < vLonCenter - 180) L += 360;
2235
+ while (L > vLonCenter + 180) L -= 360;
2236
+ return L;
2237
+ };
2238
+ const ringOverlapsView = (ring) => {
2239
+ let loMin = Infinity, loMax = -Infinity, rawMin = Infinity, rawMax = -Infinity;
2240
+ const lons = [];
2241
+ for (const [rawLon] of ring) {
2242
+ const lon = normLon(rawLon);
2243
+ lons.push(lon);
2244
+ if (lon < loMin) loMin = lon;
2245
+ if (lon > loMax) loMax = lon;
2246
+ if (rawLon < rawMin) rawMin = rawLon;
2247
+ if (rawLon > rawMax) rawMax = rawLon;
1715
2248
  }
1716
- const clusterId = g[0].p.id;
1717
- const cx0 = g.reduce((s, e) => s + e.xy[0], 0) / g.length;
1718
- const cy0 = g.reduce((s, e) => s + e.xy[1], 0) / g.length;
1719
- const maxR = Math.max(...g.map(radiusOf));
1720
- const sep = 2 * maxR + STACK_RING_GAP;
1721
- const ringR = Math.max(
1722
- COLO_R,
1723
- sep / (2 * Math.sin(Math.PI / Math.max(g.length, 2)))
1724
- );
1725
- const positions = g.map((e, i) => {
1726
- if (g.length <= STACK_RING_MAX) {
1727
- const ang2 = -Math.PI / 2 + i * 2 * Math.PI / g.length;
1728
- return {
1729
- e,
1730
- mx: cx0 + Math.cos(ang2) * ringR,
1731
- my: cy0 + Math.sin(ang2) * ringR
1732
- };
1733
- }
1734
- const ang = i * GOLDEN_ANGLE;
1735
- const rr = ringR * Math.sqrt((i + 1) / g.length);
1736
- return { e, mx: cx0 + Math.cos(ang) * rr, my: cy0 + Math.sin(ang) * rr };
1737
- });
1738
- let minX = cx0 - maxR;
1739
- let maxX = cx0 + maxR;
1740
- let minY = cy0 - maxR;
1741
- let maxY = cy0 + maxR;
1742
- for (const { mx, my, e } of positions) {
1743
- const r = radiusOf(e);
1744
- minX = Math.min(minX, mx - r);
1745
- maxX = Math.max(maxX, mx + r);
1746
- minY = Math.min(minY, my - r);
1747
- maxY = Math.max(maxY, my + r);
2249
+ lons.sort((a, b) => a - b);
2250
+ let maxGap = 0;
2251
+ for (let i = 1; i < lons.length; i++)
2252
+ maxGap = Math.max(maxGap, lons[i] - lons[i - 1]);
2253
+ if (lons.length > 1)
2254
+ maxGap = Math.max(maxGap, lons[0] + 360 - lons[lons.length - 1]);
2255
+ const occupiedArc = 360 - maxGap;
2256
+ if (occupiedArc > 270) return false;
2257
+ if (rawMax - rawMin > 180 && occupiedArc < 90) return false;
2258
+ let px0 = Infinity, py0 = Infinity, px1 = -Infinity, py1 = -Infinity, anyFinite = false;
2259
+ for (const [lon, lat] of ring) {
2260
+ const p = project(lon, lat);
2261
+ if (!p || !Number.isFinite(p[0]) || !Number.isFinite(p[1])) continue;
2262
+ anyFinite = true;
2263
+ if (p[0] < px0) px0 = p[0];
2264
+ if (p[0] > px1) px1 = p[0];
2265
+ if (p[1] < py0) py0 = p[1];
2266
+ if (p[1] > py1) py1 = p[1];
1748
2267
  }
1749
- let dx = 0;
1750
- let dy = 0;
1751
- if (minX + dx < 2) dx = 2 - minX;
1752
- if (maxX + dx > width - 2) dx = width - 2 - maxX;
1753
- if (minY + dy < 2) dy = 2 - minY;
1754
- if (maxY + dy > height - 2) dy = height - 2 - maxY;
1755
- const legsOut = [];
1756
- for (const { e, mx, my } of positions) {
1757
- const fx = mx + dx;
1758
- const fy = my + dy;
1759
- placePoi(e, fx, fy, clusterId);
1760
- legsOut.push({ x2: fx, y2: fy, color: poiFill(e.p).fill });
2268
+ if (!anyFinite) return false;
2269
+ return !(px1 < 0 || px0 > width || py1 < 0 || py0 > height);
2270
+ };
2271
+ const cullFeatureToView = (f) => {
2272
+ if (isGlobalView) return f;
2273
+ const g = f.geometry;
2274
+ if (!g) return f;
2275
+ if (g.type === "Polygon") {
2276
+ const ring = g.coordinates[0];
2277
+ return ringOverlapsView(ring) ? f : null;
1761
2278
  }
1762
- clusters.push({
1763
- id: clusterId,
1764
- cx: cx0 + dx,
1765
- cy: cy0 + dy,
1766
- count: g.length,
1767
- hitR: ringR + maxR + 6,
1768
- legs: legsOut
1769
- });
1770
- }
1771
- const legs = [];
1772
- const RIM_GAP = 1.5;
1773
- const bowMagnitude = (mx, my, nx, ny, offset, len, away) => {
1774
- if (offset !== 0) return offset;
1775
- const base = len * ARC_CURVE_FRAC;
1776
- if (!away) return base;
1777
- const dot = nx * (mx - away.x) + ny * (my - away.y);
1778
- return dot < 0 ? -base : base;
2279
+ if (g.type === "MultiPolygon") {
2280
+ const polys = g.coordinates;
2281
+ const keep = polys.filter(
2282
+ (p) => ringOverlapsView(p[0])
2283
+ );
2284
+ if (!keep.length) return null;
2285
+ if (keep.length === polys.length) return f;
2286
+ return { ...f, geometry: { ...g, coordinates: keep } };
2287
+ }
2288
+ return f;
1779
2289
  };
1780
- const legPath = (a, b, curved, offset, away) => {
1781
- const mx = (a.cx + b.cx) / 2;
1782
- const my = (a.cy + b.cy) / 2;
1783
- const dx = b.cx - a.cx;
1784
- const dy = b.cy - a.cy;
1785
- const len = Math.hypot(dx, dy) || 1;
1786
- const trimA = Math.min(a.r + RIM_GAP, len * 0.45);
1787
- const trimB = Math.min(b.r + RIM_GAP, len * 0.45);
1788
- if (!curved && offset === 0) {
1789
- const ux = dx / len;
1790
- const uy = dy / len;
1791
- const ax2 = a.cx + ux * trimA;
1792
- const ay2 = a.cy + uy * trimA;
1793
- const bx2 = b.cx - ux * trimB;
1794
- const by2 = b.cy - uy * trimB;
1795
- return `M${ax2},${ay2}L${bx2},${by2}`;
2290
+ const SEAM_SLIVER_MAX_SPAN = 100;
2291
+ const ringIsFrameFiller = (ring) => {
2292
+ const lons = ring.map(([lon]) => lon).sort((a, b) => a - b);
2293
+ if (lons.length < 2) return false;
2294
+ let maxGap = -1;
2295
+ let gapIdx = 0;
2296
+ for (let i = 1; i < lons.length; i++) {
2297
+ const g = lons[i] - lons[i - 1];
2298
+ if (g > maxGap) {
2299
+ maxGap = g;
2300
+ gapIdx = i;
2301
+ }
1796
2302
  }
1797
- const nx = -dy / len;
1798
- const ny = dx / len;
1799
- const bow = bowMagnitude(mx, my, nx, ny, offset, len, away);
1800
- const px = mx + nx * bow;
1801
- const py = my + ny * bow;
1802
- const ta = Math.hypot(px - a.cx, py - a.cy) || 1;
1803
- const tb = Math.hypot(b.cx - px, b.cy - py) || 1;
1804
- const ax = a.cx + (px - a.cx) / ta * trimA;
1805
- const ay = a.cy + (py - a.cy) / ta * trimA;
1806
- const bx = b.cx - (b.cx - px) / tb * trimB;
1807
- const by = b.cy - (b.cy - py) / tb * trimB;
1808
- return `M${ax},${ay}Q${px},${py} ${bx},${by}`;
2303
+ const wrapGap = lons[0] + 360 - lons[lons.length - 1];
2304
+ if (wrapGap >= maxGap) return false;
2305
+ const span = 360 - maxGap;
2306
+ const east = lons[gapIdx - 1] + 360;
2307
+ return east > 180 && span < SEAM_SLIVER_MAX_SPAN;
1809
2308
  };
1810
- const legLabelPoint = (a, b, curved, offset, away) => {
1811
- const mx = (a.cx + b.cx) / 2;
1812
- const my = (a.cy + b.cy) / 2;
1813
- if (!curved && offset === 0) return { x: mx, y: my - 4 };
1814
- const dx = b.cx - a.cx;
1815
- const dy = b.cy - a.cy;
1816
- const len = Math.hypot(dx, dy) || 1;
1817
- const nx = -dy / len;
1818
- const ny = dx / len;
1819
- const bow = bowMagnitude(mx, my, nx, ny, offset, len, away);
1820
- const off = bow * 0.5 + Math.sign(bow || 1) * 8;
1821
- return { x: mx + nx * off, y: my + ny * off };
2309
+ const dropFrameFillers = (f) => {
2310
+ const g = f.geometry;
2311
+ if (!g) return f;
2312
+ if (g.type === "Polygon") {
2313
+ const ring = g.coordinates[0];
2314
+ return ringIsFrameFiller(ring) ? null : f;
2315
+ }
2316
+ if (g.type === "MultiPolygon") {
2317
+ const polys = g.coordinates;
2318
+ const keep = polys.filter(
2319
+ (p) => !ringIsFrameFiller(p[0])
2320
+ );
2321
+ if (!keep.length) return null;
2322
+ if (keep.length === polys.length) return f;
2323
+ return { ...f, geometry: { ...g, coordinates: keep } };
2324
+ }
2325
+ return f;
1822
2326
  };
1823
- const LEG_SAMPLES = 25;
1824
- const POI_CLEARANCE = 10;
1825
- const LEG_CLEARANCE = 7;
1826
- const SHARED_END_RADIUS = 36;
1827
- const placedLegSamples = [];
1828
- const sampleLeg = (a, b, curved, bow) => {
1829
- const pts = [];
1830
- const dx = b.cx - a.cx;
1831
- const dy = b.cy - a.cy;
1832
- const len = Math.hypot(dx, dy) || 1;
1833
- const px = (a.cx + b.cx) / 2 + -dy / len * bow;
1834
- const py = (a.cy + b.cy) / 2 + dx / len * bow;
1835
- for (let i = 0; i < LEG_SAMPLES; i++) {
1836
- const t = i / (LEG_SAMPLES - 1);
1837
- if (!curved && bow === 0) {
1838
- pts.push({ x: a.cx + dx * t, y: a.cy + dy * t });
2327
+ const regions = [];
2328
+ const pushRegionLayer = (layerFeatures, layerKind, shouldCull) => {
2329
+ for (const [iso, f] of layerFeatures) {
2330
+ if (layerKind === "us-state" && usContext && resolved.projection === "albers-usa" && INSET_STATES.has(iso))
2331
+ continue;
2332
+ if (layerKind === "country" && usContext && iso === "US") continue;
2333
+ if (layerKind === "country" && iso === "AQ" && !regionById.has("AQ"))
2334
+ continue;
2335
+ const r = regionById.get(iso);
2336
+ const geoK = layerKind + ":" + iso;
2337
+ let geo = regionGeo.get(geoK);
2338
+ if (geo === void 0) {
2339
+ const viewF = shouldCull ? cullFeatureToView(f) : dropFrameFillers(f);
2340
+ if (!viewF) {
2341
+ regionGeo.set(geoK, null);
2342
+ continue;
2343
+ }
2344
+ const raw = path(viewF) ?? "";
2345
+ const d0 = fitIsGlobal ? dropAntimeridianWrapSlivers(raw, width, height) : raw;
2346
+ if (!d0) {
2347
+ regionGeo.set(geoK, null);
2348
+ continue;
2349
+ }
2350
+ const anchor = WORLD_LABEL_ANCHORS[iso];
2351
+ const cc = anchor ? project(anchor[0], anchor[1]) : path.centroid(viewF);
2352
+ const ok = cc != null && Number.isFinite(cc[0]) && Number.isFinite(cc[1]);
2353
+ geo = { d: d0, cx: ok ? cc[0] : void 0, cy: ok ? cc[1] : void 0 };
2354
+ regionGeo.set(geoK, geo);
2355
+ } else if (geo === null) {
2356
+ continue;
2357
+ }
2358
+ const d = geo.d;
2359
+ const hasCentroid = geo.cx !== void 0 && geo.cy !== void 0;
2360
+ const isThisLayer = r?.layer === layerKind;
2361
+ const isForeign = layerKind === "country" && usContext && iso !== "US";
2362
+ const baseFill = isForeign ? foreignFill : neutralFill;
2363
+ let fill = colorizeActive ? colorByIso.get(iso) ?? baseFill : baseFill;
2364
+ let label;
2365
+ let lineNumber = -1;
2366
+ let layer = "base";
2367
+ if (isThisLayer) {
2368
+ fill = regionFill(r);
2369
+ lineNumber = r.lineNumber;
2370
+ layer = layerKind;
2371
+ label = r.name;
1839
2372
  } else {
1840
- const u = 1 - t;
1841
- pts.push({
1842
- x: u * u * a.cx + 2 * u * t * px + t * t * b.cx,
1843
- y: u * u * a.cy + 2 * u * t * py + t * t * b.cy
1844
- });
2373
+ label = f.properties?.name;
2374
+ }
2375
+ regions.push({
2376
+ id: iso,
2377
+ d,
2378
+ fill,
2379
+ stroke: colorizeActive ? colorizeStroke(fill) : regionStroke,
2380
+ lineNumber,
2381
+ layer,
2382
+ ...label !== void 0 && { label },
2383
+ ...hasCentroid && { labelX: geo.cx, labelY: geo.cy },
2384
+ ...isThisLayer && r.value !== void 0 && { value: r.value },
2385
+ ...isThisLayer && Object.keys(r.tags).length > 0 && { tags: r.tags }
2386
+ });
2387
+ }
2388
+ };
2389
+ pushRegionLayer(worldLayer, "country", !isGlobalView);
2390
+ if (usLayer) pushRegionLayer(usLayer, "us-state", !conusFit && !isGlobalView);
2391
+ const lakesTopo = usCrisp && data.naLakes ? data.naLakes : data.lakes;
2392
+ if (lakesTopo) {
2393
+ for (const [, f] of decodeLayer(lakesTopo)) {
2394
+ const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
2395
+ if (!viewF) continue;
2396
+ const d = path(viewF) ?? "";
2397
+ if (!d) continue;
2398
+ regions.push({
2399
+ id: "lake",
2400
+ d,
2401
+ fill: water,
2402
+ stroke: lakeStroke,
2403
+ lineNumber: -1,
2404
+ layer: "base"
2405
+ });
2406
+ }
2407
+ }
2408
+ const pointInRings = (px, py, rings) => {
2409
+ let inside = false;
2410
+ for (const ring of rings) {
2411
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2412
+ const [xi, yi] = ring[i];
2413
+ const [xj, yj] = ring[j];
2414
+ if (yi > py !== yj > py && px < (xj - xi) * (py - yi) / (yj - yi) + xi)
2415
+ inside = !inside;
1845
2416
  }
1846
2417
  }
1847
- return pts;
2418
+ return inside;
1848
2419
  };
1849
- const legPenalty = (pts, fromId, toId) => {
1850
- let pen = 0;
1851
- for (const [id, p] of poiScreen) {
1852
- if (id === fromId || id === toId) continue;
1853
- let dmin = Infinity;
1854
- for (const s of pts) {
1855
- const d = Math.hypot(s.x - p.cx, s.y - p.cy);
1856
- if (d < dmin) dmin = d;
2420
+ const fillHitTargets = [...regions, ...insetRegions].map((r) => {
2421
+ const rings = parsePathRings(r.d);
2422
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
2423
+ for (const ring of rings)
2424
+ for (const p of ring) {
2425
+ if (p[0] < minX) minX = p[0];
2426
+ if (p[0] > maxX) maxX = p[0];
2427
+ if (p[1] < minY) minY = p[1];
2428
+ if (p[1] > maxY) maxY = p[1];
1857
2429
  }
1858
- const clear = p.r + POI_CLEARANCE;
1859
- if (dmin < clear) pen += (clear - dmin) * 12;
2430
+ r.bbox = [minX, minY, maxX, maxY];
2431
+ r.rings = rings;
2432
+ return { fill: r.fill, rings, minX, minY, maxX, maxY };
2433
+ });
2434
+ const fillAt = (x, y) => {
2435
+ let hit = water;
2436
+ for (const t of fillHitTargets) {
2437
+ if (x < t.minX || x > t.maxX || y < t.minY || y > t.maxY) continue;
2438
+ if (pointInRings(x, y, t.rings)) hit = t.fill;
1860
2439
  }
1861
- for (const other of placedLegSamples) {
1862
- const shared = [fromId, toId].filter((id) => id === other.fromId || id === other.toId).map((id) => poiScreen.get(id)).filter((p) => !!p);
1863
- for (const s of pts) {
1864
- if (shared.some(
1865
- (sp) => Math.hypot(s.x - sp.cx, s.y - sp.cy) < SHARED_END_RADIUS
1866
- )) {
1867
- continue;
1868
- }
1869
- let dmin = Infinity;
1870
- for (const o of other.pts) {
1871
- const d = Math.hypot(s.x - o.x, s.y - o.y);
1872
- if (d < dmin) dmin = d;
1873
- }
1874
- if (dmin < LEG_CLEARANCE) pen += 6;
1875
- }
2440
+ return hit;
2441
+ };
2442
+ const labelOnFill = (fill) => {
2443
+ const color = contrastRatio(fill, palette.textOnFillDark) >= contrastRatio(fill, palette.textOnFillLight) ? palette.textOnFillDark : palette.textOnFillLight;
2444
+ const haloColor = color === palette.textOnFillLight ? palette.textOnFillDark : palette.textOnFillLight;
2445
+ return {
2446
+ color,
2447
+ halo: contrastRatio(fill, color) < REGION_LABEL_HALO_RATIO,
2448
+ haloColor
2449
+ };
2450
+ };
2451
+ const reliefAllowed = resolved.directives.noRelief !== true;
2452
+ const relief = [];
2453
+ let reliefHatch = null;
2454
+ if (reliefAllowed && data.mountainRanges) {
2455
+ for (const [, f] of decodeLayer(data.mountainRanges)) {
2456
+ const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
2457
+ if (!viewF) continue;
2458
+ const area = path.area(viewF);
2459
+ if (!Number.isFinite(area) || area < RELIEF_MIN_AREA) continue;
2460
+ const box = path.bounds(viewF);
2461
+ if (box[1][0] - box[0][0] < RELIEF_MIN_DIM || box[1][1] - box[0][1] < RELIEF_MIN_DIM)
2462
+ continue;
2463
+ const d = path(viewF) ?? "";
2464
+ if (!d) continue;
2465
+ relief.push({ d });
1876
2466
  }
1877
- for (const s of pts) {
1878
- if (s.x < 0 || s.x > width || s.y < 0 || s.y > height) pen += 20;
2467
+ if (relief.length) {
2468
+ const darkTone = isDark ? palette.bg : palette.text;
2469
+ const lightTone = isDark ? palette.text : palette.bg;
2470
+ const reliefLandRef = colorizeActive ? isDark ? palette.surface : palette.bg : neutralFill;
2471
+ const landLum = relativeLuminance(reliefLandRef);
2472
+ const tone = Math.abs(landLum - relativeLuminance(darkTone)) > 0.04 ? darkTone : lightTone;
2473
+ reliefHatch = {
2474
+ color: mix(tone, reliefLandRef, RELIEF_HATCH_STRENGTH),
2475
+ spacing: RELIEF_HATCH_SPACING,
2476
+ width: RELIEF_HATCH_WIDTH
2477
+ };
1879
2478
  }
1880
- return pen;
1881
- };
1882
- const chooseArcBow = (a, b, fromId, toId, away) => {
1883
- const mx = (a.cx + b.cx) / 2;
1884
- const my = (a.cy + b.cy) / 2;
1885
- const dx = b.cx - a.cx;
1886
- const dy = b.cy - a.cy;
1887
- const len = Math.hypot(dx, dy) || 1;
1888
- const nx = -dy / len;
1889
- const ny = dx / len;
1890
- const def = bowMagnitude(mx, my, nx, ny, 0, len, away);
1891
- const candidates = [
1892
- def,
1893
- -def,
1894
- def * 1.6,
1895
- -def * 1.6,
1896
- def * 2.2,
1897
- -def * 2.2
1898
- ];
1899
- let best = def;
1900
- let bestPen = Infinity;
1901
- for (const c of candidates) {
1902
- const pen = legPenalty(sampleLeg(a, b, true, c), fromId, toId);
1903
- if (pen < bestPen - 1e-6) {
1904
- best = c;
1905
- bestPen = pen;
1906
- }
1907
- if (bestPen === 0) break;
2479
+ }
2480
+ let coastlineStyle = null;
2481
+ if (resolved.directives.noCoastline !== true) {
2482
+ const minDim = Math.min(width, height);
2483
+ coastlineStyle = {
2484
+ color: mix(regionStroke, water, COASTLINE_STROKE_MIX),
2485
+ // N equal-width rings: distance steps outward by COASTLINE_STEP; opacity
2486
+ // fades linearly from NEAR (innermost) to FAR (outermost).
2487
+ lines: Array.from({ length: COASTLINE_RING_COUNT }, (_, k) => ({
2488
+ d: (COASTLINE_D0 + k * COASTLINE_STEP) * minDim,
2489
+ thickness: COASTLINE_THICKNESS * minDim,
2490
+ opacity: COASTLINE_OPACITY_NEAR + (COASTLINE_OPACITY_FAR - COASTLINE_OPACITY_NEAR) * k / (COASTLINE_RING_COUNT - 1)
2491
+ })),
2492
+ minExtent: (isGlobalView ? COASTLINE_MIN_EXTENT_GLOBAL : COASTLINE_MIN_EXTENT) * minDim
2493
+ };
2494
+ }
2495
+ const riverColor = mix(palette.colors.blue, water, 32);
2496
+ const rivers = [];
2497
+ if (data.rivers) {
2498
+ for (const [, f] of decodeLayer(data.rivers)) {
2499
+ const viewF = isGlobalView ? dropFrameFillers(f) : cullFeatureToView(f);
2500
+ if (!viewF) continue;
2501
+ const d = path(viewF) ?? "";
2502
+ if (!d) continue;
2503
+ rivers.push({ d, color: riverColor, width: RIVER_WIDTH });
1908
2504
  }
1909
- return best;
2505
+ }
2506
+ const sizeVals = resolved.pois.map((p) => Number(p.meta["size"])).filter((n) => Number.isFinite(n) && n > 0);
2507
+ const sizeMin = sizeVals.length ? Math.min(...sizeVals) : 0;
2508
+ const sizeMax = sizeVals.length ? Math.max(...sizeVals) : 0;
2509
+ const radiusFor = (p) => {
2510
+ const v = Number(p.meta["size"]);
2511
+ if (!Number.isFinite(v) || v <= 0 || sizeMax <= 0) return R_DEFAULT;
2512
+ const t = sizeMax > sizeMin ? (Math.sqrt(v) - Math.sqrt(sizeMin)) / (Math.sqrt(sizeMax) - Math.sqrt(sizeMin)) : 1;
2513
+ return R_MIN + Math.max(0, Math.min(1, t)) * (R_MAX - R_MIN);
1910
2514
  };
1911
- const routeLegVals = resolved.routes.flatMap((rt) => rt.legs).map((l) => Number(l.value)).filter((n) => Number.isFinite(n) && n > 0);
1912
- const rlMin = routeLegVals.length ? Math.min(...routeLegVals) : 0;
1913
- const rlMax = routeLegVals.length ? Math.max(...routeLegVals) : 0;
1914
- const routeWidthFor = (v) => {
1915
- if (!Number.isFinite(v) || v <= 0 || rlMax <= 0) return W_MIN;
1916
- const t = rlMax > rlMin ? (v - rlMin) / (rlMax - rlMin) : 1;
1917
- return W_MIN + t * (W_MAX - W_MIN);
2515
+ const fillOpacityFor = (r) => {
2516
+ const t = Math.max(0, Math.min(1, (r - R_MIN) / (R_MAX - R_MIN)));
2517
+ return POI_FILL_OPACITY_MAX - t * (POI_FILL_OPACITY_MAX - POI_FILL_OPACITY_MIN);
1918
2518
  };
1919
- for (const rt of resolved.routes) {
1920
- const stopPts = rt.stopIds.map((id) => poiScreen.get(id)).filter((p) => !!p);
1921
- const center = stopPts.length >= 3 ? {
1922
- x: stopPts.reduce((s, p) => s + p.cx, 0) / stopPts.length,
1923
- y: stopPts.reduce((s, p) => s + p.cy, 0) / stopPts.length
1924
- } : void 0;
1925
- for (const leg of rt.legs) {
1926
- const a = poiScreen.get(leg.fromId);
1927
- const b = poiScreen.get(leg.toId);
1928
- if (!a || !b) continue;
1929
- const curvedLeg = leg.style === "arc";
1930
- const chosenBow = curvedLeg ? chooseArcBow(a, b, leg.fromId, leg.toId, center) : 0;
1931
- const lp = legLabelPoint(a, b, curvedLeg, chosenBow, center);
1932
- const bow = {
1933
- curved: curvedLeg,
1934
- offset: chosenBow,
1935
- center,
1936
- labelX: lp.x,
1937
- labelY: lp.y
1938
- };
1939
- placedLegSamples.push({
1940
- fromId: leg.fromId,
1941
- toId: leg.toId,
1942
- pts: sampleLeg(a, b, curvedLeg, chosenBow)
1943
- });
1944
- const routeLabelStyle = leg.label !== void 0 ? labelOnFill(fillAt(bow.labelX, bow.labelY)) : void 0;
1945
- const routeVal = Number(leg.value);
1946
- legs.push({
1947
- d: legPath(a, b, bow.curved, bow.offset, bow.center),
1948
- width: routeWidthFor(routeVal),
1949
- color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
1950
- arrow: true,
1951
- fromId: leg.fromId,
1952
- toId: leg.toId,
1953
- ...Number.isFinite(routeVal) && routeVal > 0 && { value: routeVal },
1954
- ...Object.keys(leg.tags).length > 0 && { tags: leg.tags },
1955
- lineNumber: leg.lineNumber,
1956
- ...leg.label !== void 0 && {
1957
- label: leg.label,
1958
- labelX: bow.labelX,
1959
- labelY: bow.labelY,
1960
- labelColor: routeLabelStyle.color,
1961
- labelHalo: routeLabelStyle.halo,
1962
- labelHaloColor: routeLabelStyle.haloColor
1963
- }
1964
- });
2519
+ const poiFill = (p) => {
2520
+ const directHex = p.color ? resolveColor(p.color, palette) : null;
2521
+ if (directHex)
2522
+ return { fill: directHex, stroke: mix(directHex, palette.text, 18) };
2523
+ for (const group of resolved.tagGroups) {
2524
+ const val = p.tags[tagAttrKey(group.name)];
2525
+ if (!val) continue;
2526
+ const entry = group.entries.find(
2527
+ (e) => e.value.toLowerCase() === val.toLowerCase()
2528
+ );
2529
+ const hex = entry?.color;
2530
+ if (hex) return { fill: hex, stroke: mix(hex, palette.text, 18) };
1965
2531
  }
2532
+ return {
2533
+ fill: palette.colors.orange,
2534
+ stroke: mix(palette.colors.orange, palette.text, 18)
2535
+ };
2536
+ };
2537
+ const routeNumberById = /* @__PURE__ */ new Map();
2538
+ const originIds = /* @__PURE__ */ new Set();
2539
+ for (const rt of resolved.routes) {
2540
+ rt.stopIds.forEach((id, i) => {
2541
+ if (i === 0) originIds.add(id);
2542
+ if (!routeNumberById.has(id)) routeNumberById.set(id, i + 1);
2543
+ });
1966
2544
  }
1967
- const weightVals = resolved.edges.map((e) => Number(e.meta["width"])).filter((n) => Number.isFinite(n) && n > 0);
1968
- const wMin = weightVals.length ? Math.min(...weightVals) : 0;
1969
- const wMax = weightVals.length ? Math.max(...weightVals) : 0;
1970
- const widthFor = (e) => {
1971
- const v = Number(e.meta["width"]);
1972
- if (!Number.isFinite(v) || v <= 0 || wMax <= 0) return W_MIN;
1973
- const t = wMax > wMin ? (v - wMin) / (wMax - wMin) : 1;
1974
- return W_MIN + t * (W_MAX - W_MIN);
2545
+ const poiScreen = /* @__PURE__ */ new Map();
2546
+ const pois = [];
2547
+ const orderedPois = [...resolved.pois].sort(
2548
+ (a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1)
2549
+ );
2550
+ const projected = [];
2551
+ for (const p of orderedPois) {
2552
+ const xy = project(p.lon, p.lat);
2553
+ if (xy) projected.push({ p, xy });
2554
+ }
2555
+ const placePoi = (e, cx, cy, clusterId) => {
2556
+ const { fill, stroke } = poiFill(e.p);
2557
+ const r = radiusFor(e.p);
2558
+ poiScreen.set(e.p.id, { cx, cy, r });
2559
+ const num = routeNumberById.get(e.p.id);
2560
+ pois.push({
2561
+ id: e.p.id,
2562
+ cx,
2563
+ cy,
2564
+ r,
2565
+ fill,
2566
+ fillOpacity: fillOpacityFor(r),
2567
+ stroke,
2568
+ lineNumber: e.p.lineNumber,
2569
+ implicit: !!e.p.implicit,
2570
+ isOrigin: originIds.has(e.p.id),
2571
+ ...num !== void 0 && { routeNumber: num },
2572
+ ...Object.keys(e.p.tags).length > 0 && { tags: e.p.tags },
2573
+ ...clusterId !== void 0 && { clusterId }
2574
+ });
1975
2575
  };
1976
- const pairGroups = /* @__PURE__ */ new Map();
2576
+ const clusters = [];
2577
+ const connected = /* @__PURE__ */ new Set();
1977
2578
  for (const e of resolved.edges) {
1978
- const key = [e.fromId, e.toId].sort().join(" ");
1979
- const arr = pairGroups.get(key);
1980
- if (arr) arr.push(e);
1981
- else pairGroups.set(key, [e]);
2579
+ connected.add(e.fromId);
2580
+ connected.add(e.toId);
1982
2581
  }
1983
- for (const groupEdges of pairGroups.values()) {
1984
- const ordered = [...groupEdges].sort((a, b) => a.lineNumber - b.lineNumber);
1985
- const n = ordered.length;
1986
- ordered.forEach((e, i) => {
1987
- const a = poiScreen.get(e.fromId);
1988
- const b = poiScreen.get(e.toId);
1989
- if (!a || !b) return;
1990
- const fanOffset = n > 1 ? (i - (n - 1) / 2) * FAN_STEP : 0;
1991
- const curved = e.style === "arc" || n > 1;
1992
- const edgeBow = curved && n === 1 ? chooseArcBow(a, b, e.fromId, e.toId) : fanOffset;
1993
- const lp = legLabelPoint(a, b, curved, edgeBow);
1994
- const bow = {
1995
- curved,
1996
- offset: edgeBow,
1997
- labelX: lp.x,
1998
- labelY: lp.y
1999
- };
2000
- placedLegSamples.push({
2001
- fromId: e.fromId,
2002
- toId: e.toId,
2003
- pts: sampleLeg(a, b, curved, edgeBow)
2004
- });
2005
- const edgeLabelStyle = e.label !== void 0 ? labelOnFill(fillAt(bow.labelX, bow.labelY)) : void 0;
2006
- const edgeVal = Number(e.meta["width"]);
2007
- legs.push({
2008
- d: legPath(a, b, bow.curved, bow.offset),
2009
- width: widthFor(e),
2010
- color: lineColor(e.tags) ?? mix(palette.text, palette.bg, 66),
2011
- arrow: e.directed,
2012
- fromId: e.fromId,
2013
- toId: e.toId,
2014
- ...Number.isFinite(edgeVal) && edgeVal > 0 && { value: edgeVal },
2015
- ...Object.keys(e.tags).length > 0 && { tags: e.tags },
2016
- lineNumber: e.lineNumber,
2017
- ...e.label !== void 0 && {
2018
- label: e.label,
2019
- labelX: bow.labelX,
2020
- labelY: bow.labelY,
2021
- labelColor: edgeLabelStyle.color,
2022
- labelHalo: edgeLabelStyle.halo,
2023
- labelHaloColor: edgeLabelStyle.haloColor
2024
- }
2025
- });
2582
+ for (const rt of resolved.routes) {
2583
+ rt.stopIds.forEach((id) => connected.add(id));
2584
+ }
2585
+ const radiusOf = (e) => radiusFor(e.p);
2586
+ for (const e of projected) {
2587
+ if (connected.has(e.p.id)) placePoi(e, e.xy[0], e.xy[1]);
2588
+ }
2589
+ const groups = [];
2590
+ for (const e of projected) {
2591
+ if (connected.has(e.p.id)) continue;
2592
+ const r = radiusOf(e);
2593
+ const near = groups.find(
2594
+ (g) => g.some(
2595
+ (q) => Math.hypot(q.xy[0] - e.xy[0], q.xy[1] - e.xy[1]) < (r + radiusOf(q)) * STACK_OVERLAP
2596
+ )
2597
+ );
2598
+ if (near) near.push(e);
2599
+ else groups.push([e]);
2600
+ }
2601
+ for (const g of groups) {
2602
+ if (g.length === 1) {
2603
+ placePoi(g[0], g[0].xy[0], g[0].xy[1]);
2604
+ continue;
2605
+ }
2606
+ const clusterId = g[0].p.id;
2607
+ const cx0 = g.reduce((s, e) => s + e.xy[0], 0) / g.length;
2608
+ const cy0 = g.reduce((s, e) => s + e.xy[1], 0) / g.length;
2609
+ const maxR = Math.max(...g.map(radiusOf));
2610
+ const sep = 2 * maxR + STACK_RING_GAP;
2611
+ const ringR = Math.max(
2612
+ COLO_R,
2613
+ sep / (2 * Math.sin(Math.PI / Math.max(g.length, 2)))
2614
+ );
2615
+ const positions = g.map((e, i) => {
2616
+ if (g.length <= STACK_RING_MAX) {
2617
+ const ang2 = -Math.PI / 2 + i * 2 * Math.PI / g.length;
2618
+ return {
2619
+ e,
2620
+ mx: cx0 + Math.cos(ang2) * ringR,
2621
+ my: cy0 + Math.sin(ang2) * ringR
2622
+ };
2623
+ }
2624
+ const ang = i * GOLDEN_ANGLE;
2625
+ const rr = ringR * Math.sqrt((i + 1) / g.length);
2626
+ return { e, mx: cx0 + Math.cos(ang) * rr, my: cy0 + Math.sin(ang) * rr };
2627
+ });
2628
+ let minX = cx0 - maxR;
2629
+ let maxX = cx0 + maxR;
2630
+ let minY = cy0 - maxR;
2631
+ let maxY = cy0 + maxR;
2632
+ for (const { mx, my, e } of positions) {
2633
+ const r = radiusOf(e);
2634
+ minX = Math.min(minX, mx - r);
2635
+ maxX = Math.max(maxX, mx + r);
2636
+ minY = Math.min(minY, my - r);
2637
+ maxY = Math.max(maxY, my + r);
2638
+ }
2639
+ let dx = 0;
2640
+ let dy = 0;
2641
+ if (minX + dx < 2) dx = 2 - minX;
2642
+ if (maxX + dx > width - 2) dx = width - 2 - maxX;
2643
+ if (minY + dy < 2) dy = 2 - minY;
2644
+ if (maxY + dy > height - 2) dy = height - 2 - maxY;
2645
+ const legsOut = [];
2646
+ for (const { e, mx, my } of positions) {
2647
+ const fx = mx + dx;
2648
+ const fy = my + dy;
2649
+ placePoi(e, fx, fy, clusterId);
2650
+ legsOut.push({ x2: fx, y2: fy, color: poiFill(e.p).fill });
2651
+ }
2652
+ clusters.push({
2653
+ id: clusterId,
2654
+ cx: cx0 + dx,
2655
+ cy: cy0 + dy,
2656
+ count: g.length,
2657
+ hitR: ringR + maxR + 6,
2658
+ legs: legsOut
2026
2659
  });
2027
2660
  }
2661
+ const legs = buildConnectorLegs({
2662
+ resolved,
2663
+ poiScreen,
2664
+ palette,
2665
+ width,
2666
+ height,
2667
+ fillAt,
2668
+ labelOnFill
2669
+ });
2028
2670
  const labels = [];
2029
2671
  const obstacles = [...topReserved];
2030
2672
  const regionLabelGuards = [];
@@ -2069,36 +2711,8 @@ function layoutMap(resolvedIn, data, size, opts) {
2069
2711
  const regionValueStr = (value) => showRegionValues && value !== void 0 ? compactNumber(value) : void 0;
2070
2712
  const isCompact = width < COMPACT_WIDTH_PX;
2071
2713
  const usChoroplethZoom = resolved.projection === "mercator" && resolved.basemaps.subdivisions.includes("us-states") && activeIsScore;
2072
- const LABEL_PADX = 6;
2073
- const LABEL_PADY = 3;
2074
- const VALUE_GAP = 1;
2075
- const labelW = (text, font = FONT2) => measureLegendText(text, font) + 2 * LABEL_PADX;
2076
- const labelH = FONT2 + 2 * LABEL_PADY;
2077
- const stackW = (text, valueText, font = FONT2) => Math.max(
2078
- labelW(text, font),
2079
- valueText ? measureLegendText(valueText, Math.round(font * 0.82)) + 2 * LABEL_PADX : 0
2080
- );
2081
- const stackH = (hasValue, font = FONT2) => {
2082
- const lh = font + 2 * LABEL_PADY;
2083
- return hasValue ? lh + VALUE_GAP + Math.round(font * 0.82) : lh;
2084
- };
2085
- const REGION_FONT_MAX_ORIENT = 22;
2086
- const REGION_SIZE_FRAC_MIN = 0.06;
2087
- const REGION_SIZE_FRAC_MAX = 0.32;
2088
- const SUBJECT_FONT_MAX = 18;
2089
- const SUBJECT_MIN_PROMINENCE = 13;
2090
- const SUBJECT_LEADER_FONT = 15;
2091
- const canvasLinear = Math.sqrt(Math.max(1, width * height));
2092
- const sizeT = (boxW, boxH) => {
2093
- const frac = Math.sqrt(Math.max(0, boxW * boxH)) / canvasLinear;
2094
- return Math.min(
2095
- 1,
2096
- Math.max(
2097
- 0,
2098
- (frac - REGION_SIZE_FRAC_MIN) / (REGION_SIZE_FRAC_MAX - REGION_SIZE_FRAC_MIN)
2099
- )
2100
- );
2101
- };
2714
+ const labelMetrics = createLabelMetrics(size);
2715
+ const { labelW, labelH } = labelMetrics;
2102
2716
  const pushRegionLabel = (x, y, text, fill, lineNumber, valueLine, fontSize = FONT2, colorOverride) => {
2103
2717
  const { color: baseColor, haloColor } = labelOnFill(fill);
2104
2718
  const color = colorOverride ?? baseColor;
@@ -2138,373 +2752,39 @@ function layoutMap(resolvedIn, data, size, opts) {
2138
2752
  return { x: cx - w / 2, y: cy - h / 2, w, h };
2139
2753
  };
2140
2754
  if (showRegionLabels) {
2141
- const frameContainers = new Set(resolved.poiFrameContainers);
2142
- const entries = regions.map((r) => {
2143
- const isContainer = frameContainers.has(r.id);
2144
- if (r.layer === "base" && !isContainer || r.label === void 0)
2145
- return null;
2146
- const isUsState = r.layer === "us-state" || r.id.startsWith("US-");
2147
- const f = isUsState ? usLayer?.get(r.id) : worldLayer.get(r.id);
2148
- if (!f) return null;
2149
- const [[x0, y0], [x1, y1]] = path.bounds(f);
2150
- const boxW = x1 - x0;
2151
- const boxH = y1 - y0;
2152
- const abbrev = isUsState && !usChoroplethZoom ? r.id.replace(/^US-/, "") : void 0;
2153
- const candidates = abbrev !== void 0 ? isCompact ? [abbrev, r.label] : isContainer ? [r.label] : [r.label, abbrev] : [r.label];
2154
- const anchor = !isUsState ? WORLD_LABEL_ANCHORS[r.id] : void 0;
2155
- const c = anchor ? project(anchor[0], anchor[1]) : path.centroid(f);
2156
- if (!c || !Number.isFinite(c[0])) return null;
2157
- return { r, c, boxW, boxH, area: boxW * boxH, candidates };
2158
- }).filter((e) => e !== null).sort((a, b) => b.area - a.area || a.r.lineNumber - b.r.lineNumber);
2159
- const placedRegionRects = [...topReserved];
2160
- const placedLeaders = [];
2161
- const isEmptyFill = (f) => f === water || f === neutralFill || f === foreignFill;
2162
- const POI_LABEL_PAD = 14;
2163
- const poiObstacles = pois.map((p) => ({
2164
- x: p.cx - p.r - POI_LABEL_PAD,
2165
- y: p.cy - p.r - POI_LABEL_PAD,
2166
- w: 2 * (p.r + POI_LABEL_PAD),
2167
- h: 2 * (p.r + POI_LABEL_PAD)
2168
- }));
2169
- const SHORT_HOP_MAX = Math.max(46, Math.min(width, height) * 0.11);
2170
- const STEP = 4;
2171
- const HOP_GAP = 9;
2172
- const CLEAR_STEP = 6;
2173
- const CLEAR_MAX = SHORT_HOP_MAX * 3;
2174
- const CLEAR_TOL = CLEAR_STEP;
2175
- const clearanceAt = (px, py, ox, oy) => {
2176
- const baseAng = Math.atan2(oy, ox);
2177
- let minClear = CLEAR_MAX;
2178
- for (let k = -2; k <= 2; k++) {
2179
- const a = baseAng + k * Math.PI / 4;
2180
- const rx = Math.cos(a);
2181
- const ry = Math.sin(a);
2182
- let d = CLEAR_STEP;
2183
- for (; d <= CLEAR_MAX; d += CLEAR_STEP) {
2184
- const qx = px + rx * d;
2185
- const qy = py + ry * d;
2186
- if (qx < FIT_PAD || qy < topPad || qx > width - FIT_PAD || qy > height - FIT_PAD)
2187
- break;
2188
- if (fillAt(qx, qy) !== water) break;
2189
- }
2190
- if (d < minClear) minClear = d;
2191
- }
2192
- return minClear;
2193
- };
2194
- const tryShortHopCallout = (cx, cy, fill, name, value, font = FONT2, gap = HOP_GAP, maxLen = SHORT_HOP_MAX) => {
2195
- const chipW = stackW(name, value, font);
2196
- const chipH = stackH(value !== void 0, font);
2197
- const D = Math.SQRT1_2;
2198
- const dirs = [
2199
- [0, -1],
2200
- // north
2201
- [0, 1],
2202
- // south
2203
- [1, 0],
2204
- // east
2205
- [-1, 0],
2206
- // west
2207
- [D, -D],
2208
- // NE
2209
- [D, D],
2210
- // SE
2211
- [-D, -D],
2212
- // NW
2213
- [-D, D]
2214
- // SW
2215
- ];
2216
- let best = null;
2217
- for (const [dx, dy] of dirs) {
2218
- let ex = cx;
2219
- let ey = cy;
2220
- let steps = 0;
2221
- const maxSteps = Math.ceil(maxLen / STEP) + 1;
2222
- while (steps < maxSteps && fillAt(ex, ey) === fill) {
2223
- ex += dx * STEP;
2224
- ey += dy * STEP;
2225
- steps++;
2226
- }
2227
- if (fillAt(ex, ey) === fill) continue;
2228
- const halfAlong = (Math.abs(dx) * chipW + Math.abs(dy) * chipH) / 2;
2229
- const ccx = ex + dx * (gap + halfAlong);
2230
- const ccy = ey + dy * (gap + halfAlong);
2231
- const rect = {
2232
- x: ccx - chipW / 2,
2233
- y: ccy - chipH / 2,
2234
- w: chipW,
2235
- h: chipH
2236
- };
2237
- if (rect.x < FIT_PAD || rect.y < topPad || rect.x + rect.w > width - FIT_PAD || rect.y + rect.h > height - FIT_PAD)
2238
- continue;
2239
- const sx = [rect.x + 2, ccx, rect.x + rect.w - 2];
2240
- const sy = [rect.y + 2, ccy, rect.y + rect.h - 2];
2241
- let onEmpty = true;
2242
- for (const px of sx)
2243
- for (const py of sy)
2244
- if (!isEmptyFill(fillAt(px, py))) {
2245
- onEmpty = false;
2246
- break;
2247
- }
2248
- if (!onEmpty) continue;
2249
- if (placedRegionRects.some((p) => rectsOverlap(rect, p))) continue;
2250
- if (poiObstacles.some((o) => rectsOverlap(rect, o))) continue;
2251
- if (markers.some((m) => rectCircleOverlap(rect, m))) continue;
2252
- const innerX = ccx - dx * (chipW / 2);
2253
- const innerY = ccy - dy * (chipH / 2);
2254
- const len = Math.hypot(innerX - cx, innerY - cy);
2255
- if (len > maxLen) continue;
2256
- if (placedLeaders.some(
2257
- (l) => segmentsCross(cx, cy, innerX, innerY, l[0], l[1], l[2], l[3])
2258
- ))
2259
- continue;
2260
- if (placedRegionRects.some(
2261
- (p) => segmentRectOverlap(cx, cy, innerX, innerY, p)
2262
- ))
2263
- continue;
2264
- const clear = clearanceAt(ccx, ccy, dx, dy);
2265
- const better = best === null || clear > best.clear + CLEAR_TOL || clear >= best.clear - CLEAR_TOL && len < best.len;
2266
- if (better)
2267
- best = {
2268
- x: ccx,
2269
- y: ccy,
2270
- rect,
2271
- leader: [cx, cy, innerX, innerY],
2272
- len,
2273
- clear
2274
- };
2275
- }
2276
- return best ? { x: best.x, y: best.y, rect: best.rect, leader: best.leader } : null;
2277
- };
2278
- for (const { r, c, boxW, boxH, candidates } of entries) {
2279
- const valStr = regionValueStr(r.value);
2280
- const fitsRegions = (rect) => !placedRegionRects.some((p) => rectsOverlap(rect, p));
2281
- const fitsPois = (rect) => !poiObstacles.some((o) => rectsOverlap(rect, o));
2282
- const isSubject = regionById.has(r.id);
2283
- const extremesOnFill = (ax, ay, text) => {
2284
- const halfW = measureLegendText(text, FONT2) / 2;
2285
- return fillAt(ax - halfW, ay) === r.fill && fillAt(ax + halfW, ay) === r.fill;
2286
- };
2287
- if (isSubject && valStr === void 0 && r.label !== void 0) {
2288
- const name = candidates[0];
2289
- let best = -1;
2290
- for (let f = SUBJECT_FONT_MAX; f >= FONT2; f--) {
2291
- if (measureLegendText(name, f) > boxW || f + 2 * LABEL_PADY > boxH)
2292
- continue;
2293
- const rect = regionLabelRect(c[0], c[1], name, void 0, f);
2294
- if (!fitsRegions(rect) || !fitsPois(rect) || collides(rect)) continue;
2295
- const halfW = measureLegendText(name, f) / 2;
2296
- if (fillAt(c[0] - halfW, c[1]) !== r.fill || fillAt(c[0] + halfW, c[1]) !== r.fill)
2297
- continue;
2298
- best = f;
2299
- break;
2300
- }
2301
- if (best >= SUBJECT_MIN_PROMINENCE) {
2302
- const rect = regionLabelRect(c[0], c[1], name, void 0, best);
2303
- placedRegionRects.push(rect);
2304
- pushRegionLabel(
2305
- c[0],
2306
- c[1],
2307
- name,
2308
- r.fill,
2309
- r.lineNumber,
2310
- void 0,
2311
- best
2312
- );
2313
- labeledRegionIds.add(r.id);
2314
- regionLabelGuards.push({ label: labels[labels.length - 1], rect });
2315
- } else {
2316
- const hop = tryShortHopCallout(
2317
- c[0],
2318
- c[1],
2319
- r.fill,
2320
- r.label,
2321
- void 0,
2322
- SUBJECT_LEADER_FONT,
2323
- HOP_GAP + SUBJECT_LEADER_FONT,
2324
- SHORT_HOP_MAX * 2.2
2325
- );
2326
- if (hop) {
2327
- placedRegionRects.push(hop.rect);
2328
- placedLeaders.push(hop.leader);
2329
- const dark = mix(r.fill, palette.text, 60);
2330
- labels.push({
2331
- x: hop.x,
2332
- y: hop.y,
2333
- text: r.label,
2334
- anchor: "middle",
2335
- color: palette.text,
2336
- halo: false,
2337
- haloColor: palette.bg,
2338
- fontSize: SUBJECT_LEADER_FONT,
2339
- leader: {
2340
- x1: hop.leader[0],
2341
- y1: hop.leader[1],
2342
- x2: hop.leader[2],
2343
- y2: hop.leader[3]
2344
- },
2345
- leaderColor: dark,
2346
- calloutDot: { x: c[0], y: c[1], color: dark },
2347
- lineNumber: r.lineNumber
2348
- });
2349
- labeledRegionIds.add(r.id);
2350
- }
2351
- }
2352
- continue;
2353
- }
2354
- const seekAnchors = [
2355
- { x: c[0], y: c[1], guard: false }
2356
- ];
2357
- if (valStr) {
2358
- seekAnchors.push(
2359
- { x: c[0], y: c[1] + boxH * 0.26, guard: true },
2360
- { x: c[0], y: c[1] - boxH * 0.26, guard: true },
2361
- { x: c[0] + boxW * 0.26, y: c[1], guard: true },
2362
- { x: c[0] - boxW * 0.26, y: c[1], guard: true },
2363
- { x: c[0] + boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
2364
- { x: c[0] - boxW * 0.22, y: c[1] + boxH * 0.22, guard: true },
2365
- { x: c[0] + boxW * 0.22, y: c[1] - boxH * 0.22, guard: true },
2366
- { x: c[0] - boxW * 0.22, y: c[1] - boxH * 0.22, guard: true }
2367
- );
2368
- }
2369
- let chosen;
2370
- for (const a of seekAnchors) {
2371
- if (a.guard && fillAt(a.x, a.y) !== r.fill) continue;
2372
- for (const t of candidates) {
2373
- const nameRect = regionLabelRect(a.x, a.y, t);
2374
- if (valStr && stackW(t, valStr) <= boxW && stackH(true) <= boxH) {
2375
- const stackRect = regionLabelRect(a.x, a.y, t, valStr);
2376
- if (fitsRegions(stackRect) && fitsPois(nameRect) && extremesOnFill(a.x, a.y, t)) {
2377
- chosen = { text: t, valueLine: valStr, ax: a.x, ay: a.y };
2378
- break;
2379
- }
2380
- }
2381
- if (labelW(t) <= boxW && labelH <= boxH) {
2382
- if (fitsRegions(nameRect) && fitsPois(nameRect) && (valStr === void 0 || extremesOnFill(a.x, a.y, t))) {
2383
- chosen = { text: t, ax: a.x, ay: a.y };
2384
- break;
2385
- }
2386
- }
2387
- }
2388
- if (chosen) break;
2389
- }
2390
- if (chosen === void 0 && r.label !== void 0 && isSubject) {
2391
- const hop = tryShortHopCallout(c[0], c[1], r.fill, r.label, valStr);
2392
- if (hop) {
2393
- placedRegionRects.push(hop.rect);
2394
- placedLeaders.push(hop.leader);
2395
- const dark = mix(r.fill, palette.text, 60);
2396
- labels.push({
2397
- x: hop.x,
2398
- y: hop.y,
2399
- text: r.label,
2400
- anchor: "middle",
2401
- color: palette.text,
2402
- halo: false,
2403
- haloColor: palette.bg,
2404
- ...valStr !== void 0 && { valueLine: valStr },
2405
- leader: {
2406
- x1: hop.leader[0],
2407
- y1: hop.leader[1],
2408
- x2: hop.leader[2],
2409
- y2: hop.leader[3]
2410
- },
2411
- leaderColor: dark,
2412
- calloutDot: { x: c[0], y: c[1], color: dark },
2413
- lineNumber: r.lineNumber
2414
- });
2415
- labeledRegionIds.add(r.id);
2416
- }
2417
- continue;
2418
- }
2419
- if (chosen === void 0) continue;
2420
- const isOrient = r.value === void 0 && r.layer === "base";
2421
- let font = FONT2;
2422
- if (isOrient) {
2423
- const growT = sizeT(boxW, boxH);
2424
- const desiredFont = Math.round(
2425
- FONT2 + growT * (REGION_FONT_MAX_ORIENT - FONT2)
2426
- );
2427
- const hasVal = chosen.valueLine !== void 0;
2428
- const extremesClean = (f) => {
2429
- const halfW = measureLegendText(chosen.text, f) / 2;
2430
- return [chosen.ax - halfW, chosen.ax + halfW].every((sx) => {
2431
- const fAt = fillAt(sx, chosen.ay);
2432
- return fAt === r.fill || isEmptyFill(fAt);
2433
- });
2434
- };
2435
- let fit = -1;
2436
- for (let f = desiredFont; f >= FONT2; f--) {
2437
- if (stackW(chosen.text, chosen.valueLine, f) > boxW || stackH(hasVal, f) > boxH)
2438
- continue;
2439
- const gRect = regionLabelRect(
2440
- chosen.ax,
2441
- chosen.ay,
2442
- chosen.text,
2443
- chosen.valueLine,
2444
- f
2445
- );
2446
- const gName = regionLabelRect(
2447
- chosen.ax,
2448
- chosen.ay,
2449
- chosen.text,
2450
- void 0,
2451
- f
2452
- );
2453
- if (!fitsRegions(gRect) || !fitsPois(gName) || collides(gName))
2454
- continue;
2455
- if (!extremesClean(f)) continue;
2456
- fit = f;
2457
- break;
2458
- }
2459
- if (fit < 0) {
2460
- if (!frameContainers.has(r.id)) continue;
2461
- font = FONT2;
2462
- } else {
2463
- font = fit;
2464
- }
2465
- }
2466
- const rRect = regionLabelRect(
2467
- chosen.ax,
2468
- chosen.ay,
2469
- chosen.text,
2470
- chosen.valueLine,
2471
- font
2472
- );
2473
- placedRegionRects.push(rRect);
2474
- pushRegionLabel(
2475
- chosen.ax,
2476
- chosen.ay,
2477
- chosen.text,
2478
- r.fill,
2479
- r.lineNumber,
2480
- chosen.valueLine,
2481
- font,
2482
- isOrient ? backdropLandTone : void 0
2483
- );
2484
- labeledRegionIds.add(r.id);
2485
- regionLabelGuards.push({
2486
- label: labels[labels.length - 1],
2487
- rect: rRect
2488
- });
2489
- }
2490
- for (const seed of insetLabelSeeds) {
2491
- const text = isCompact ? seed.iso.replace(/^US-/, "") : seed.name;
2492
- const src = regionById.get(seed.iso);
2493
- const valStr = regionValueStr(src?.value);
2494
- pushRegionLabel(
2495
- seed.x,
2496
- seed.y,
2497
- text,
2498
- src ? regionFill(src) : neutralFill,
2499
- seed.lineNumber,
2500
- valStr
2501
- );
2502
- labeledRegionIds.add(seed.iso);
2503
- regionLabelGuards.push({
2504
- label: labels[labels.length - 1],
2505
- rect: regionLabelRect(seed.x, seed.y, text, valStr)
2506
- });
2507
- }
2755
+ placeRegionLabels({
2756
+ resolved,
2757
+ palette,
2758
+ width,
2759
+ height,
2760
+ path,
2761
+ project,
2762
+ worldLayer,
2763
+ usLayer,
2764
+ regionById,
2765
+ regions,
2766
+ pois,
2767
+ insetLabelSeeds,
2768
+ topReserved,
2769
+ topPad,
2770
+ markers,
2771
+ collides,
2772
+ neutralFill,
2773
+ water,
2774
+ foreignFill,
2775
+ backdropLandTone,
2776
+ regionFill,
2777
+ fillAt,
2778
+ regionValueStr,
2779
+ isCompact,
2780
+ usChoroplethZoom,
2781
+ metrics: labelMetrics,
2782
+ pushRegionLabel,
2783
+ regionLabelRect,
2784
+ labels,
2785
+ labeledRegionIds,
2786
+ regionLabelGuards
2787
+ });
2508
2788
  }
2509
2789
  if (resolved.directives.noPoiLabels !== true) {
2510
2790
  const ordered = [...pois].filter((p) => p.clusterId === void 0).sort((a, b) => a.lineNumber - b.lineNumber || (a.id < b.id ? -1 : 1));
@@ -2518,7 +2798,7 @@ function layoutMap(resolvedIn, data, size, opts) {
2518
2798
  const text = labelText(p);
2519
2799
  return { text, w: measureLegendText(text, FONT2) };
2520
2800
  };
2521
- const GAP = 3;
2801
+ const GAP2 = 3;
2522
2802
  const POI_EDGE_CLEAR = Math.max(
2523
2803
  20,
2524
2804
  Math.round(Math.min(width, height) * 0.03)
@@ -2533,10 +2813,10 @@ function layoutMap(resolvedIn, data, size, opts) {
2533
2813
  const inlineRect = (p, w, side) => {
2534
2814
  switch (side) {
2535
2815
  case "right":
2536
- return { x: p.cx + p.r + GAP, y: p.cy - poiLabH / 2, w, h: poiLabH };
2816
+ return { x: p.cx + p.r + GAP2, y: p.cy - poiLabH / 2, w, h: poiLabH };
2537
2817
  case "left":
2538
2818
  return {
2539
- x: p.cx - p.r - GAP - w,
2819
+ x: p.cx - p.r - GAP2 - w,
2540
2820
  y: p.cy - poiLabH / 2,
2541
2821
  w,
2542
2822
  h: poiLabH
@@ -2544,28 +2824,28 @@ function layoutMap(resolvedIn, data, size, opts) {
2544
2824
  case "above":
2545
2825
  return {
2546
2826
  x: p.cx - w / 2,
2547
- y: p.cy - p.r - GAP - poiLabH,
2827
+ y: p.cy - p.r - GAP2 - poiLabH,
2548
2828
  w,
2549
2829
  h: poiLabH
2550
2830
  };
2551
2831
  case "below":
2552
- return { x: p.cx - w / 2, y: p.cy + p.r + GAP, w, h: poiLabH };
2832
+ return { x: p.cx - w / 2, y: p.cy + p.r + GAP2, w, h: poiLabH };
2553
2833
  // Diagonal corners: offset both axes by (r+GAP)/√2 so the box's near
2554
2834
  // corner clears the dot rim by the same gap as a cardinal side.
2555
2835
  case "below-right": {
2556
- const d = (p.r + GAP) * 0.7071;
2836
+ const d = (p.r + GAP2) * 0.7071;
2557
2837
  return { x: p.cx + d, y: p.cy + d, w, h: poiLabH };
2558
2838
  }
2559
2839
  case "below-left": {
2560
- const d = (p.r + GAP) * 0.7071;
2840
+ const d = (p.r + GAP2) * 0.7071;
2561
2841
  return { x: p.cx - d - w, y: p.cy + d, w, h: poiLabH };
2562
2842
  }
2563
2843
  case "above-right": {
2564
- const d = (p.r + GAP) * 0.7071;
2844
+ const d = (p.r + GAP2) * 0.7071;
2565
2845
  return { x: p.cx + d, y: p.cy - d - poiLabH, w, h: poiLabH };
2566
2846
  }
2567
2847
  case "above-left": {
2568
- const d = (p.r + GAP) * 0.7071;
2848
+ const d = (p.r + GAP2) * 0.7071;
2569
2849
  return { x: p.cx - d - w, y: p.cy - d - poiLabH, w, h: poiLabH };
2570
2850
  }
2571
2851
  }
@@ -2692,10 +2972,10 @@ function layoutMap(resolvedIn, data, size, opts) {
2692
2972
  };
2693
2973
  const pushHidden = (p) => {
2694
2974
  const { text, w } = labelInfo(p);
2695
- let x = p.cx + p.r + GAP;
2975
+ let x = p.cx + p.r + GAP2;
2696
2976
  let anchor = "start";
2697
2977
  if (x + w > width) {
2698
- x = p.cx - p.r - GAP - w;
2978
+ x = p.cx - p.r - GAP2 - w;
2699
2979
  anchor = "end";
2700
2980
  }
2701
2981
  const y = Math.max(0, Math.min(p.cy - poiLabH / 2, height - poiLabH));
@@ -2892,15 +3172,15 @@ function layoutMap(resolvedIn, data, size, opts) {
2892
3172
  }
2893
3173
  }
2894
3174
  if (regionLabelGuards.length > 0) {
2895
- const PAD = 2;
3175
+ const PAD2 = 2;
2896
3176
  const poiRects = labels.filter((l) => l.poiId !== void 0 && l.hidden !== true).map((l) => {
2897
3177
  const w = measureLegendText(l.text, FONT2);
2898
3178
  const x = l.anchor === "start" ? l.x : l.anchor === "end" ? l.x - w : l.x - w / 2;
2899
3179
  return {
2900
- x: x - PAD,
3180
+ x: x - PAD2,
2901
3181
  y: l.y - FONT2,
2902
- w: w + 2 * PAD,
2903
- h: FONT2 * 1.4 + 2 * PAD
3182
+ w: w + 2 * PAD2,
3183
+ h: FONT2 * 1.4 + 2 * PAD2
2904
3184
  };
2905
3185
  });
2906
3186
  for (const g of regionLabelGuards) {
@@ -2910,79 +3190,21 @@ function layoutMap(resolvedIn, data, size, opts) {
2910
3190
  }
2911
3191
  }
2912
3192
  }
2913
- if (legs.some((lg) => lg.label !== void 0)) {
2914
- const committedBoxes = labels.filter((l) => !l.hidden).map((l) => {
2915
- const w = labelW(l.text);
2916
- const x = l.anchor === "start" ? l.x : l.anchor === "end" ? l.x - w : l.x - w / 2;
2917
- return { x, y: l.y - labelH / 2, w, h: labelH };
2918
- });
2919
- const placedEdge = [];
2920
- const T_LIST = [0.5, 0.42, 0.58, 0.34, 0.66, 0.28, 0.72];
2921
- for (let i = 0; i < legs.length; i++) {
2922
- const lg = legs[i];
2923
- if (lg.label === void 0) continue;
2924
- const a = poiScreen.get(lg.fromId);
2925
- const b = poiScreen.get(lg.toId);
2926
- if (!a || !b) continue;
2927
- const w = labelW(lg.label);
2928
- const dx = b.cx - a.cx;
2929
- const dy = b.cy - a.cy;
2930
- const len = Math.hypot(dx, dy) || 1;
2931
- const nx = -dy / len;
2932
- const ny = dx / len;
2933
- const perp = labelH + 2;
2934
- const stroke = lg.width ?? W_MIN;
2935
- const clearW = stroke / 2 + labelH / 2 + 3;
2936
- const offList = stroke >= 4 ? [clearW, -clearW, clearW + perp, -(clearW + perp), 0] : [0, perp, -perp, 2 * perp, -2 * perp];
2937
- const pm = /^M(-?[\d.]+),(-?[\d.]+)(?:L(-?[\d.]+),(-?[\d.]+)|Q(-?[\d.]+),(-?[\d.]+) (-?[\d.]+),(-?[\d.]+))$/.exec(
2938
- lg.d
2939
- );
2940
- const pointAt = (t) => {
2941
- if (pm?.[5] !== void 0) {
2942
- const u = 1 - t;
2943
- return [
2944
- u * u * +pm[1] + 2 * u * t * +pm[5] + t * t * +pm[7],
2945
- u * u * +pm[2] + 2 * u * t * +pm[6] + t * t * +pm[8]
2946
- ];
2947
- }
2948
- if (pm?.[3] !== void 0)
2949
- return [
2950
- +pm[1] + (+pm[3] - +pm[1]) * t,
2951
- +pm[2] + (+pm[4] - +pm[2]) * t
2952
- ];
2953
- return [a.cx + dx * t, a.cy + dy * t];
2954
- };
2955
- const candidates = [];
2956
- for (const off of offList)
2957
- for (const t of T_LIST) {
2958
- const [bx, by] = pointAt(t);
2959
- candidates.push([bx + nx * off, by + ny * off - 4]);
2960
- }
2961
- const clean = ([cx2, cy2]) => {
2962
- const rect2 = { x: cx2 - w / 2, y: cy2 - labelH / 2, w, h: labelH };
2963
- if (rect2.x < 0 || rect2.x + w > width || rect2.y < 0 || rect2.y + rect2.h > height)
2964
- return false;
2965
- if (markers.some((m) => rectCircleOverlap(rect2, m))) return false;
2966
- if (committedBoxes.some((o) => rectsOverlap(o, rect2))) return false;
2967
- if (placedEdge.some((o) => rectsOverlap(o, rect2))) return false;
2968
- return true;
2969
- };
2970
- const mid = pointAt(0.5);
2971
- const [cx, cy] = candidates.find(clean) ?? [mid[0], mid[1] - 4];
2972
- const style = labelOnFill(fillAt(cx, cy));
2973
- legs[i] = {
2974
- ...lg,
2975
- labelX: cx,
2976
- labelY: cy,
2977
- labelColor: style.color,
2978
- labelHalo: style.halo,
2979
- labelHaloColor: style.haloColor
2980
- };
2981
- const rect = { x: cx - w / 2, y: cy - labelH / 2, w, h: labelH };
2982
- placedEdge.push(rect);
2983
- obstacles.push(rect);
2984
- }
2985
- }
3193
+ obstacles.push(
3194
+ ...layoutEdgeLabels({
3195
+ legs,
3196
+ committed: labels,
3197
+ poiScreen,
3198
+ markers,
3199
+ labelW: (text) => labelW(text),
3200
+ labelH,
3201
+ minStroke: W_MIN,
3202
+ fillAt,
3203
+ labelOnFill,
3204
+ width,
3205
+ height
3206
+ })
3207
+ );
2986
3208
  if (resolved.directives.noContextLabels !== true) {
2987
3209
  for (const l of labels) {
2988
3210
  if (l.hidden) continue;
@@ -3098,49 +3320,13 @@ function layoutMap(resolvedIn, data, size, opts) {
3098
3320
  });
3099
3321
  labels.push(...contextLabels);
3100
3322
  }
3101
- const cityDots = [];
3102
- if (resolved.directives.noCities !== true) {
3103
- const CITY_DOT_SPACING = 12;
3104
- const CITY_DOT_CAP = 220;
3105
- const SPACING_SQ = CITY_DOT_SPACING * CITY_DOT_SPACING;
3106
- const CITY_DOT_R_MIN = 0.7;
3107
- const CITY_DOT_R_MAX = 2.6;
3108
- const CITY_POP_MIN = 5e4;
3109
- const CITY_POP_MAX = 15e6;
3110
- const LOG_MIN = Math.log10(CITY_POP_MIN);
3111
- const LOG_SPAN = Math.log10(CITY_POP_MAX) - LOG_MIN;
3112
- const cityDotRadius = (pop) => {
3113
- if (!(pop > CITY_POP_MIN)) return CITY_DOT_R_MIN;
3114
- const t = Math.min(1, (Math.log10(pop) - LOG_MIN) / LOG_SPAN);
3115
- return CITY_DOT_R_MIN + t * (CITY_DOT_R_MAX - CITY_DOT_R_MIN);
3116
- };
3117
- const placed = pois.map((p) => ({
3118
- x: p.cx,
3119
- y: p.cy
3120
- }));
3121
- const sorted = [...data.gazetteer.cities].sort((a, b) => b[3] - a[3]);
3122
- for (const c of sorted) {
3123
- if (cityDots.length >= CITY_DOT_CAP) break;
3124
- const lat = c[0];
3125
- const lon = c[1];
3126
- const p = project(lon, lat);
3127
- if (!p) continue;
3128
- const [px, py] = p;
3129
- if (px < 0 || px > width || py < 0 || py > height) continue;
3130
- let tooClose = false;
3131
- for (const q of placed) {
3132
- const dx = q.x - px;
3133
- const dy = q.y - py;
3134
- if (dx * dx + dy * dy < SPACING_SQ) {
3135
- tooClose = true;
3136
- break;
3137
- }
3138
- }
3139
- if (tooClose) continue;
3140
- placed.push({ x: px, y: py });
3141
- cityDots.push({ cx: px, cy: py, r: cityDotRadius(c[3]) });
3142
- }
3143
- }
3323
+ const cityDots = resolved.directives.noCities !== true ? layoutCityDots({
3324
+ cities: data.gazetteer.cities,
3325
+ occupied: pois.map((p) => ({ x: p.cx, y: p.cy })),
3326
+ project,
3327
+ width,
3328
+ height
3329
+ }) : [];
3144
3330
  return {
3145
3331
  width,
3146
3332
  height,
@@ -3177,4 +3363,4 @@ export {
3177
3363
  albersSkewFallback,
3178
3364
  layoutMap
3179
3365
  };
3180
- //# sourceMappingURL=chunk-OPBZYISB.js.map
3366
+ //# sourceMappingURL=chunk-37EHGTQ2.js.map