{"version":3,"file":"geocode.cjs","names":[],"sources":["../../src/br/geocode.ts"],"sourcesContent":["import { haversineKm } from \"@/geo/distance\";\nimport type { Coordinate } from \"@/geo/types\";\nimport type { BrUfGeometry, Ring } from \"./br-geo\";\nimport type { UF } from \"./locations\";\nimport { loadStateMunicipalities } from \"./state-geo\";\n\n/** A municipality with its representative coordinate (IBGE centroid). */\nexport interface MunicipalityCentroid {\n    /** 7-digit IBGE code. */\n    id: string;\n    /** Municipality name. */\n    name: string;\n    /** Federative unit. */\n    uf: UF;\n    latitude: number;\n    longitude: number;\n}\n\n/** {@link MunicipalityCentroid} plus the distance from the query point. */\nexport interface NearestMunicipality extends MunicipalityCentroid {\n    /** Great-circle distance from the query coordinate, in kilometers. */\n    distanceKm: number;\n}\n\n/** A municipality identified by point-in-polygon (see {@link reverseGeocode}). */\nexport interface ReverseGeocodeResult {\n    id: string;\n    name: string;\n    uf: UF;\n}\n\ninterface RawIndex {\n    states: Record<string, [number, number]>;\n    municipalities: [string, string, string, number, number][];\n}\n\ninterface CentroidIndex {\n    states: Partial<Record<UF, Coordinate>>;\n    municipalities: MunicipalityCentroid[];\n}\n\nlet cache: CentroidIndex | null = null;\nlet pending: Promise<CentroidIndex> | null = null;\n\n/** Strip accents and lowercase, for accent-insensitive name matching. */\nfunction normalizeName(value: string): string {\n    return value\n        .normalize(\"NFD\")\n        .replace(/\\p{Diacritic}/gu, \"\")\n        .trim()\n        .toLowerCase();\n}\n\n/**\n * Lazily load and cache the compact centroid index (~97 KB gzip). Kept out of\n * the synchronous barrel so importing `/br` for data/maps never pulls it — only\n * a geocode call fetches it, and only once.\n */\nasync function loadIndex(): Promise<CentroidIndex> {\n    if (cache) return cache;\n    if (!pending) {\n        pending = import(\"./data/br-centroids.json\").then((mod) => {\n            const raw = (mod.default ?? mod) as unknown as RawIndex;\n            const states: Partial<Record<UF, Coordinate>> = {};\n            for (const [uf, [lon, lat]] of Object.entries(raw.states)) {\n                states[uf as UF] = { latitude: lat, longitude: lon };\n            }\n            cache = {\n                states,\n                municipalities: raw.municipalities.map(([id, name, uf, lon, lat]) => ({\n                    id,\n                    name,\n                    uf: uf as UF,\n                    latitude: lat,\n                    longitude: lon,\n                })),\n            };\n            return cache;\n        });\n    }\n    return pending;\n}\n\n/**\n * Nearest Brazilian municipality to a coordinate by **centroid distance** —\n * fast and geometry-free (only the small centroid index loads). Approximate:\n * near borders or inside large municipalities a neighbor's centroid can be\n * closer. For an exact containing municipality use {@link reverseGeocode}.\n *\n * @param coord - Query coordinate.\n * @returns The nearest municipality with `distanceKm`, or `null` if the index\n *   is somehow empty.\n *\n * @example\n * const near = await nearestMunicipality({ latitude: -23.55, longitude: -46.63 });\n * // { id, name, uf: \"SP\", …, distanceKm }\n */\nexport async function nearestMunicipality(coord: Coordinate): Promise<NearestMunicipality | null> {\n    const { municipalities } = await loadIndex();\n    let best: MunicipalityCentroid | null = null;\n    let bestDistance = Infinity;\n    for (const m of municipalities) {\n        const d = haversineKm(coord, m);\n        if (d < bestDistance) {\n            bestDistance = d;\n            best = m;\n        }\n    }\n    return best ? { ...best, distanceKm: bestDistance } : null;\n}\n\n/**\n * Forward-geocode a municipality by name (accent-insensitive), optionally scoped\n * to a UF. Returns every exact-name match — the same name can occur in several\n * states (e.g. \"Bonito\").\n *\n * @param name - Municipality name.\n * @param uf - Optional UF to restrict the search.\n * @returns Matching municipalities (empty array when none match).\n */\nexport async function geocodeMunicipality(name: string, uf?: UF): Promise<MunicipalityCentroid[]> {\n    const { municipalities } = await loadIndex();\n    const target = normalizeName(name);\n    return municipalities.filter((m) => (!uf || m.uf === uf) && normalizeName(m.name) === target);\n}\n\n/**\n * Substring search over municipality names (accent-insensitive), for\n * autocomplete. Ranked prefix-first, then alphabetically.\n *\n * @param query - Partial name.\n * @param options - `uf` to scope, `limit` (default 20).\n * @returns Ranked matches.\n */\nexport async function searchMunicipalities(\n    query: string,\n    options: { uf?: UF; limit?: number } = {},\n): Promise<MunicipalityCentroid[]> {\n    const { uf, limit = 20 } = options;\n    const q = normalizeName(query);\n    if (!q) return [];\n    const { municipalities } = await loadIndex();\n    const hits = municipalities\n        .filter((m) => (!uf || m.uf === uf) && normalizeName(m.name).includes(q))\n        .sort((a, b) => {\n            const ap = normalizeName(a.name).startsWith(q) ? 0 : 1;\n            const bp = normalizeName(b.name).startsWith(q) ? 0 : 1;\n            return ap - bp || a.name.localeCompare(b.name, \"pt-BR\");\n        });\n    return hits.slice(0, limit);\n}\n\n/**\n * Centroid of a municipality by IBGE code.\n *\n * @param id - 7-digit IBGE code.\n * @returns The municipality centroid, or `null` if unknown.\n */\nexport async function municipalityCentroid(id: string): Promise<MunicipalityCentroid | null> {\n    const { municipalities } = await loadIndex();\n    return municipalities.find((m) => m.id === id) ?? null;\n}\n\n/**\n * Centroid of a federative unit.\n *\n * @param uf - Federative unit acronym.\n * @returns The state centroid, or `null` if unknown.\n */\nexport async function stateCentroid(uf: UF): Promise<Coordinate | null> {\n    const { states } = await loadIndex();\n    return states[uf] ?? null;\n}\n\n/** Ray-casting point-in-ring test (`ring` is `[lon, lat]` pairs). */\nfunction pointInRing(lon: number, lat: number, ring: Ring): boolean {\n    let inside = false;\n    for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {\n        const [xi, yi] = ring[i];\n        const [xj, yj] = ring[j];\n        const intersects = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;\n        if (intersects) inside = !inside;\n    }\n    return inside;\n}\n\n/** True when the point lies in a polygon's outer ring and not in a hole. */\nfunction pointInPolygon(lon: number, lat: number, rings: Ring[]): boolean {\n    if (rings.length === 0 || !pointInRing(lon, lat, rings[0])) return false;\n    for (let i = 1; i < rings.length; i += 1) {\n        if (pointInRing(lon, lat, rings[i])) return false; // inside a hole\n    }\n    return true;\n}\n\n/** True when the point lies inside a (multi)polygon geometry. */\nfunction pointInGeometry(lon: number, lat: number, geom: BrUfGeometry): boolean {\n    if (geom.type === \"Polygon\") {\n        return pointInPolygon(lon, lat, geom.coordinates as Ring[]);\n    }\n    return (geom.coordinates as Ring[][]).some((poly) => pointInPolygon(lon, lat, poly));\n}\n\n/**\n * Exact reverse-geocode: the municipality whose (simplified) boundary\n * **contains** the coordinate, via point-in-polygon. Loads one state's geometry\n * chunk — pass `uf` when you know it to skip the centroid lookup that picks the\n * candidate state. Boundaries are simplified (~2 km), so points within ~1-2 km\n * of a border may resolve to the neighbor; offshore points return `null`.\n *\n * @param coord - Query coordinate.\n * @param options - `uf` to force the state searched.\n * @returns The containing municipality, or `null` (falls back to the nearest\n *   centroid municipality when the point is inside no polygon of the state).\n *\n * @example\n * const here = await reverseGeocode({ latitude: -23.5505, longitude: -46.6333 });\n * // { id: \"3550308\", name: \"São Paulo\", uf: \"SP\" }\n */\nexport async function reverseGeocode(\n    coord: Coordinate,\n    options: { uf?: UF } = {},\n): Promise<ReverseGeocodeResult | null> {\n    const nearest = options.uf ? null : await nearestMunicipality(coord);\n    const uf = options.uf ?? nearest?.uf;\n    if (!uf) return null;\n\n    const state = await loadStateMunicipalities(uf);\n    const hit = state?.features.find((f) =>\n        pointInGeometry(coord.longitude, coord.latitude, f.geometry),\n    );\n    if (hit) return { id: hit.properties.id, name: hit.properties.name, uf };\n\n    // Point inside no polygon (border/offshore) — fall back to nearest centroid.\n    const fallback = nearest ?? (await nearestMunicipality(coord));\n    return fallback ? { id: fallback.id, name: fallback.name, uf: fallback.uf } : null;\n}\n"],"mappings":"oEAyCA,IAAI,EAA8B,KAC9B,EAAyC,KAG7C,SAAS,EAAc,EAAuB,CAC1C,OAAO,EACF,UAAU,KAAK,CAAC,CAChB,QAAQ,kBAAmB,EAAE,CAAC,CAC9B,KAAK,CAAC,CACN,YAAY,CACrB,CAOA,eAAe,GAAoC,CAsB/C,OArBI,IACJ,AACI,IAAA,QAAA,QAAA,CAAA,CAAA,SAAA,QAAU,yBAAA,CAAA,CAAA,CAAmC,KAAM,GAAQ,CACvD,IAAM,EAAO,EAAI,SAAW,EACtB,EAA0C,CAAC,EACjD,IAAK,GAAM,CAAC,EAAI,CAAC,EAAK,MAAS,OAAO,QAAQ,EAAI,MAAM,EACpD,EAAO,GAAY,CAAE,SAAU,EAAK,UAAW,CAAI,EAYvD,MAVA,GAAQ,CACJ,SACA,eAAgB,EAAI,eAAe,KAAK,CAAC,EAAI,EAAM,EAAI,EAAK,MAAU,CAClE,KACA,OACI,KACJ,SAAU,EACV,UAAW,CACf,EAAE,CACN,EACO,CACX,CAAC,EAEE,EACX,CAgBA,eAAsB,EAAoB,EAAwD,CAC9F,GAAM,CAAE,kBAAmB,MAAM,EAAU,EACvC,EAAoC,KACpC,EAAe,IACnB,IAAK,IAAM,KAAK,EAAgB,CAC5B,IAAM,EAAI,EAAA,YAAY,EAAO,CAAC,EAC1B,EAAI,IACJ,EAAe,EACf,EAAO,EAEf,CACA,OAAO,EAAO,CAAE,GAAG,EAAM,WAAY,CAAa,EAAI,IAC1D,CAWA,eAAsB,EAAoB,EAAc,EAA0C,CAC9F,GAAM,CAAE,kBAAmB,MAAM,EAAU,EACrC,EAAS,EAAc,CAAI,EACjC,OAAO,EAAe,OAAQ,IAAO,CAAC,GAAM,EAAE,KAAO,IAAO,EAAc,EAAE,IAAI,IAAM,CAAM,CAChG,CAUA,eAAsB,EAClB,EACA,EAAuC,CAAC,EACT,CAC/B,GAAM,CAAE,KAAI,QAAQ,IAAO,EACrB,EAAI,EAAc,CAAK,EAC7B,GAAI,CAAC,EAAG,MAAO,CAAC,EAChB,GAAM,CAAE,kBAAmB,MAAM,EAAU,EAQ3C,OAPa,EACR,OAAQ,IAAO,CAAC,GAAM,EAAE,KAAO,IAAO,EAAc,EAAE,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CACxE,MAAM,EAAG,IACK,IAAc,EAAE,IAAI,CAAC,CAAC,WAAW,CAAC,EAClC,GAAc,EAAE,IAAI,CAAC,CAAC,WAAW,CAAC,GAC3B,EAAE,KAAK,cAAc,EAAE,KAAM,OAAO,CAEvD,CAAA,CAAK,MAAM,EAAG,CAAK,CAC9B,CAQA,eAAsB,EAAqB,EAAkD,CACzF,GAAM,CAAE,kBAAmB,MAAM,EAAU,EAC3C,OAAO,EAAe,KAAM,GAAM,EAAE,KAAO,CAAE,GAAK,IACtD,CAQA,eAAsB,EAAc,EAAoC,CACpE,GAAM,CAAE,UAAW,MAAM,EAAU,EACnC,OAAO,EAAO,IAAO,IACzB,CAGA,SAAS,EAAY,EAAa,EAAa,EAAqB,CAChE,IAAI,EAAS,GACb,IAAK,IAAI,EAAI,EAAG,EAAI,EAAK,OAAS,EAAG,EAAI,EAAK,OAAQ,EAAI,EAAG,GAAK,EAAG,CACjE,GAAM,CAAC,EAAI,GAAM,EAAK,GAChB,CAAC,EAAI,GAAM,EAAK,GACH,EAAK,GAAQ,EAAK,GAAO,GAAQ,EAAK,IAAO,EAAM,IAAQ,EAAK,GAAM,IACzE,EAAS,CAAC,EAC9B,CACA,OAAO,CACX,CAGA,SAAS,EAAe,EAAa,EAAa,EAAwB,CACtE,GAAI,EAAM,SAAW,GAAK,CAAC,EAAY,EAAK,EAAK,EAAM,EAAE,EAAG,MAAO,GACnE,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,GAAK,EACnC,GAAI,EAAY,EAAK,EAAK,EAAM,EAAE,EAAG,MAAO,GAEhD,MAAO,EACX,CAGA,SAAS,EAAgB,EAAa,EAAa,EAA6B,CAI5E,OAHI,EAAK,OAAS,UACP,EAAe,EAAK,EAAK,EAAK,WAAqB,EAEtD,EAAK,YAAyB,KAAM,GAAS,EAAe,EAAK,EAAK,CAAI,CAAC,CACvF,CAkBA,eAAsB,EAClB,EACA,EAAuB,CAAC,EACY,CACpC,IAAM,EAAU,EAAQ,GAAK,KAAO,MAAM,EAAoB,CAAK,EAC7D,EAAK,EAAQ,IAAM,GAAS,GAClC,GAAI,CAAC,EAAI,OAAO,KAGhB,IAAM,GAAM,MADQ,EAAA,wBAAwB,CAAE,EAAA,EAC3B,SAAS,KAAM,GAC9B,EAAgB,EAAM,UAAW,EAAM,SAAU,EAAE,QAAQ,CAC/D,EACA,GAAI,EAAK,MAAO,CAAE,GAAI,EAAI,WAAW,GAAI,KAAM,EAAI,WAAW,KAAM,IAAG,EAGvE,IAAM,EAAW,GAAY,MAAM,EAAoB,CAAK,EAC5D,OAAO,EAAW,CAAE,GAAI,EAAS,GAAI,KAAM,EAAS,KAAM,GAAI,EAAS,EAAG,EAAI,IAClF"}