SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
2.9 KB · 59 lines typescript
Raw Blame History
1import { readFileSync, existsSync } from "node:fs";2import { dirname, join } from "node:path";3import { fileURLToPath } from "node:url";45/**6 * Offline point-in-country lookup (Natural Earth 1:50m admin-0, public domain) — used when a source publishes7 * coordinates but no country (OpenStreetMap features rarely carry addr:country). Deterministic, no network.8 */9type Ring = number[][];10interface Feat { iso2: string; bbox: [number, number, number, number]; polys: Ring[][] }11let feats: Feat[] | null = null;1213function load(): Feat[] {14  if (feats) return feats;15  const here = dirname(fileURLToPath(import.meta.url));16  const candidates = [join(here, "..", "..", "data", "ne_50m_admin_0_countries.geojson"), join(process.cwd(), "apps", "worker", "data", "ne_50m_admin_0_countries.geojson"), join(process.cwd(), "data", "ne_50m_admin_0_countries.geojson")];17  const file = candidates.find((f) => existsSync(f));18  if (!file) { feats = []; return feats; }19  const gj = JSON.parse(readFileSync(file, "utf8")) as { features: Array<{ properties: Record<string, unknown>; geometry: { type: string; coordinates: unknown } }> };20  const out: Feat[] = [];21  for (const f of gj.features) {22    const p = f.properties;23    let iso2 = String(p.ISO_A2_EH ?? p.ISO_A2 ?? "-99");24    if (iso2 === "-99") { const a = String(p.ADM0_A3 ?? ""); iso2 = ({ FRA: "FR", NOR: "NO", KOS: "XK", SDS: "SS", SOL: "SO" } as Record<string, string>)[a] ?? "-99"; }25    if (!/^[A-Z]{2}$/.test(iso2)) continue;26    const polys: Ring[][] = f.geometry.type === "Polygon" ? [f.geometry.coordinates as Ring[]] : f.geometry.type === "MultiPolygon" ? (f.geometry.coordinates as Ring[][]) : [];27    let minX = 180, minY = 90, maxX = -180, maxY = -90;28    for (const poly of polys) for (const [x, y] of poly[0] ?? []) { if (x! < minX) minX = x!; if (x! > maxX) maxX = x!; if (y! < minY) minY = y!; if (y! > maxY) maxY = y!; }29    out.push({ iso2, bbox: [minX, minY, maxX, maxY], polys });30  }31  feats = out;32  return feats;33}3435function inRing(x: number, y: number, ring: Ring): boolean {36  let inside = false;37  for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {38    const xi = ring[i]![0]!, yi = ring[i]![1]!, xj = ring[j]![0]!, yj = ring[j]![1]!;39    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;40  }41  return inside;42}4344/** ISO-2 of the country containing the point, or null (ocean / unknown). Handles holes. */45export function countryFromPoint(lat: number, lng: number): string | null {46  if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;47  for (const f of load()) {48    const [minX, minY, maxX, maxY] = f.bbox;49    if (lng < minX || lng > maxX || lat < minY || lat > maxY) continue;50    for (const poly of f.polys) {51      if (!poly[0] || !inRing(lng, lat, poly[0])) continue;52      let hole = false;53      for (let k = 1; k < poly.length; k++) if (inRing(lng, lat, poly[k]!)) { hole = true; break; }54      if (!hole) return f.iso2;55    }56  }57  return null;58}59