304 lines
12 KiB
JavaScript
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)
|
|
})
|