canvas-globe 1.1.1 → 1.3.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.
package/src/geo-globe.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  getLicensePresentation,
16
16
  reportLicenseStatus,
17
17
  } from "./license.js";
18
- import { D2R, R2D, TAU, clamp, wrapLon, resolveProjection, projectionBounds, ortho, orthoInverse, greatCircle, circleAround, distanceMeters, subsolarPoint, pointInGeometry, geometryBounds, normalizeShapes, withAlpha } from "./geo.js";
18
+ import { D2R, R2D, TAU, clamp, wrapLon, resolveProjection, projectionBounds, ortho, orthoInverse, greatCircle, circleAround, distanceMeters, subsolarPoint, pointInGeometry, geometryBounds, normalizeShapes, withAlpha, colorScale, hexBinPoints } from "./geo.js";
19
19
 
20
20
  const DEFAULTS = {
21
21
  licenseKey: null,
@@ -40,6 +40,7 @@ const DEFAULTS = {
40
40
  timeline: null,
41
41
  transparentBackground: false,
42
42
  heatmap: false,
43
+ hexBins: false,
43
44
  spikes: false,
44
45
  labels: false,
45
46
  legend: null,
@@ -83,13 +84,28 @@ const DEFAULTS = {
83
84
  onCountryHover: null,
84
85
  onCountryClick: null,
85
86
  onRender: null,
87
+ effects: null,
86
88
  };
87
89
 
88
90
  const coord = (v) => (Array.isArray(v) ? [v[0], v[1]] : [v.lon ?? v.lng ?? v.longitude, v.lat ?? v.latitude]);
89
91
 
92
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
93
+
94
+ /**
95
+ * An effect's own 0→1 progress at a point on the shared clock. Derived purely
96
+ * from `ms`, so the same millisecond always renders the same frame.
97
+ */
98
+ const phaseOf = (fx, ms) => {
99
+ const duration = Math.max(1, fx.duration ?? 1000);
100
+ const total = duration + (fx.hold ?? 0);
101
+ const elapsed = fx.loop === false ? Math.min(ms, total) : ((ms % total) + total) % total;
102
+ return Math.min(1, elapsed / duration);
103
+ };
104
+
90
105
  const defaultTooltip = (target, kind) => {
91
106
  if (kind === "country") return target.name || String(target.id ?? "");
92
107
  if (kind === "cluster") return `${target.count} in this area`;
108
+ if (kind === "hex-bin") return `${target.markerCount} markers, value ${target.value}`;
93
109
  const name = target.city || target.name || target.label;
94
110
  const count = target.count != null ? `: ${target.count}` : "";
95
111
  return name ? `${name}${count}` : `${target.lat.toFixed(2)}, ${target.lon.toFixed(2)}${count}`;
@@ -141,6 +157,11 @@ export class GeoGlobe {
141
157
  this._licenseHits = [];
142
158
  this._licenseDismissed = false;
143
159
  this._licenseHovered = false;
160
+ this._fx = [];
161
+ this._clock = null;
162
+ this._t0 = now();
163
+ this._spinBase = this.lon;
164
+ this.pointer = { x: 0, y: 0, lon: 0, lat: 0, over: false };
144
165
 
145
166
  this._applyWorld();
146
167
  this._applyMarkers(this.o.markers);
@@ -152,12 +173,138 @@ export class GeoGlobe {
152
173
  this.resize();
153
174
  if (this.o.focus) this.focusOn(this.o.focus, { instant: true });
154
175
  if (this.o.showViewer) this._initViewer();
176
+ for (const fx of this.o.effects || []) this.use(fx);
155
177
  this._loop = this._loop.bind(this);
156
178
  this._raf = requestAnimationFrame(this._loop);
157
179
  }
158
180
 
159
181
  /* ------------------------------ public API ------------------------------ */
160
182
 
183
+ /* --------------------------------- effects ------------------------------- */
184
+
185
+ /** Installs an effect. Returns `this`, so calls chain. */
186
+ use(effect) {
187
+ if (!effect || typeof effect.frame !== "function") return this;
188
+ this._fx.push({ fx: effect, state: effect.setup?.(this) ?? {} });
189
+ this._fx.sort((a, b) => (a.fx.z ?? 0) - (b.fx.z ?? 0));
190
+ return this.invalidate();
191
+ }
192
+
193
+ /** Removes an effect by reference or by name. */
194
+ remove(effect) {
195
+ const i = this._fx.findIndex((e) => e.fx === effect || e.fx.name === effect);
196
+ if (i < 0) return this;
197
+ const [entry] = this._fx.splice(i, 1);
198
+ entry.fx.dispose?.(entry.state, this);
199
+ return this.invalidate();
200
+ }
201
+
202
+ clearEffects() {
203
+ while (this._fx.length) this.remove(this._fx[0].fx);
204
+ return this;
205
+ }
206
+
207
+ get effects() {
208
+ return this._fx.map((e) => e.fx);
209
+ }
210
+
211
+ /**
212
+ * Pins the effect clock to `ms`. Rendering becomes reproducible, which is
213
+ * what frame-exact export and seamless loops need. `play()` releases it.
214
+ */
215
+ seek(ms) {
216
+ // Capture where auto-rotation started from, so longitude can be derived
217
+ // from the clock instead of accumulated per frame. Only on the way in:
218
+ // seeking again while already seeked must not shift the origin.
219
+ if (this._clock == null) this._spinBase = wrapLon(this.lon - this._spinRate() * this._fxTime());
220
+ this._clock = Math.max(0, ms);
221
+ if (this._autoSpin()) this.lon = wrapLon(this._spinBase + this._spinRate() * this._clock);
222
+ return this.invalidate();
223
+ }
224
+
225
+ play() {
226
+ // Resume the timeline where it was paused rather than snapping back to 0.
227
+ if (this._clock != null) this._t0 = now() - this._clock;
228
+ this._clock = null;
229
+ return this.invalidate();
230
+ }
231
+
232
+ /** Draws the single frame that belongs at `ms` on the effect clock. */
233
+ renderFrame(ms) {
234
+ return this.seek(ms).render();
235
+ }
236
+
237
+ _fxTime() {
238
+ return this._clock != null ? this._clock : now() - this._t0;
239
+ }
240
+
241
+ /**
242
+ * Wall clock for looping scene animation. Pinned while seeking, so arcs,
243
+ * orbits and pulses land on the same phase every time a frame is redrawn.
244
+ */
245
+ _animTime() {
246
+ return this._clock != null ? this._clock : Date.now();
247
+ }
248
+
249
+ /** Degrees of auto-rotation per millisecond. */
250
+ _spinRate() {
251
+ return ((this.o.rotateSpeed || 0) * (this.o.fps || 30)) / 1000;
252
+ }
253
+
254
+ _runPasses(stage) {
255
+ if (!this._fx.length) return;
256
+ const ms = this._fxTime();
257
+ const { ctx } = this;
258
+ for (const entry of this._fx) {
259
+ if ((entry.fx.stage || "above") !== stage) continue;
260
+ ctx.save();
261
+ try {
262
+ entry.fx.frame(ctx, this, phaseOf(entry.fx, ms), entry.state);
263
+ } catch (err) {
264
+ // One bad effect must not take the render loop down with it.
265
+ if (!entry.failed) {
266
+ entry.failed = true;
267
+ console.error(`canvas-globe: effect "${entry.fx.name || "anonymous"}" failed`, err);
268
+ }
269
+ }
270
+ ctx.restore();
271
+ }
272
+ }
273
+
274
+ /** Land sample points as `[lon, lat]` pairs — what the dot matrix draws. */
275
+ landPoints(spacing) {
276
+ const prev = this.o.dotSpacing;
277
+ if (spacing != null) this.o.dotSpacing = spacing;
278
+ const flat = this._dotPoints();
279
+ this.o.dotSpacing = prev;
280
+ const out = [];
281
+ for (let i = 0; i < flat.length; i += 2) out.push([flat[i], flat[i + 1]]);
282
+ return out;
283
+ }
284
+
285
+ /** Traces a country outline into a path using the current projection. */
286
+ tracePath(shape, ctx = this.ctx, transform) {
287
+ const geom = shape?.geometry || shape;
288
+ if (!geom?.coordinates) return this;
289
+ const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.coordinates;
290
+ for (const poly of polys) {
291
+ for (const ring of poly) {
292
+ let open = false;
293
+ for (const c of ring) {
294
+ const src = transform ? transform(c) : c;
295
+ const p = this.project(src[0], src[1]);
296
+ if (!p) {
297
+ open = false;
298
+ continue;
299
+ }
300
+ open ? ctx.lineTo(p.x, p.y) : (ctx.moveTo(p.x, p.y), (open = true));
301
+ }
302
+ if (open) ctx.closePath();
303
+ }
304
+ }
305
+ return this;
306
+ }
307
+
161
308
  setMarkers(markers = []) {
162
309
  this._applyMarkers(markers);
163
310
  this._focus = -1;
@@ -714,9 +861,12 @@ export class GeoGlobe {
714
861
  const { w, h } = this._size();
715
862
  ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
716
863
  ctx.clearRect(0, 0, w, h);
864
+ this._runPasses("beneath");
717
865
  this.hits = this.o.mode === "map" ? this._paintMap(w, h) : this._paintGlobe(w, h);
718
866
  this._dirty = false;
867
+ this._runPasses("above");
719
868
  this.o.onRender?.(this);
869
+ this._runPasses("post");
720
870
  this._paintLicenseNotice(w, h);
721
871
  return this;
722
872
  }
@@ -734,6 +884,7 @@ export class GeoGlobe {
734
884
  destroy() {
735
885
  this._destroyed = true;
736
886
  cancelAnimationFrame(this._raf);
887
+ this.clearEffects();
737
888
  this.stopTour();
738
889
  this.stopStory();
739
890
  this.stopTimeline();
@@ -1188,6 +1339,15 @@ export class GeoGlobe {
1188
1339
  this._onMove = (e) => {
1189
1340
  const [x, y] = this._local(e);
1190
1341
  this._pointer = { x: e.clientX, y: e.clientY };
1342
+ this.pointer.x = x;
1343
+ this.pointer.y = y;
1344
+ this.pointer.over = true;
1345
+ const at = this.unproject(x, y);
1346
+ if (at) {
1347
+ this.pointer.lon = at[0];
1348
+ this.pointer.lat = at[1];
1349
+ }
1350
+ if (this._fx.length) this._dirty = true;
1191
1351
  const licenseHovered = this._licenseHitAt(x, y);
1192
1352
  if (licenseHovered !== this._licenseHovered) {
1193
1353
  this._licenseHovered = licenseHovered;
@@ -1241,7 +1401,7 @@ export class GeoGlobe {
1241
1401
  this._cursor();
1242
1402
  this._dirty = true;
1243
1403
  const target = marker || this._hoveredCountry;
1244
- this._showTip(marker ? (marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
1404
+ this._showTip(marker ? (marker.hexBin ? "hex-bin" : marker.cluster ? "cluster" : "marker") : this._hoveredCountry ? "country" : null, target);
1245
1405
  } else if (this._tipVisible) {
1246
1406
  this._placeTip();
1247
1407
  }
@@ -1258,6 +1418,7 @@ export class GeoGlobe {
1258
1418
  this._onLeave = (e) => {
1259
1419
  this._onUp(e);
1260
1420
  this._pointer = null;
1421
+ this.pointer.over = false;
1261
1422
  this._licenseHovered = false;
1262
1423
  if (this._hovered || this._hoveredCountry) {
1263
1424
  if (this._hovered) this.o.onHover?.(null, null);
@@ -1438,13 +1599,20 @@ export class GeoGlobe {
1438
1599
 
1439
1600
  /* --------------------------------- loop --------------------------------- */
1440
1601
 
1441
- _spinning() {
1602
+ /** Auto-rotation is configured and permitted, regardless of the clock. */
1603
+ _autoSpin() {
1442
1604
  return !!this.o.autoRotate && !this._reducedMotion() && !this._drag && !this._vel && this.o.mode === "globe";
1443
1605
  }
1444
1606
 
1607
+ /** Auto-rotation should advance on its own this frame. */
1608
+ _spinning() {
1609
+ return this._autoSpin() && this._clock == null;
1610
+ }
1611
+
1445
1612
  /** True while something on screen still needs to move. */
1446
1613
  _animating() {
1447
1614
  if (this._target || this._drag || this._vel) return true;
1615
+ if (this._fx.length && this._clock == null) return true;
1448
1616
  if (this._spinning()) return true;
1449
1617
  if (this._reducedMotion()) return false;
1450
1618
  if (this._hasLive || this._pings.length) return true;
@@ -1718,7 +1886,7 @@ export class GeoGlobe {
1718
1886
  const list = this._orbitList();
1719
1887
  if (!list.length) return;
1720
1888
  const { ctx } = this;
1721
- const spin = this._reducedMotion() ? 0 : (Date.now() / 1000) % 3600;
1889
+ const spin = this._reducedMotion() ? 0 : (this._animTime() / 1000) % 3600;
1722
1890
  ctx.lineCap = "round";
1723
1891
  for (const orbit of list) {
1724
1892
  const inc = (orbit.inclination ?? 30) * D2R;
@@ -2272,7 +2440,7 @@ export class GeoGlobe {
2272
2440
  const arcs = this.o.arcs;
2273
2441
  if (!arcs || !arcs.length) return;
2274
2442
  const { ctx } = this;
2275
- const now = Date.now();
2443
+ const now = this._animTime();
2276
2444
  const still = this._reducedMotion();
2277
2445
  ctx.lineCap = "round";
2278
2446
  ctx.lineJoin = "round";
@@ -2421,6 +2589,87 @@ export class GeoGlobe {
2421
2589
  ctx.restore();
2422
2590
  }
2423
2591
 
2592
+ /** Aggregate projected markers into an interactive hexagonal density layer. */
2593
+ _paintHexBins(pts, t, cx, cy, globeRadius, w, h) {
2594
+ const o = this.o.hexBins === true ? {} : this.o.hexBins;
2595
+ const { ctx } = this;
2596
+ const radius = Math.max(4, o.radius ?? 18);
2597
+ const padding = clamp(o.padding ?? 1.5, 0, radius * 0.45);
2598
+ const drawRadius = radius - padding;
2599
+ const minValue = Math.max(0, o.minValue ?? 1);
2600
+ const metric = o.value === "count" ? "count" : "value";
2601
+ const bins = hexBinPoints(pts, radius).filter((bin) => {
2602
+ if (bin[metric] < minValue) return false;
2603
+ if (this.o.mode === "globe") return Math.hypot(bin.x - cx, bin.y - cy) <= globeRadius + drawRadius;
2604
+ return bin.x >= -drawRadius && bin.x <= w + drawRadius && bin.y >= -drawRadius && bin.y <= h + drawRadius;
2605
+ });
2606
+ const max = Math.max(1, ...bins.map((bin) => bin[metric]));
2607
+ const range = Array.isArray(o.colorRange) && o.colorRange.length > 1
2608
+ ? o.colorRange
2609
+ : [t.ocean[0], o.color || t.marker];
2610
+ const domain = range.map((_, index) => max * index / (range.length - 1));
2611
+ const scale = colorScale(domain, range);
2612
+ const hits = [];
2613
+
2614
+ ctx.save();
2615
+ if (this.o.mode === "globe") {
2616
+ ctx.beginPath();
2617
+ ctx.arc(cx, cy, globeRadius, 0, TAU);
2618
+ ctx.clip();
2619
+ } else {
2620
+ ctx.beginPath();
2621
+ ctx.rect(0, 0, w, h);
2622
+ ctx.clip();
2623
+ }
2624
+
2625
+ ctx.lineJoin = "round";
2626
+ for (const bin of bins) {
2627
+ const value = bin[metric];
2628
+ ctx.beginPath();
2629
+ for (let i = 0; i < 6; i++) {
2630
+ const angle = (60 * i - 30) * D2R;
2631
+ const x = bin.x + drawRadius * Math.cos(angle);
2632
+ const y = bin.y + drawRadius * Math.sin(angle);
2633
+ i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
2634
+ }
2635
+ ctx.closePath();
2636
+ ctx.globalAlpha = clamp(o.opacity ?? 0.82, 0, 1);
2637
+ ctx.fillStyle = scale(value) || o.color || t.marker;
2638
+ ctx.fill();
2639
+ if ((o.strokeWidth ?? 0.8) > 0) {
2640
+ ctx.globalAlpha = 1;
2641
+ ctx.strokeStyle = o.stroke || withAlpha(t.label, 0.3);
2642
+ ctx.lineWidth = o.strokeWidth ?? 0.8;
2643
+ ctx.stroke();
2644
+ }
2645
+ if (o.showCount && drawRadius >= 10) {
2646
+ ctx.globalAlpha = 1;
2647
+ ctx.fillStyle = o.labelColor || t.label;
2648
+ ctx.font = `600 ${Math.max(9, Math.min(13, drawRadius * 0.7))}px Inter,system-ui,sans-serif`;
2649
+ ctx.textAlign = "center";
2650
+ ctx.textBaseline = "middle";
2651
+ ctx.fillText(String(value), bin.x, bin.y);
2652
+ }
2653
+ hits.push({
2654
+ marker: {
2655
+ hexBin: true,
2656
+ count: bin.count,
2657
+ value: bin.value,
2658
+ markerCount: bin.count,
2659
+ markers: bin.markers,
2660
+ lon: bin.lon,
2661
+ lat: bin.lat,
2662
+ },
2663
+ x: bin.x,
2664
+ y: bin.y,
2665
+ r: drawRadius,
2666
+ });
2667
+ }
2668
+ ctx.restore();
2669
+ this._lastHexBins = bins;
2670
+ return hits;
2671
+ }
2672
+
2424
2673
  /** Spike height for a marker, as a fraction of the globe radius. */
2425
2674
  _spikeLift(m) {
2426
2675
  if (!this.o.spikes) return 0;
@@ -2801,9 +3050,11 @@ export class GeoGlobe {
2801
3050
  pts.push({ m, x: cx + x, y: cy + y, depth: 0.65 + c * 0.35 });
2802
3051
  }
2803
3052
  if (this.o.heatmap) this._paintHeatmap(pts, t);
3053
+ const binHits = this.o.hexBins ? this._paintHexBins(pts, t, cx, cy, r, w, h) : [];
2804
3054
  if (this.o.spikes) this._paintSpikes(t, cx, cy, r, w, h, null);
2805
3055
  this._paintViewerAccuracy(t, cx, cy, r, null);
2806
- const hits = this._paintMarkers(pts, t);
3056
+ const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
3057
+ const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
2807
3058
  if (this.o.labels) this._paintLabels(pts, t, cx, cy, r, w, h);
2808
3059
  this._paintAnnotations(t);
2809
3060
  this._paintPings(t);
@@ -2878,9 +3129,11 @@ export class GeoGlobe {
2878
3129
  pts.push({ m, x, y, depth: 1 });
2879
3130
  }
2880
3131
  if (this.o.heatmap) this._paintHeatmap(pts, t);
3132
+ const binHits = this.o.hexBins ? this._paintHexBins(pts, t, 0, 0, 0, w, h) : [];
2881
3133
  if (this.o.spikes) this._paintSpikes(t, 0, 0, 0, w, h, fwd);
2882
3134
  this._paintViewerAccuracy(t, 0, 0, 0, fwd);
2883
- const hits = this._paintMarkers(pts, t);
3135
+ const showMarkers = !this.o.hexBins || (this.o.hexBins !== true && this.o.hexBins.hideMarkers === false);
3136
+ const hits = [...binHits, ...(showMarkers ? this._paintMarkers(pts, t) : [])];
2884
3137
  if (this.o.labels) this._paintLabels(pts, t, 0, 0, 0, w, h);
2885
3138
  this._paintAnnotations(t);
2886
3139
  this._paintPings(t);
@@ -3001,7 +3254,7 @@ export class GeoGlobe {
3001
3254
  }
3002
3255
 
3003
3256
  if (m.live && !this._reducedMotion()) {
3004
- const pulse = (Date.now() % 2200) / 2200;
3257
+ const pulse = (this._animTime() % 2200) / 2200;
3005
3258
  ctx.globalAlpha = 1 - pulse;
3006
3259
  ctx.strokeStyle = t.live;
3007
3260
  ctx.lineWidth = 1.4 * scale;
package/src/geo.js CHANGED
@@ -254,6 +254,76 @@ export const withAlpha = (color, a) => {
254
254
  return color;
255
255
  };
256
256
 
257
+ /**
258
+ * Aggregates projected points into a pointy-top hexagonal grid.
259
+ *
260
+ * The input is deliberately renderer-shaped (`{ x, y, depth, m }`) so the
261
+ * same helper works after either globe or flat-map projection. Each returned
262
+ * bin preserves its source markers for tooltips, clicks, and custom details.
263
+ */
264
+ export const hexBinPoints = (points = [], radius = 18) => {
265
+ const size = Math.max(1, Number(radius) || 18);
266
+ const sqrt3 = Math.sqrt(3);
267
+ const cells = new Map();
268
+
269
+ const roundAxial = (q, r) => {
270
+ let x = q;
271
+ let z = r;
272
+ let y = -x - z;
273
+ let rx = Math.round(x);
274
+ let ry = Math.round(y);
275
+ let rz = Math.round(z);
276
+ const dx = Math.abs(rx - x);
277
+ const dy = Math.abs(ry - y);
278
+ const dz = Math.abs(rz - z);
279
+ if (dx > dy && dx > dz) rx = -ry - rz;
280
+ else if (dy > dz) ry = -rx - rz;
281
+ else rz = -rx - ry;
282
+ return [rx, rz];
283
+ };
284
+
285
+ for (const point of points) {
286
+ if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
287
+ const q = (sqrt3 / 3 * point.x - point.y / 3) / size;
288
+ const r = (2 * point.y / 3) / size;
289
+ const [hq, hr] = roundAxial(q, r);
290
+ const key = `${hq}:${hr}`;
291
+ let cell = cells.get(key);
292
+ if (!cell) {
293
+ cell = {
294
+ q: hq,
295
+ r: hr,
296
+ x: size * sqrt3 * (hq + hr / 2),
297
+ y: size * 1.5 * hr,
298
+ count: 0,
299
+ value: 0,
300
+ depth: 0,
301
+ lon: 0,
302
+ lat: 0,
303
+ weight: 0,
304
+ markers: [],
305
+ };
306
+ cells.set(key, cell);
307
+ }
308
+ const marker = point.m || {};
309
+ const value = Number(marker.count);
310
+ const weight = Number.isFinite(value) && value > 0 ? value : 1;
311
+ cell.count += 1;
312
+ cell.value += weight;
313
+ cell.depth = Math.max(cell.depth, Number(point.depth) || 0);
314
+ cell.lon += (Number(marker.lon) || 0) * weight;
315
+ cell.lat += (Number(marker.lat) || 0) * weight;
316
+ cell.weight += weight;
317
+ cell.markers.push(marker);
318
+ }
319
+
320
+ return [...cells.values()].map((cell) => ({
321
+ ...cell,
322
+ lon: cell.weight ? cell.lon / cell.weight : 0,
323
+ lat: cell.weight ? cell.lat / cell.weight : 0,
324
+ }));
325
+ };
326
+
257
327
  const parseRGB = (color) => {
258
328
  if (color.startsWith("#")) {
259
329
  const hex = color.length === 4 ? color.replace(/#(.)(.)(.)/, "#$1$1$2$2$3$3") : color;
package/src/places.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * City search.
3
+ *
4
+ * An optional table of roughly 6,700 populated places - every national and
5
+ * state capital, plus the most populous cities worldwide. Import it only if
6
+ * you need it: it is about as large as the country geometry, and the core
7
+ * library already resolves countries and ~300 major cities for free through
8
+ * `geocode()`.
9
+ *
10
+ * import { searchPlaces, placeSource } from "canvas-globe/places";
11
+ * import { searchAndFly } from "canvas-globe/controls";
12
+ *
13
+ * searchAndFly(globe, input, { source: placeSource() });
14
+ *
15
+ * For the long tail - every hamlet and village - point `source` at GeoNames,
16
+ * Nominatim or your own index instead. Nothing here needs to change.
17
+ */
18
+ import { placeTable } from "./data/places.js";
19
+
20
+ let index = null;
21
+
22
+ /** Lowercase and strip accents, so "São Paulo" answers to "sao paulo". */
23
+ export const fold = (s) =>
24
+ String(s)
25
+ .normalize("NFD")
26
+ .replace(/\p{Diacritic}/gu, "")
27
+ .toLowerCase()
28
+ .trim();
29
+
30
+ const parse = () => {
31
+ if (index) return index;
32
+ index = [];
33
+ for (const row of placeTable.split(";")) {
34
+ const [name, lon, lat, country, pop, region] = row.split("|");
35
+ if (!name) continue;
36
+ index.push({
37
+ name,
38
+ key: fold(name),
39
+ lon: Number(lon) / 100,
40
+ lat: Number(lat) / 100,
41
+ country,
42
+ region: region || "",
43
+ population: Number(pop) * 1000,
44
+ });
45
+ }
46
+ return index;
47
+ };
48
+
49
+ const decorate = (p) => ({
50
+ ...p,
51
+ label: p.region ? `${p.name}, ${p.region}, ${p.country}` : `${p.name}, ${p.country}`,
52
+ });
53
+
54
+ /**
55
+ * Places matching `query`, best first.
56
+ *
57
+ * Names that start with the query always outrank names that merely contain
58
+ * it, so "york" still puts York above New York. Population breaks ties, which
59
+ * is what makes "london" mean the British one.
60
+ *
61
+ * At this table size a linear scan costs well under a millisecond, so there
62
+ * is no prefix tree to build or keep in sync. Build one only if you swap in a
63
+ * table an order of magnitude larger.
64
+ */
65
+ export function searchPlaces(query, { limit = 8, country, contains = true } = {}) {
66
+ const q = fold(query);
67
+ if (!q) return [];
68
+ const hits = [];
69
+ for (const place of parse()) {
70
+ if (country && place.country !== country) continue;
71
+ const at = place.key.indexOf(q);
72
+ if (at < 0) continue;
73
+ if (at > 0 && !contains) continue;
74
+ hits.push({ place, tier: at === 0 ? 0 : 1, exact: place.key === q });
75
+ }
76
+ return hits
77
+ .sort((a, b) =>
78
+ a.tier - b.tier ||
79
+ b.exact - a.exact ||
80
+ b.place.population - a.place.population)
81
+ .slice(0, limit)
82
+ .map((h) => decorate(h.place));
83
+ }
84
+
85
+ /** Coordinate for an exact-ish name, matching the shape of `geocode()`. */
86
+ export function placePoint(name, options) {
87
+ const [hit] = searchPlaces(name, { contains: false, ...options, limit: 1 });
88
+ return hit ? { lon: hit.lon, lat: hit.lat } : null;
89
+ }
90
+
91
+ /** Ready-made resolver for `searchAndFly({ source })`. */
92
+ export function placeSource(options) {
93
+ return (query) => searchPlaces(query, options);
94
+ }
95
+
96
+ /** How many places the bundled table holds. */
97
+ export const placeCount = () => parse().length;