spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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