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%
3.5 KB · 92 lines typescript
Raw Blame History
1/**2 * Metro assignment: nearest seeded metro whose radius_km covers the point; otherwise null.3 * Metros are loaded once per process and refreshed every 10 minutes.4 */5import { sql } from "@dci/db";6import { haversineKm, normalizeName } from "@dci/core";7import type { Tx } from "./common.js";89export interface MetroRef {10  id: string;11  slug: string;12  name: string;13  countryIso2: string;14  lat: number;15  lng: number;16  radiusKm: number;17  aliases: string[];18  normalizedAliases: string[];19}2021let cache: { at: number; metros: MetroRef[] } | null = null;22const TTL_MS = 10 * 60 * 1000;2324export async function loadMetros(tx: Tx, force = false): Promise<MetroRef[]> {25  if (!force && cache && Date.now() - cache.at < TTL_MS) return cache.metros;26  const rows = await tx.execute(sql`select id, slug, name, country_iso2, lat, lng, radius_km, aliases from metros`);27  const metros: MetroRef[] = rows.map((r) => {28    const aliases = Array.isArray(r.aliases) ? (r.aliases as string[]) : [];29    return {30      id: String(r.id),31      slug: String(r.slug),32      name: String(r.name),33      countryIso2: String(r.country_iso2),34      lat: Number(r.lat),35      lng: Number(r.lng),36      radiusKm: Number(r.radius_km ?? 60),37      aliases,38      normalizedAliases: [normalizeName(String(r.name)), ...aliases.map((a) => normalizeName(a))].filter(Boolean),39    };40  });41  cache = { at: Date.now(), metros };42  return metros;43}4445export function invalidateMetroCache(): void {46  cache = null;47}4849/** Nearest metro covering the coordinates (distance ≤ radius_km). */50export function nearestMetro(metros: MetroRef[], lat: number, lng: number, countryIso2?: string | null): MetroRef | null {51  let best: MetroRef | null = null;52  let bestD = Number.POSITIVE_INFINITY;53  for (const m of metros) {54    // A metro never spans a border in our seed data, so a known country is a hard filter.55    if (countryIso2 && m.countryIso2 !== countryIso2) continue;56    const d = haversineKm(lat, lng, m.lat, m.lng);57    if (d <= m.radiusKm && d < bestD) {58      best = m;59      bestD = d;60    }61  }62  return best;63}6465/** Metro by city / region name (used when no coordinates are available): exact alias match within the same country. */66export function metroByCityName(metros: MetroRef[], city: string | null | undefined, countryIso2: string | null | undefined): MetroRef | null {67  if (!city || !countryIso2) return null;68  const n = normalizeName(city);69  if (!n) return null;70  for (const m of metros) if (m.countryIso2 === countryIso2 && m.normalizedAliases.includes(n)) return m;71  return null;72}7374export interface MetroAssignment {75  metroId: string | null;76  countryIso2: string | null;77  method: "geo" | "city" | "none";78}7980/** Assign a metro (geo first, then city alias) and fall back to the metro's country when the entity has none. */81export async function assignMetro(tx: Tx, input: { lat?: number | null; lng?: number | null; city?: string | null; countryIso2?: string | null }): Promise<MetroAssignment> {82  const metros = await loadMetros(tx);83  const country = input.countryIso2?.toUpperCase() ?? null;84  if (typeof input.lat === "number" && typeof input.lng === "number" && Number.isFinite(input.lat) && Number.isFinite(input.lng)) {85    const m = nearestMetro(metros, input.lat, input.lng, country);86    if (m) return { metroId: m.id, countryIso2: country ?? m.countryIso2, method: "geo" };87  }88  const byCity = metroByCityName(metros, input.city, country);89  if (byCity) return { metroId: byCity.id, countryIso2: country ?? byCity.countryIso2, method: "city" };90  return { metroId: null, countryIso2: country, method: "none" };91}92