teqo/scripts/build-bahia-geometries.mjs

304 lines
12 KiB
JavaScript

/**
* Builds static Bahia municipality + identity-territory TopoJSON and the
* name→IBGE-code table for the campaign map (roadmap B2).
*
* Provenance (do not strip):
* - IBGE Malhas API v3 (UF BA=29, municipal intrarregião, qualidade intermediaria):
* https://servicodados.ibge.gov.br/api/v3/malhas/estados/29?formato=application/vnd.geo+json&intrarregiao=municipio&qualidade=intermediaria
* - IBGE Localidades API v1 (municipality id + name for BA):
* https://servicodados.ibge.gov.br/api/v1/localidades/estados/29/municipios
* - Identity-territory composition: src/lib/bahiaTerritories.ts (SECULT/SEPLAN;
* territory polygons are dissolved from IBGE municipality meshes — decision
* 2026-07-18). IDE Bahia / SEI territory polygons remain a validation
* reference only:
* https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864
*
* Safety: does not touch any database — only downloads public IBGE JSON and
* writes versioned artifacts under src/lib/ and tests/fixtures/.
*
* Usage:
* pnpm build:geometries
* GEOMETRIES_CACHE_DIR=./data/geometries pnpm build:geometries
*/
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { merge, quantize } from 'topojson-client'
import { topology } from 'topojson-server'
import { presimplify, quantile, simplify } from 'topojson-simplify'
const { canonicalizeMunicipalityName, UnknownMunicipalityError } =
await import('../src/lib/electionResults.ts')
const { bahiaMunicipalities, bahiaIdentityTerritoryRecords } =
await import('../src/lib/bahiaTerritories.ts')
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SOURCES = {
malhas: {
key: 'bahia-malhas-municipios-intermediaria',
url: 'https://servicodados.ibge.gov.br/api/v3/malhas/estados/29?formato=application/vnd.geo+json&intrarregiao=municipio&qualidade=intermediaria',
ext: 'geojson',
},
localidades: {
key: 'bahia-localidades-municipios',
url: 'https://servicodados.ibge.gov.br/api/v1/localidades/estados/29/municipios',
ext: 'json',
},
}
/** Quantile of triangle area kept after presimplify (higher = more detail). */
const SIMPLIFY_QUANTILE = 0.35
/** Quantization precision for the final topology. */
const QUANTIZE_DIGITS = 1e4
const die = (message) => {
console.error(`\n[build:geometries] ${message}\n`)
process.exit(1)
}
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const cacheDir = () => process.env.GEOMETRIES_CACHE_DIR || join(ROOT, 'data', 'geometries')
const ensureCachedJson = async ({ key, url, ext }) => {
const dir = cacheDir()
await mkdir(dir, { recursive: true })
const path = join(dir, `${key}.${ext}`)
try {
await access(path)
console.log(`[build:geometries] cache hit ${path}`)
const buffer = await readFile(path)
return { url, hash: sha256(buffer), json: JSON.parse(buffer.toString('utf8')) }
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
console.log(`[build:geometries] downloading ${url}`)
const buffer = await downloadToBuffer(url)
const hash = sha256(buffer)
await writeFile(path, buffer)
console.log(`[build:geometries] saved ${path} (${buffer.length} bytes, sha256=${hash})`)
return { url, hash, json: JSON.parse(buffer.toString('utf8')) }
}
const writeJson = async (relativePath, value) => {
const path = join(ROOT, relativePath)
await mkdir(dirname(path), { recursive: true })
const body = typeof value === 'string' ? value : `${JSON.stringify(value)}\n`
await writeFile(path, body)
console.log(`[build:geometries] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
}
const simplifyTopology = (topo) => {
let next = presimplify(topo)
const minWeight = quantile(next, SIMPLIFY_QUANTILE)
next = simplify(next, minWeight)
return quantize(next, QUANTIZE_DIGITS)
}
const buildMunicipalityCodesModule = (entries, provenance) => {
const lines = entries
.map(([name, code]) => ` ${JSON.stringify(name)}: ${JSON.stringify(code)},`)
.join('\n')
return `/**
* Official Bahia municipality name → IBGE 7-digit code (codarea) mapping.
*
* Source (IBGE Localidades API v1 — municípios da UF BA; accessed ${provenance.accessed}):
* ${provenance.localidadesUrl}
* Download SHA-256: ${provenance.localidadesSha256}
*
* Mesh provenance (IBGE Malhas API v3 — qualidade intermediaria; accessed ${provenance.accessed}):
* ${provenance.malhasUrl}
* Download SHA-256: ${provenance.malhasSha256}
*
* Municipality spellings follow the canonical application names in bahiaTerritories.
* IBGE variants (Araçás, Iuiu, Muquém do São Francisco, Santa Terezinha) were
* reconciled via canonicalizeMunicipalityName. Coverage is independently checked
* by the official test evidence fixture.
*/
import { isBahiaMunicipality } from '@/lib/bahiaTerritories'
export const bahiaMunicipalityCodes: Readonly<Record<string, string>> = {
${lines}
}
export const codeForMunicipality = (name: string): string | undefined => {
if (!isBahiaMunicipality(name)) return undefined
return bahiaMunicipalityCodes[name]
}
const municipalityByCode = new Map(
Object.entries(bahiaMunicipalityCodes).map(([name, code]) => [code, name]),
)
export const municipalityForCode = (codarea: string): string | undefined =>
municipalityByCode.get(codarea)
`
}
const main = async () => {
const malhas = await ensureCachedJson(SOURCES.malhas)
const localidades = await ensureCachedJson(SOURCES.localidades)
console.log('[build:geometries] provenance:')
console.log(` - ${malhas.url}`)
console.log(` sha256=${malhas.hash}`)
console.log(` - ${localidades.url}`)
console.log(` sha256=${localidades.hash}`)
if (!Array.isArray(localidades.json) || localidades.json.length !== 417) {
die(`Expected 417 IBGE localidades; got ${localidades.json?.length ?? 'non-array'}`)
}
if (malhas.json?.type !== 'FeatureCollection' || malhas.json.features?.length !== 417) {
die(`Expected 417 IBGE malha features; got ${malhas.json?.features?.length ?? 'invalid'}`)
}
/** @type {Map<string, string>} canonical name → IBGE codarea */
const codeByCanonical = new Map()
/** @type {Map<string, string>} IBGE codarea → canonical name */
const canonicalByCodarea = new Map()
for (const row of localidades.json) {
const codarea = String(row.id)
if (!/^\d{7}$/.test(codarea) || !codarea.startsWith('29')) {
die(`Unexpected IBGE municipality id: ${row.id}`)
}
let canonical
try {
canonical = canonicalizeMunicipalityName(row.nome)
} catch (error) {
if (!(error instanceof UnknownMunicipalityError)) throw error
die(`IBGE name without canonical mapping: "${row.nome}"`)
}
if (codeByCanonical.has(canonical)) {
die(`Duplicate canonical municipality from IBGE: ${canonical}`)
}
codeByCanonical.set(canonical, codarea)
canonicalByCodarea.set(codarea, canonical)
}
const missing = bahiaMunicipalities.filter((name) => !codeByCanonical.has(name))
if (missing.length > 0) {
die(`Canonical municipalities missing from IBGE localidades:\n - ${missing.join('\n - ')}`)
}
if (codeByCanonical.size !== 417) {
die(`Expected 417 canonical mappings; got ${codeByCanonical.size}`)
}
const municipalityFeatures = []
for (const feature of malhas.json.features) {
const codarea = String(feature.properties?.codarea ?? '')
const name = canonicalByCodarea.get(codarea)
if (!name) die(`Malha feature codarea not in localidades: ${codarea}`)
municipalityFeatures.push({
type: 'Feature',
properties: { codarea, name },
geometry: feature.geometry,
})
}
municipalityFeatures.sort((left, right) =>
left.properties.name.localeCompare(right.properties.name, 'pt-BR'),
)
let municipalityTopo = topology({
municipalities: { type: 'FeatureCollection', features: municipalityFeatures },
})
municipalityTopo = simplifyTopology(municipalityTopo)
const geometriesByCodarea = new Map(
municipalityTopo.objects.municipalities.geometries.map((geometry) => [
geometry.properties.codarea,
geometry,
]),
)
const territoryFeatures = bahiaIdentityTerritoryRecords.map((territory) => {
const memberGeometries = territory.municipalities.map((name) => {
const code = codeByCanonical.get(name)
const geometry = geometriesByCodarea.get(code)
if (!geometry)
die(`Missing municipality geometry for ${name} (${code}) in territory ${territory.code}`)
return geometry
})
return {
type: 'Feature',
properties: { code: territory.code, name: territory.name },
geometry: merge(municipalityTopo, memberGeometries),
}
})
let territoryTopo = topology({
territories: { type: 'FeatureCollection', features: territoryFeatures },
})
territoryTopo = simplifyTopology(territoryTopo)
const accessed = new Date().toISOString().slice(0, 10)
const provenance = {
accessed,
malhasUrl: malhas.url,
malhasSha256: malhas.hash,
localidadesUrl: localidades.url,
localidadesSha256: localidades.hash,
}
const codeEntries = [...codeByCanonical.entries()].sort(([left], [right]) =>
left.localeCompare(right, 'pt-BR'),
)
await writeJson('src/lib/geometries/bahia-municipalities.topo.json', municipalityTopo)
await writeJson('src/lib/geometries/bahia-identity-territories.topo.json', territoryTopo)
await writeJson(
'src/lib/bahiaMunicipalityCodes.ts',
buildMunicipalityCodesModule(codeEntries, provenance),
)
const assignmentRows = codeEntries
.map(([municipality, code]) => `M\t${municipality}\t${code}\n`)
.join('')
const evidenceSha256 = createHash('sha256').update(assignmentRows).digest('hex')
const fixture = {
provenance: {
description:
'Test-only evidence for the Bahia municipality name→IBGE-code table. Rows are the canonical application spellings paired with IBGE 7-digit codes from the Localidades API; mesh SHA-256 documents the Malhas download used to build the committed TopoJSON.',
localidadesSource: {
url: localidades.url,
downloadSha256: localidades.hash,
},
malhasSource: {
url: malhas.url,
quality: 'intermediaria',
downloadSha256: malhas.hash,
},
territoryComposition:
'Identity-territory polygons are dissolved from IBGE municipality meshes using bahiaIdentityTerritoryRecords (SECULT/SEPLAN composition). IDE Bahia / SEI territory polygons are a validation reference only.',
accessed,
normalization:
'IBGE Localidades nomes were reconciled to canonical application spellings via canonicalizeMunicipalityName (Araçás→Araças, Iuiu→Iuiú, Muquém do São Francisco→Muquém de São Francisco, Santa Terezinha→Santa Teresinha). The evidence SHA-256 covers every canonical municipality→codarea assignment.',
},
municipalityCount: 417,
assignments: codeEntries.map(([municipality, code]) => ({ municipality, code })),
evidenceSha256,
}
await writeJson(
'tests/fixtures/bahia-municipality-codes.official.json',
`${JSON.stringify(fixture, null, 2)}\n`,
)
console.log('[build:geometries] done')
console.log(` municipalities: ${municipalityFeatures.length}`)
console.log(` territories: ${territoryFeatures.length}`)
console.log(` evidenceSha256: ${evidenceSha256}`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})