B8 F2: polígonos das ZE de Salvador e re-keying do coroplético por map key

Salvador era um polígono só carregando os votos da cidade inteira, então o
coroplético não conseguia responder "qual pedaço de Salvador está fraco / é
meu". As 19 ZE ganham polígono próprio, dissolvido da malha de bairros do IBGE
(Censo 2022) pela lista de circunscrição da RA 02/2017 do TRE-BA, e o mapa passa
a pintar por map key (slug para as zonas, ibgeCode para o resto) em vez de
codarea. O polígono municipal de Salvador continua desenhado por baixo, sem dado
e sem interação, para não abrir buraco no contorno do estado.

Aproximação declarada na copy e no cabeçalho do script: o TRE publica a
circunscrição como lista de bairros, então o bairro é o átomo geométrico —
Periperi, que a resolução corta na Rua das Pedrinhas, vai inteiro para a ZE 17.
competitiveRank segue por cidade (o artefato TSE só tem federalRankByIbgeCode).

Sem migration, sem collection, sem action, sem Consent.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 23:08:02 -03:00
parent 2ac3a3e512
commit 84e796ab49
22 changed files with 1051 additions and 243 deletions

View file

@ -33,6 +33,7 @@
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
@ -108,6 +109,7 @@
"@types/pg": "^8.10.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/shpjs": "^3.4.7",
"@types/topojson-client": "^3.1.5",
"@types/topojson-server": "^3.0.4",
"@types/topojson-simplify": "^3.0.3",
@ -129,6 +131,7 @@
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.3.0",
"schema-dts": "^2.0.0",
"shpjs": "^6.2.0",
"tailwindcss": "^4.1.18",
"topojson-server": "^3.0.1",
"topojson-simplify": "^3.0.3",

View file

@ -147,6 +147,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/shpjs':
specifier: ^3.4.7
version: 3.4.7
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
@ -210,6 +213,9 @@ importers:
schema-dts:
specifier: ^2.0.0
version: 2.0.0(@typescript/typescript6@6.0.2)
shpjs:
specifier: ^6.2.0
version: 6.2.0
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
@ -3148,6 +3154,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/shpjs@3.4.7':
resolution: {integrity: sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@ -3834,6 +3843,9 @@ packages:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
but-unzip@0.1.10:
resolution: {integrity: sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -5627,6 +5639,9 @@ packages:
merge@2.1.1:
resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==}
mgrs@1.0.0:
resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@ -5974,6 +5989,9 @@ packages:
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
parsedbf@2.0.0:
resolution: {integrity: sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -6239,6 +6257,14 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
proj4@2.20.9:
resolution: {integrity: sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==}
peerDependencies:
geotiff: '*'
peerDependenciesMeta:
geotiff:
optional: true
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@ -6607,6 +6633,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shpjs@6.2.0:
resolution: {integrity: sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@ -7318,6 +7347,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
wkt-parser@1.5.6:
resolution: {integrity: sha512-cqHU3lzGt/gt2OqIORP0uVy5yeOX43WABmgFkmJsmcqH3HhQo9PiJG6ftytwGKmpQUbegeX7+pQc2BuNCRfcrw==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@ -10260,6 +10292,11 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/shpjs@3.4.7':
dependencies:
'@types/geojson': 7946.0.16
'@types/node': 22.19.9
'@types/statuses@2.0.6': {}
'@types/topojson-client@3.1.5':
@ -10912,6 +10949,8 @@ snapshots:
dependencies:
streamsearch: 1.1.0
but-unzip@0.1.10: {}
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@ -12826,6 +12865,8 @@ snapshots:
merge@2.1.1: {}
mgrs@1.0.0: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
@ -13344,6 +13385,8 @@ snapshots:
dependencies:
entities: 6.0.1
parsedbf@2.0.0: {}
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
@ -13633,6 +13676,11 @@ snapshots:
process-warning@5.0.0: {}
proj4@2.20.9:
dependencies:
mgrs: 1.0.0
wkt-parser: 1.5.6
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@ -14209,6 +14257,14 @@ snapshots:
shebang-regex@3.0.0: {}
shpjs@6.2.0:
dependencies:
but-unzip: 0.1.10
parsedbf: 2.0.0
proj4: 2.20.9
transitivePeerDependencies:
- geotiff
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@ -14940,6 +14996,8 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wkt-parser@1.5.6: {}
wmf@1.0.2: {}
word-wrap@1.2.5: {}

View file

@ -0,0 +1,407 @@
/**
* Builds the static TopoJSON of the zone municipalities — Salvador's 19 ZE
* (roadmap B8 F2). One feature per catalog `kind: 'zona'` entry, dissolved from
* the IBGE neighborhood mesh using the official TRE circunscrição list.
*
* Provenance (do not strip):
* - IBGE Malhas de bairros, Censo 2022 (shapefile, UF BA):
* https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip
* - Zone → neighborhood assignment: src/lib/municipalityZoneNeighborhoods.ts
* (TRE-BA Resolução Administrativa nº 2/2017, Anexo I).
*
* APPROXIMATION — the result is NOT an official TSE boundary. The TRE publishes
* the circunscrição as a list of neighborhoods, so the polygon is only as exact
* as the neighborhood mesh: where the resolution splits one neighborhood
* between two zones by a street, the polygon is atomic and goes whole to one of
* them (see SPLIT_NEIGHBORHOOD_ZONES). Every surface that draws it says so.
*
* Safety: does not touch any database — only downloads public IBGE data and
* writes a versioned artifact under src/lib/geometries/.
*
* Usage:
* pnpm build:municipality-zone-geometries
* pnpm build:municipality-zone-geometries --report # reconciliation only
* GEOMETRIES_CACHE_DIR=./data/geometries pnpm build:municipality-zone-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 shp from 'shpjs'
import { merge, quantize } from 'topojson-client'
import { topology } from 'topojson-server'
import { presimplify, quantile, simplify } from 'topojson-simplify'
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityZoneNeighborhoods } =
await import('../src/lib/municipalityZoneNeighborhoods.ts')
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SOURCE = {
key: 'bahia-bairros-cd2022',
url: 'https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip',
ext: 'zip',
}
const OUTPUT_PATH = 'src/lib/geometries/bahia-municipality-zones.topo.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
/**
* Same neighborhood, different name in the two sources — IBGE composites
* ("Beiru / Tancredo Neves") and spellings the accent folding cannot reach.
* Identity beats the adjacency rule below: the resolution naming a
* neighborhood is the fact, adjacency is only the fallback for the ones it
* never mentions. Both sides are matched normalized and the target must exist
* in the TRE catalog, or the build fails.
*/
const IBGE_TO_TRE_NEIGHBORHOOD = {
'BEIRU TANCREDO NEVES': 'Tancredo Neves',
'CENTRO ADMINISTRATIVO DA BAHIA CAB': 'Centro Administrativo da Bahia',
'NORDESTE DO AMARALINA': 'Nordeste de Amaralina',
// Islands have no shared boundary, so the adjacency rule cannot place them —
// and it does not need to: RA 02/2017 names both in ZE 4.
'ILHA BOM JESUS DOS PASSOS': 'Ilha de Bom Jesus dos Passos',
'ILHA DOS FRADES ILHA DE SANTO ANTONIO': 'Ilha dos Frades',
'VILA RUY BARBOSA JARDIM CRUZEIRO': 'Vila Ruy Barbosa',
}
/**
* TRE neighborhoods with no polygon of their own because the IBGE mesh draws
* them inside a neighbor. Only legal within one zone — the build refuses a
* merge that would move territory across zones.
*/
const TRE_NEIGHBORHOODS_MERGED_INTO = {
'Jardim Cruzeiro': 'Vila Ruy Barbosa',
}
/**
* Neighborhoods the resolution splits between two zones by a street. The
* polygon is atomic without street-level geodata, so it goes whole to the zone
* named here, and every build reports it.
*/
const SPLIT_NEIGHBORHOOD_ZONES = {
// RA 02/2017 cuts Periperi at Rua das Pedrinhas (north = ZE 4, south = ZE 17).
// It goes to ZE 17, whose other neighborhoods (Alto do Cabrito, Itacaranha,
// Plataforma, Praia Grande, Rio Sena, São João do Cabrito) wrap it; ZE 4
// keeps Paripe, Coutos and the islands.
PERIPERI: 'salvador-ze-17',
}
const REPORT_ONLY = process.argv.includes('--report')
const die = (message) => {
console.error(`\n[build:municipality-zone-geometries] ${message}\n`)
process.exit(1)
}
const log = (message) => console.log(`[build:municipality-zone-geometries] ${message}`)
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const cacheDir = () => process.env.GEOMETRIES_CACHE_DIR || join(ROOT, 'data', 'geometries')
const ensureCachedBinary = async ({ key, url, ext }) => {
const dir = cacheDir()
await mkdir(dir, { recursive: true })
const path = join(dir, `${key}.${ext}`)
try {
await access(path)
log(`cache hit ${path}`)
const buffer = await readFile(path)
return { url, hash: sha256(buffer), buffer }
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
log(`downloading ${url}`)
const buffer = await downloadToBuffer(url)
const hash = sha256(buffer)
await writeFile(path, buffer)
log(`saved ${path} (${buffer.length} bytes, sha256=${hash})`)
return { url, hash, buffer }
}
const writeJson = async (relativePath, value) => {
const path = join(ROOT, relativePath)
await mkdir(dirname(path), { recursive: true })
const body = `${JSON.stringify(value)}\n`
await writeFile(path, body)
log(`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)
}
/** Accent-folded, punctuation-free key; parentheses (the TRE's split notes) drop. */
const normalizeNeighborhood = (name) =>
name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/\(.*?\)/g, ' ')
.replace(/[^A-Z0-9]+/g, ' ')
.trim()
const polygonCount = (geometry) =>
geometry?.type === 'MultiPolygon' ? geometry.coordinates.length : geometry ? 1 : 0
const arcIndexes = (arcs, into = new Set()) => {
for (const item of arcs) {
if (Array.isArray(item)) arcIndexes(item, into)
else into.add(item < 0 ? -1 - item : item)
}
return into
}
const arcLength = (arc) => {
let total = 0
for (let index = 1; index < arc.length; index += 1) {
const [previousX, previousY] = arc[index - 1]
const [x, y] = arc[index]
total += Math.hypot(x - previousX, y - previousY)
}
return total
}
/**
* Places a polygon RA 02/2017 never names in the zone it shares the most
* boundary with. Validated against the neighborhoods the resolution DOES name
* under a different label (Beiru/Tancredo Neves, CAB, Nordeste de Amaralina,
* Vila Ruy Barbosa): the rule reproduces the official zone in all four.
*/
const rankZonesBySharedBoundary = (topo, geometry, zoneSlugByGeometry) => {
const ownArcs = arcIndexes(geometry.arcs)
const lengthByZone = new Map()
for (const other of topo.objects.neighborhoods.geometries) {
const zone = zoneSlugByGeometry.get(other)
if (!zone || other === geometry) continue
const otherArcs = arcIndexes(other.arcs)
let shared = 0
for (const arc of ownArcs) if (otherArcs.has(arc)) shared += arcLength(topo.arcs[arc])
if (shared > 0) lengthByZone.set(zone, (lengthByZone.get(zone) ?? 0) + shared)
}
return [...lengthByZone.entries()].sort((left, right) => right[1] - left[1])
}
const main = async () => {
const zoneEntries = municipalityCatalog.filter((entry) => entry.kind === 'zona')
if (zoneEntries.length === 0) die('No zone municipalities in the catalog.')
const zoneIbgeCodes = new Set(zoneEntries.map((entry) => entry.ibgeCode))
// TRE neighborhood → zone slug. A name in two zones is a documented split.
const zoneSlugByNeighborhood = new Map()
const splitNeighborhoods = new Set()
for (const record of municipalityZoneNeighborhoods) {
for (const neighborhood of record.neighborhoods) {
const key = normalizeNeighborhood(neighborhood)
const previous = zoneSlugByNeighborhood.get(key)
if (previous && previous !== record.municipalitySlug) {
const resolved = SPLIT_NEIGHBORHOOD_ZONES[key]
if (!resolved) {
die(
`"${neighborhood}" is listed in ${previous} and ${record.municipalitySlug}; add it to SPLIT_NEIGHBORHOOD_ZONES.`,
)
}
splitNeighborhoods.add(key)
zoneSlugByNeighborhood.set(key, resolved)
continue
}
if (!previous) zoneSlugByNeighborhood.set(key, record.municipalitySlug)
}
}
const aliasTargets = new Map()
for (const [ibgeName, treName] of Object.entries(IBGE_TO_TRE_NEIGHBORHOOD)) {
const treKey = normalizeNeighborhood(treName)
if (!zoneSlugByNeighborhood.has(treKey)) {
die(`IBGE_TO_TRE_NEIGHBORHOOD maps "${ibgeName}" to unknown TRE neighborhood "${treName}".`)
}
aliasTargets.set(normalizeNeighborhood(ibgeName), treKey)
}
for (const [treName, hostName] of Object.entries(TRE_NEIGHBORHOODS_MERGED_INTO)) {
const treZone = zoneSlugByNeighborhood.get(normalizeNeighborhood(treName))
const hostZone = zoneSlugByNeighborhood.get(normalizeNeighborhood(hostName))
if (!treZone) die(`TRE_NEIGHBORHOODS_MERGED_INTO has unknown neighborhood "${treName}".`)
if (!hostZone) die(`TRE_NEIGHBORHOODS_MERGED_INTO has unknown host "${hostName}".`)
if (treZone !== hostZone) {
die(
`Merging "${treName}" (${treZone}) into "${hostName}" (${hostZone}) would move territory across zones.`,
)
}
}
const { hash, buffer } = await ensureCachedBinary(SOURCE)
const features = [await shp(buffer)]
.flat()
.flatMap((collection) => collection.features)
.filter((feature) => zoneIbgeCodes.has(feature.properties?.CD_MUN))
if (features.length === 0) die('No neighborhood polygons found for the zone municipalities.')
log(`${features.length} neighborhood polygons in the zone municipalities (sha256=${hash})`)
// Topology before anything else: the dissolve needs shared arcs to drop
// interior boundaries instead of leaving slivers, adjacency is read off those
// same arcs, and simplification then moves both sides of an arc identically.
const neighborhoodTopology = topology({
neighborhoods: {
type: 'FeatureCollection',
features: features.map((feature) => ({
type: 'Feature',
properties: { name: feature.properties?.NM_BAIRRO ?? '' },
geometry: feature.geometry,
})),
},
})
const geometries = neighborhoodTopology.objects.neighborhoods.geometries
// Pass 1 — the resolution's own names.
const zoneSlugByGeometry = new Map()
const unnamed = []
for (const geometry of geometries) {
const key = normalizeNeighborhood(geometry.properties.name)
const slug = zoneSlugByNeighborhood.get(aliasTargets.get(key) ?? key)
if (slug) zoneSlugByGeometry.set(geometry, slug)
else unnamed.push(geometry)
}
// Pass 2 — polygons RA 02/2017 never names, by shared boundary.
const orphans = []
for (const geometry of unnamed) {
const ranked = rankZonesBySharedBoundary(neighborhoodTopology, geometry, zoneSlugByGeometry)
const [winner, runnerUp] = ranked
if (!winner) {
orphans.push(geometry.properties.name)
continue
}
zoneSlugByGeometry.set(geometry, winner[0])
const margin = runnerUp
? `+${(((winner[1] - runnerUp[1]) / runnerUp[1]) * 100).toFixed(0)}%`
: 'only neighbour'
log(
`not in RA 02/2017 — "${geometry.properties.name}" → ${winner[0]} by shared boundary (${margin}${
runnerUp ? ` over ${runnerUp[0]}` : ''
})`,
)
}
const coveredKeys = new Set(
geometries.map((geometry) => {
const key = normalizeNeighborhood(geometry.properties.name)
return aliasTargets.get(key) ?? key
}),
)
for (const hostName of Object.values(TRE_NEIGHBORHOODS_MERGED_INTO)) {
if (!coveredKeys.has(normalizeNeighborhood(hostName))) continue
for (const [treName, host] of Object.entries(TRE_NEIGHBORHOODS_MERGED_INTO)) {
if (host === hostName) coveredKeys.add(normalizeNeighborhood(treName))
}
}
const neighborhoodsWithoutPolygon = municipalityZoneNeighborhoods.flatMap((record) =>
record.neighborhoods
.filter((neighborhood) => !coveredKeys.has(normalizeNeighborhood(neighborhood)))
.map((neighborhood) => `${record.municipalitySlug}: ${neighborhood}`),
)
const geometriesByZone = new Map()
for (const [geometry, slug] of zoneSlugByGeometry) {
geometriesByZone.set(slug, [...(geometriesByZone.get(slug) ?? []), geometry])
}
log(
`assigned ${zoneSlugByGeometry.size}/${geometries.length} polygons to ${geometriesByZone.size}/${zoneEntries.length} zones`,
)
if (splitNeighborhoods.size > 0) {
log(
`split by RA 02/2017, kept whole: ${[...splitNeighborhoods]
.map((key) => `${key} → ${SPLIT_NEIGHBORHOOD_ZONES[key]}`)
.join(', ')}`,
)
}
if (orphans.length > 0) {
log(`polygons with no zone (${orphans.length}):`)
for (const name of [...orphans].sort()) console.log(` - ${name}`)
}
if (neighborhoodsWithoutPolygon.length > 0) {
log(`TRE neighborhoods with no polygon (${neighborhoodsWithoutPolygon.length}):`)
for (const entry of neighborhoodsWithoutPolygon.sort()) console.log(` - ${entry}`)
}
if (orphans.length > 0 || neighborhoodsWithoutPolygon.length > 0) {
die(
'Reconciliation is incomplete — extend IBGE_TO_TRE_NEIGHBORHOOD / TRE_NEIGHBORHOODS_MERGED_INTO until every polygon has a zone and every neighborhood has a polygon.',
)
}
if (REPORT_ONLY) {
log('--report: reconciliation complete, artifact not written.')
return
}
// Simplification keeps geometry order and properties, so the zone buckets
// above still address the right shapes.
const simplified = simplifyTopology(neighborhoodTopology)
const simplifiedByName = new Map(
simplified.objects.neighborhoods.geometries.map((geometry) => [
geometry.properties.name,
geometry,
]),
)
const zoneFeatures = zoneEntries.map((entry) => {
const members = (geometriesByZone.get(entry.slug) ?? []).map((geometry) =>
simplifiedByName.get(geometry.properties.name),
)
if (members.length === 0 || members.some((member) => !member)) {
die(`Missing neighborhood geometry for ${entry.slug}.`)
}
const geometry = merge(simplified, members)
if (!geometry || geometry.coordinates.length === 0) {
die(`Dissolve produced an empty polygon for ${entry.slug}.`)
}
return {
type: 'Feature',
properties: {
municipalitySlug: entry.slug,
name: entry.name,
zoneNumber: entry.zoneNumber,
ibgeCode: entry.ibgeCode,
},
geometry,
}
})
// Already simplified above — the final pass only quantizes, so the artifact
// stores short integers instead of the floats `merge` hands back.
await writeJson(
OUTPUT_PATH,
quantize(
topology({ municipalityZones: { type: 'FeatureCollection', features: zoneFeatures } }),
QUANTIZE_DIGITS,
),
)
for (const feature of zoneFeatures) {
const pieces = polygonCount(feature.geometry)
log(
`${feature.properties.municipalitySlug}: ${
geometriesByZone.get(feature.properties.municipalitySlug).length
} neighborhoods → ${pieces} ${pieces === 1 ? 'piece' : 'pieces'}`,
)
}
log(`done — ${zoneFeatures.length} zone polygons.`)
}
await main()

View file

@ -9,17 +9,16 @@ import 'leaflet/dist/leaflet.css'
import { Spinner } from '@/components/ui/Spinner'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
type MunicipalityGeometryModule,
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes'
import {
bubbleRadius,
buildHighlightSet,
buildLayerStyleContext,
canonicalMapKeysKey,
featureKeyFromProperties,
keyPropertyForMode,
featureMapKey,
resolvePathStyle,
type BahiaMapFillMode,
type ChoroplethFills,
@ -64,6 +63,32 @@ const applyBahiaMinZoom = (map: L.Map) => {
export type BahiaMapMode = 'municipality' | 'territory'
/**
* The municipality layer is two meshes: the 417 municípios plus the 19 Salvador
* zone polygons on top (insertion order is z-order in the SVG). The zone city's
* own polygon stays underneath as a BASE — no value, no pointer — so a sliver of
* disagreement between two independently simplified meshes cannot punch a hole
* in the state's outline.
*/
const loadLayerFeatures = async (
mode: BahiaMapMode,
): Promise<{ features: BahiaGeometryFeature[]; baseKeys: Set<string> }> => {
if (mode === 'territory') {
const { features } = await loadTerritoryGeometryModule()
return { features: [...features], baseKeys: new Set() }
}
const [municipalities, zones] = await Promise.all([
loadMunicipalityGeometryModule(),
loadMunicipalityZoneGeometryModule(),
])
return {
features: [...municipalities.features, ...zones.features],
baseKeys: new Set(zones.features.map((zone) => zone.properties.ibgeCode)),
}
}
export type BahiaMapFeatureInfo = {
key: string
name: string
@ -106,37 +131,28 @@ type BahiaMapProps = {
const getPointerType = (event: L.LeafletMouseEvent, fallback = 'mouse') =>
event.originalEvent instanceof PointerEvent ? event.originalEvent.pointerType : fallback
/**
* Fits from the drawn paths rather than the geometry modules: since B8 F2 a
* municipality layer is assembled from two meshes, and the paths are the one
* index that already knows which key came from which.
*/
const fitMapToHighlights = (
map: L.Map,
mode: BahiaMapMode,
highlightSet: Set<string>,
geometryModule: MunicipalityGeometryModule | TerritoryGeometryModule,
pathByKey: Map<string, L.Path>,
) => {
if (highlightSet.size === 0) {
const highlightBounds = L.latLngBounds([])
for (const key of highlightSet) {
const path = pathByKey.get(key)
if (path instanceof L.Polygon) highlightBounds.extend(path.getBounds())
}
if (!highlightBounds.isValid()) {
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
return
}
const highlightedFeatures =
mode === 'municipality'
? [...highlightSet].flatMap((key) => {
const feature = (geometryModule as MunicipalityGeometryModule).getMunicipalityFeature(key)
return feature ? [feature] : []
})
: [...highlightSet].flatMap((key) => {
const feature = (geometryModule as TerritoryGeometryModule).getTerritoryFeature(key)
return feature ? [feature] : []
})
if (highlightedFeatures.length === 0) {
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
return
}
const highlightBounds = L.geoJSON(highlightedFeatures).getBounds()
if (highlightBounds.isValid()) {
map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 })
}
map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 })
}
export const BahiaMap = ({
@ -160,9 +176,6 @@ export const BahiaMap = ({
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(
null,
)
const pathByKeyRef = useRef<Map<string, L.Path>>(new Map())
const styleContextRef = useRef<LayerStyleContext | null>(null)
const geometryReadyRef = useRef(false)
@ -299,7 +312,6 @@ export const BahiaMap = ({
map.remove()
mapRef.current = null
layerRef.current = null
geometryModuleRef.current = null
pathByKeyRef.current = new Map()
styleContextRef.current = null
geometryReadyRef.current = false
@ -317,19 +329,13 @@ export const BahiaMap = ({
const renderLayer = async () => {
try {
const geometryModule =
mode === 'municipality'
? await loadMunicipalityGeometryModule()
: await loadTerritoryGeometryModule()
const { features, baseKeys } = await loadLayerFeatures(mode)
if (cancelled) return
layerRef.current?.remove()
pathByKeyRef.current = new Map()
geometryModuleRef.current = geometryModule
const keyProperty = keyPropertyForMode(mode)
const syncMountStyleContext = () => {
styleContextRef.current = buildLayerStyleContext({
...stylePropsRef.current,
@ -340,21 +346,32 @@ export const BahiaMap = ({
syncMountStyleContext()
const mountContext = styleContextRef.current!
const layer = L.geoJSON([...geometryModule.features], {
/**
* The key this feature answers for, or `undefined` when it is a base
* polygon: those carry no value, take no pointer, and must not answer
* for a key the data has moved off of.
*/
const paintedKey = (properties: Feature['properties'] | undefined) => {
const key = featureMapKey(properties)
return key && !baseKeys.has(key) ? key : undefined
}
const layer = L.geoJSON(features, {
style: (feature?: Feature) => {
const properties = feature?.properties as Record<string, string> | undefined
const key = featureKeyFromProperties(properties, keyProperty)
const context = styleContextRef.current ?? mountContext
return resolvePathStyleForFeature(context, key ?? '')
const key = paintedKey(feature?.properties)
return key
? resolvePathStyleForFeature(context, key)
: { ...resolvePathStyle(context, ''), interactive: false }
},
onEachFeature: (feature, featureLayer) => {
if (!(featureLayer instanceof L.Path)) return
const properties = feature.properties as Record<string, string> | undefined
const key = featureKeyFromProperties(properties, keyProperty)
const key = paintedKey(feature.properties)
if (!key) return
const name = properties?.name ?? key
const name =
typeof feature.properties?.name === 'string' ? feature.properties.name : key
pathByKeyRef.current.set(key, featureLayer)
featureLayer.on('mouseover', (event) => {
@ -403,7 +420,7 @@ export const BahiaMap = ({
restyleAllPaths()
const fitIdentity = `${mode}:${viewportKey}`
fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule)
fitMapToHighlights(map, buildHighlightSet(viewportKey), pathByKeyRef.current)
lastFittedViewportKeyRef.current = fitIdentity
geometryReadyRef.current = true
@ -420,7 +437,6 @@ export const BahiaMap = ({
layerRef.current?.remove()
layerRef.current = null
pathByKeyRef.current = new Map()
geometryModuleRef.current = null
geometryReadyRef.current = false
}
// Geometry layer rebuilds only when `mode` changes; metric/hover updates use separate effects.
@ -434,10 +450,9 @@ export const BahiaMap = ({
if (lastFittedViewportKeyRef.current === fitIdentity) return
const map = mapRef.current
const geometryModule = geometryModuleRef.current
if (!map || !geometryModule || !layerRef.current) return
if (!map || !layerRef.current) return
fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule)
fitMapToHighlights(map, buildHighlightSet(viewportKey), pathByKeyRef.current)
lastFittedViewportKeyRef.current = fitIdentity
}, [viewportKey, mode])

View file

@ -103,11 +103,6 @@ export const MapFeatureReadout = ({
{/* The bubble's own line: mixing "votes he got" and "votes at stake"
into one sentence is how a reader ends up quoting the wrong one. */}
{bubbleReading ? <p className="text-xs text-muted-foreground">{bubbleReading}</p> : null}
{navigation?.kind === 'zones' ? (
<p className="text-xs text-muted-foreground">
Toque de novo no mapa ou role até os Municípios por zona abaixo.
</p>
) : null}
</div>
{navigation?.kind === 'navigate' ? (

View file

@ -202,20 +202,20 @@ export const MunicipalityMapPanel = ({
}, [comparisonActive, displayValues, percentScaleActive])
const scopedKeys = useMemo(
() => Object.keys(bundle.municipalitiesByIbgeCode),
[bundle.municipalitiesByIbgeCode],
() => Object.keys(bundle.municipalitiesByMapKey),
[bundle.municipalitiesByMapKey],
)
/** LQ per município against his statewide standard — the same ratio E10 classes on. */
const lqByCode = useMemo(() => {
const lqByMapKey = useMemo(() => {
const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0
if (statewideShare <= 0) return {}
const lq: Record<string, number> = {}
for (const [code, votes] of Object.entries(rawValues)) {
const validVotes = validVotesForYear[code] ?? 0
for (const [mapKey, votes] of Object.entries(rawValues)) {
const validVotes = validVotesForYear[mapKey] ?? 0
if (votes <= 0 || validVotes <= 0) continue
lq[code] = votes / validVotes / statewideShare
lq[mapKey] = votes / validVotes / statewideShare
}
return lq
}, [bundle.statewideShareByYear, rawValues, validVotesForYear, year])
@ -230,12 +230,12 @@ export const MunicipalityMapPanel = ({
case 'quantile':
return buildQuantileClassing(rawValues)
case 'lq':
return buildLqClassing(lqByCode)
return buildLqClassing(lqByMapKey)
case 'competitiveRank':
return buildCompetitiveRankClassing(
Object.fromEntries(
Object.entries(competitiveRankForYear).map(([code, placement]) => [
code,
Object.entries(competitiveRankForYear).map(([mapKey, placement]) => [
mapKey,
placement.rank,
]),
),
@ -243,7 +243,7 @@ export const MunicipalityMapPanel = ({
default:
return null
}
}, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues])
}, [competitiveRankForYear, effectiveScaleMode, lqByMapKey, rawValues])
// A classing with no classes paints nothing, so the legend must fall through
// to the continuous ramp — passing `{}` here would tell `BahiaMap` the map is
@ -320,16 +320,16 @@ export const MunicipalityMapPanel = ({
*
* Both sides are already stable references, so there is nothing to memoize.
*/
const bubbleValues = showBubbles ? bundle.projectedValidVotesByCode : undefined
const bubbleValues = showBubbles ? bundle.projectedValidVotesByMapKey : undefined
const bubbleFillByKey = useMemo(() => {
if (!showBubbles) return undefined
const fills: ChoroplethFills = {}
for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) {
fills[code] = territorialClassMapFill[territorialClass]
for (const [mapKey, territorialClass] of Object.entries(bundle.territorialClassByMapKey)) {
fills[mapKey] = territorialClassMapFill[territorialClass]
}
return fills
}, [bundle.territorialClassByCode, showBubbles])
}, [bundle.territorialClassByMapKey, showBubbles])
// The key's reference circles and the map's radius denominator have to be
// the same number, or the key labels a size the map never draws.
@ -339,14 +339,14 @@ export const MunicipalityMapPanel = ({
(key: string): string | null => {
if (!showBubbles) return null
const projected = bundle.projectedValidVotesByCode[key] ?? 0
const projected = bundle.projectedValidVotesByMapKey[key] ?? 0
if (projected <= 0) return null
const territorialClass = bundle.territorialClassByCode[key]
const territorialClass = bundle.territorialClassByMapKey[key]
const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null
return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}`
},
[bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles],
[bundle.projectedValidVotesByMapKey, bundle.territorialClassByMapKey, showBubbles],
)
/**
@ -366,7 +366,7 @@ export const MunicipalityMapPanel = ({
return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}`
}
case 'lq': {
const lq = lqByCode[key]
const lq = lqByMapKey[key]
return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq)
}
case 'competitiveRank': {
@ -378,7 +378,7 @@ export const MunicipalityMapPanel = ({
return null
}
},
[classing, competitiveRankForYear, effectiveScaleMode, lqByCode],
[classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey],
)
// Optimistic on the control, honest pending on the map: the comparison data
@ -582,7 +582,7 @@ export const MunicipalityMapPanel = ({
relativeReadingFor={relativeReadingFor}
bubbleReadingFor={bubbleReadingFor}
comparisonActive={comparisonActive}
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
municipalitiesByMapKey={bundle.municipalitiesByMapKey}
ariaLabel={
comparisonActive && comparison
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
@ -592,11 +592,12 @@ export const MunicipalityMapPanel = ({
{/* zone breakdown below */}
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Municípios por zona eleitoral</h3>
<p className="text-sm text-muted-foreground">
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
detalhadas aqui.
Salvador é pintada zona por zona. O desenho vem dos bairros de cada circunscrição
(TRE-BA, RA nº 2/2017) sobre a malha do IBGE — é aproximado, não é o limite oficial do
TSE.
</p>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
@ -645,7 +646,7 @@ const MunicipalityMapSelection = ({
relativeReadingFor,
bubbleReadingFor,
comparisonActive,
municipalitiesByIbgeCode,
municipalitiesByMapKey,
ariaLabel,
}: {
displayValues: ChoroplethValues
@ -660,7 +661,7 @@ const MunicipalityMapSelection = ({
relativeReadingFor: (key: string) => string | null
bubbleReadingFor: (key: string) => string | null
comparisonActive: boolean
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
municipalitiesByMapKey: MunicipalityMapBundle['municipalitiesByMapKey']
ariaLabel: string
}) => {
const router = useRouter()
@ -676,20 +677,12 @@ const MunicipalityMapSelection = ({
(key: string) => {
if (selectedKeyRef.current !== key) return
const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByIbgeCode)
const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByMapKey)
if (navigation.kind === 'navigate') {
router.push(`/campanha/municipios/${navigation.slug}`)
return
}
if (navigation.kind === 'zones') {
document.getElementById('municipality-zone-breakdown')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
},
[municipalitiesByIbgeCode, router],
[municipalitiesByMapKey, router],
)
const selectedMetricValue =
@ -703,9 +696,9 @@ const MunicipalityMapSelection = ({
const selectedNavigation = useMemo(
() =>
selectedFeature
? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByIbgeCode)
? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByMapKey)
: null,
[municipalitiesByIbgeCode, selectedFeature],
[municipalitiesByMapKey, selectedFeature],
)
return (

View file

@ -1,21 +1,25 @@
/**
* Lazy loaders for Bahia map geometries (B3 / B5 F1).
* Lazy loaders for Bahia map geometries (B3 / B5 F1 / B8 F2).
*
* TopoJSON decode runs only when a map surface mounts — not on the default `/campanha` graph.
* Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`.
* Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`,
* `bahiaMunicipalityZoneGeometries.ts`.
*/
export type {
MunicipalityGeometryModule,
MunicipalityZoneGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
import type {
MunicipalityGeometryModule,
MunicipalityZoneGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
let municipalityModulePromise: Promise<MunicipalityGeometryModule> | null = null
let municipalityZoneModulePromise: Promise<MunicipalityZoneGeometryModule> | null = null
let territoryModulePromise: Promise<TerritoryGeometryModule> | null = null
export const loadMunicipalityGeometryModule = (): Promise<MunicipalityGeometryModule> => {
@ -25,6 +29,13 @@ export const loadMunicipalityGeometryModule = (): Promise<MunicipalityGeometryMo
return municipalityModulePromise
}
export const loadMunicipalityZoneGeometryModule = (): Promise<MunicipalityZoneGeometryModule> => {
municipalityZoneModulePromise ??= import('@/lib/bahiaMunicipalityZoneGeometries').then(
(module) => module.municipalityZoneGeometryModule,
)
return municipalityZoneModulePromise
}
export const loadTerritoryGeometryModule = (): Promise<TerritoryGeometryModule> => {
territoryModulePromise ??= import('@/lib/bahiaTerritoryGeometries').then(
(module) => module.territoryGeometryModule,

View file

@ -1,4 +1,4 @@
import type { Feature, MultiPolygon, Polygon } from 'geojson'
import type { Feature, GeoJsonProperties, MultiPolygon, Polygon } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
type BahiaMunicipalityProperties = {
@ -11,10 +11,26 @@ type BahiaTerritoryProperties = {
name: string
}
/**
* Zone municipalities (Salvador ZE 1–19): keyed by the catalog slug, because a
* zone has no IBGE code of its own — the whole city shares `ibgeCode`.
*/
type MunicipalityZoneProperties = {
municipalitySlug: string
name: string
zoneNumber: number
ibgeCode: string
}
export type BahiaMunicipalityFeature = Feature<Polygon | MultiPolygon, BahiaMunicipalityProperties>
export type BahiaTerritoryFeature = Feature<Polygon | MultiPolygon, BahiaTerritoryProperties>
export type MunicipalityZoneFeature = Feature<Polygon | MultiPolygon, MunicipalityZoneProperties>
/** Any feature of the committed meshes — for helpers that only read coordinates. */
export type BahiaGeometryFeature = Feature<Polygon | MultiPolygon, GeoJsonProperties>
export type MunicipalityTopology = Topology<{
municipalities: GeometryCollection<BahiaMunicipalityProperties>
}>
@ -23,6 +39,10 @@ export type TerritoryTopology = Topology<{
territories: GeometryCollection<BahiaTerritoryProperties>
}>
export type MunicipalityZoneTopology = Topology<{
municipalityZones: GeometryCollection<MunicipalityZoneProperties>
}>
export type MunicipalityGeometryModule = {
topology: MunicipalityTopology
features: readonly BahiaMunicipalityFeature[]
@ -34,3 +54,9 @@ export type TerritoryGeometryModule = {
features: readonly BahiaTerritoryFeature[]
getTerritoryFeature: (code: string) => BahiaTerritoryFeature | undefined
}
export type MunicipalityZoneGeometryModule = {
topology: MunicipalityZoneTopology
features: readonly MunicipalityZoneFeature[]
getMunicipalityZoneFeature: (municipalitySlug: string) => MunicipalityZoneFeature | undefined
}

View file

@ -12,7 +12,8 @@ export type ChoroplethValues = Record<string, number>
export type BahiaMapFillMode = 'sequential' | 'diverging'
export type FeatureKeyProperty = 'codarea' | 'code'
/** Properties that carry a map key, most specific first (see `featureMapKey`). */
const MAP_KEY_PROPERTIES = ['municipalitySlug', 'codarea', 'code'] as const
/**
* Feature key → fill, for the discrete relative scales (B13). When present it
@ -91,13 +92,22 @@ export const buildHighlightSet = (highlightKey: string): Set<string> =>
export const canonicalMapKeysKey = (keys: string[]): string =>
keys.length > 0 ? [...keys].sort().join(',') : ''
export const keyPropertyForMode = (mode: 'municipality' | 'territory'): FeatureKeyProperty =>
mode === 'municipality' ? 'codarea' : 'code'
export const featureKeyFromProperties = (
properties: Record<string, string> | undefined,
keyProperty: FeatureKeyProperty,
): string | undefined => properties?.[keyProperty]
/**
* The key a feature is painted and addressed by, whichever mesh it came from:
* a zone municipality carries `municipalitySlug` (B8 F2 — the whole city shares
* one codarea, so the code cannot identify it), a município `codarea`, an
* identity territory `code`. The three never coexist on one feature, so no mode
* argument is needed.
*/
export const featureMapKey = (
properties: Record<string, unknown> | undefined | null,
): string | undefined => {
for (const property of MAP_KEY_PROPERTIES) {
const value = properties?.[property]
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => {
const classed = context.fillByKey !== undefined

View file

@ -0,0 +1,23 @@
import { feature } from 'topojson-client'
import type {
MunicipalityZoneFeature,
MunicipalityZoneGeometryModule,
MunicipalityZoneTopology,
} from '@/lib/bahiaGeometriesTypes'
import municipalityZoneTopologyJson from '@/lib/geometries/bahia-municipality-zones.topo.json'
const topology = municipalityZoneTopologyJson as unknown as MunicipalityZoneTopology
const municipalityZoneFeatures = feature(topology, topology.objects.municipalityZones)
.features as MunicipalityZoneFeature[]
const zoneBySlug = new Map(
municipalityZoneFeatures.map((entry) => [entry.properties.municipalitySlug, entry]),
)
export const municipalityZoneGeometryModule: MunicipalityZoneGeometryModule = {
topology,
features: municipalityZoneFeatures,
getMunicipalityZoneFeature: (municipalitySlug: string) => zoneBySlug.get(municipalitySlug),
}

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,7 @@
import type { Position } from 'geojson'
import type {
BahiaGeometryFeature,
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
@ -105,7 +106,7 @@ const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => {
return inside
}
const polygonRingsOf = (feature: BahiaMunicipalityFeature): readonly Position[][][] =>
const polygonRingsOf = (feature: BahiaGeometryFeature): readonly Position[][][] =>
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
@ -114,7 +115,7 @@ const polygonRingsOf = (feature: BahiaMunicipalityFeature): readonly Position[][
* Holes and MultiPolygons do not occur in the committed mesh (417 plain
* Polygons), but a rebuild at finer quality would introduce them.
*/
export const featureContainsPoint = (feature: BahiaMunicipalityFeature, point: GeoPoint): boolean =>
export const featureContainsPoint = (feature: BahiaGeometryFeature, point: GeoPoint): boolean =>
polygonRingsOf(feature).some(([outerRing, ...holes]) => {
if (!outerRing || !isPointInRing(point, outerRing)) return false
return !holes.some((hole) => isPointInRing(point, hole))
@ -125,7 +126,7 @@ export const featureContainsPoint = (feature: BahiaMunicipalityFeature, point: G
* at Bahia's latitudes is far below the precision this feeds). Holes are
* ignored: they move the centre by less than the rounding of the label.
*/
export const featureCentroid = (feature: BahiaMunicipalityFeature): GeoPoint => {
export const featureCentroid = (feature: BahiaGeometryFeature): GeoPoint => {
let weightedLng = 0
let weightedLat = 0
let totalArea = 0

View file

@ -6,6 +6,10 @@
* https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017
* (accessed 2026-07-21)
*
* Rows author the slug and the neighborhood list only: `city` and `zoneNumber`
* are hydrated from `municipalityCatalog`, which is also what makes an unknown
* or non-zona slug fail at import instead of drifting silently.
*
* Coverage is independently checked by tests/fixtures/municipality-zone-neighborhoods.official.json.
*/
@ -13,11 +17,9 @@ import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
export type MunicipalityZoneNeighborhoodSource = 'tre-ra-02-2017'
type MunicipalityZoneNeighborhoodCity = 'Salvador'
export type MunicipalityZoneNeighborhoodEntry = {
readonly municipalitySlug: string
readonly city: MunicipalityZoneNeighborhoodCity
readonly city: string
readonly zoneNumber: number
readonly source: MunicipalityZoneNeighborhoodSource
readonly neighborhoods: readonly string[]
@ -28,20 +30,25 @@ const sortNeighborhoods = (names: readonly string[]): readonly string[] =>
const entry = (
municipalitySlug: string,
city: MunicipalityZoneNeighborhoodCity,
zoneNumber: number,
source: MunicipalityZoneNeighborhoodSource,
neighborhoods: readonly string[],
): MunicipalityZoneNeighborhoodEntry => ({
municipalitySlug,
city,
zoneNumber,
source,
neighborhoods: sortNeighborhoods(neighborhoods),
})
): MunicipalityZoneNeighborhoodEntry => {
const catalogEntry = getMunicipalityCatalogEntry(municipalitySlug)
if (!catalogEntry || catalogEntry.kind !== 'zona' || catalogEntry.zoneNumber === undefined) {
throw new Error(`Missing zona municipality catalog entry for slug: ${municipalitySlug}`)
}
return {
municipalitySlug,
city: catalogEntry.city,
zoneNumber: catalogEntry.zoneNumber,
source,
neighborhoods: sortNeighborhoods(neighborhoods),
}
}
export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoodEntry[] = [
entry('salvador-ze-1', 'Salvador', 1, 'tre-ra-02-2017', [
entry('salvador-ze-1', 'tre-ra-02-2017', [
'Alto das Pombas',
'Barra',
'Calabar',
@ -52,7 +59,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Graça',
'Vitória',
]),
entry('salvador-ze-2', 'Salvador', 2, 'tre-ra-02-2017', [
entry('salvador-ze-2', 'tre-ra-02-2017', [
'Amaralina',
'Chapada do Rio Vermelho',
'Engenho Velho da Federação',
@ -60,7 +67,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Ondina',
'Rio Vermelho',
]),
entry('salvador-ze-3', 'Salvador', 3, 'tre-ra-02-2017', [
entry('salvador-ze-3', 'tre-ra-02-2017', [
'Baixa de Quintas',
'Cidade Nova',
'Fazenda Grande do Retiro',
@ -69,7 +76,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Pero Vaz',
'Santa Mônica',
]),
entry('salvador-ze-4', 'Salvador', 4, 'tre-ra-02-2017', [
entry('salvador-ze-4', 'tre-ra-02-2017', [
'Coutos',
'Fazenda Coutos',
'Ilha de Bom Jesus dos Passos',
@ -80,7 +87,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Periperi (parte ao norte da Rua das Pedrinhas)',
'São Tomé',
]),
entry('salvador-ze-5', 'Salvador', 5, 'tre-ra-02-2017', [
entry('salvador-ze-5', 'tre-ra-02-2017', [
'Barreiras',
'Cabula',
'Engomadeira',
@ -89,14 +96,14 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Retiro',
'São Gonçalo',
]),
entry('salvador-ze-6', 'Salvador', 6, 'tre-ra-02-2017', [
entry('salvador-ze-6', 'tre-ra-02-2017', [
'Acupe',
'Boa Vista de Brotas',
'Brotas',
'Candeal',
'Engenho Velho de Brotas',
]),
entry('salvador-ze-7', 'Salvador', 7, 'tre-ra-02-2017', [
entry('salvador-ze-7', 'tre-ra-02-2017', [
'Barbalho',
'Barris',
'Comércio',
@ -111,7 +118,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Tororó',
'Vila Laura',
]),
entry('salvador-ze-8', 'Salvador', 8, 'tre-ra-02-2017', [
entry('salvador-ze-8', 'tre-ra-02-2017', [
'Cajazeiras II',
'Cajazeiras IV',
'Cajazeiras V',
@ -125,7 +132,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Pirajá',
'Valéria',
]),
entry('salvador-ze-9', 'Salvador', 9, 'tre-ra-02-2017', [
entry('salvador-ze-9', 'tre-ra-02-2017', [
'Boa Viagem',
'Bonfim',
'Jardim Cruzeiro',
@ -137,7 +144,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Uruguai',
'Vila Ruy Barbosa',
]),
entry('salvador-ze-10', 'Salvador', 10, 'tre-ra-02-2017', [
entry('salvador-ze-10', 'tre-ra-02-2017', [
'Bairro da Paz',
'Boca do Rio',
'Costa Azul',
@ -148,7 +155,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Pituaçu',
'STIEP',
]),
entry('salvador-ze-11', 'Salvador', 11, 'tre-ra-02-2017', [
entry('salvador-ze-11', 'tre-ra-02-2017', [
'Águas Claras',
'Calabetão',
'Castelo Branco',
@ -161,7 +168,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Sete de Abril',
'Vila Canária',
]),
entry('salvador-ze-12', 'Salvador', 12, 'tre-ra-02-2017', [
entry('salvador-ze-12', 'tre-ra-02-2017', [
'Aeroporto',
'Alto do Coqueirinho',
'Areia Branca',
@ -171,7 +178,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'São Cristóvão',
'Stella Maris',
]),
entry('salvador-ze-13', 'Salvador', 13, 'tre-ra-02-2017', [
entry('salvador-ze-13', 'tre-ra-02-2017', [
'Caminho das Árvores',
'Itaigara',
'Nordeste de Amaralina',
@ -179,14 +186,14 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Santa Cruz',
'Vale das Pedrinhas',
]),
entry('salvador-ze-14', 'Salvador', 14, 'tre-ra-02-2017', [
entry('salvador-ze-14', 'tre-ra-02-2017', [
'Arenoso',
'Novo Horizonte',
'São Marcos',
'Sussuarana',
'Tancredo Neves',
]),
entry('salvador-ze-15', 'Salvador', 15, 'tre-ra-02-2017', [
entry('salvador-ze-15', 'tre-ra-02-2017', [
'Arraial do Retiro',
'Boa Vista de São Caetano',
'Bom Juá',
@ -194,7 +201,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Lobato',
'São Caetano',
]),
entry('salvador-ze-16', 'Salvador', 16, 'tre-ra-02-2017', [
entry('salvador-ze-16', 'tre-ra-02-2017', [
'Cabula VI',
'Canabrava',
'Centro Administrativo da Bahia',
@ -207,7 +214,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Saramandaia',
'Vale dos Lagos',
]),
entry('salvador-ze-17', 'Salvador', 17, 'tre-ra-02-2017', [
entry('salvador-ze-17', 'tre-ra-02-2017', [
'Alto da Terezinha',
'Alto do Cabrito',
'Itacaranha',
@ -217,7 +224,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Rio Sena',
'São João do Cabrito',
]),
entry('salvador-ze-18', 'Salvador', 18, 'tre-ra-02-2017', [
entry('salvador-ze-18', 'tre-ra-02-2017', [
"Caixa D'água",
'Calçada',
'Caminho de Areia',
@ -227,7 +234,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Mares',
'Roma',
]),
entry('salvador-ze-19', 'Salvador', 19, 'tre-ra-02-2017', [
entry('salvador-ze-19', 'tre-ra-02-2017', [
'Boca da Mata',
'Cajazeiras X',
'Cajazeiras XI',
@ -264,19 +271,3 @@ export const municipalityZoneNeighborhoodSourceLabel = (
return source satisfies never
}
}
const assertMunicipalityZoneNeighborhoodCatalogIntegrity = (): void => {
for (const record of municipalityZoneNeighborhoods) {
const catalogEntry = getMunicipalityCatalogEntry(record.municipalitySlug)
if (!catalogEntry || catalogEntry.kind !== 'zona') {
throw new Error(
`Missing zona municipality catalog entry for slug: ${record.municipalitySlug}`,
)
}
if (catalogEntry.city !== record.city || catalogEntry.zoneNumber !== record.zoneNumber) {
throw new Error(`Catalog mismatch for slug: ${record.municipalitySlug}`)
}
}
}
assertMunicipalityZoneNeighborhoodCatalogIntegrity()

View file

@ -1,7 +1,7 @@
import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates'
import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import type { MunicipalitiesByMapKey } from '@/utilities/municipalityMapNavigation'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/**
@ -89,16 +89,22 @@ type MunicipalityZoneBreakdownRow = {
export type MunicipalityMapComparison = {
candidateNumber: number
candidateName: string
/** TSE year (as string) → codarea → (sollaVotes − otherVotes). */
/** TSE year (as string) → map key → (sollaVotes − otherVotes). */
diffByYear: Record<string, Record<string, number>>
}
/**
* Every record below is keyed by MAP KEY, not by IBGE codarea: since B8 F2 the
* map paints one polygon per catalog unit, so Salvador's 19 zones are addressed
* by slug (`salvador-ze-3`) and every other município by its codarea. See
* `mapKeyForMunicipality`.
*/
export type MunicipalityMapBundle = {
/** year (as string) → codarea → value. 2026 = cenário médio (central). */
/** year (as string) → map key → value. 2026 = cenário médio (central). */
valuesByYear: Record<string, Record<string, number>>
/** 2026 totals per estimate scenario (codarea → votes). */
/** 2026 totals per estimate scenario (map key → votes). */
values2026ByScenario: Record<VoteEstimateScenario, Record<string, number>>
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
/** year (as string) → map key → votosValidos (federal T1). 2026 reuses 2022. */
validVotesByYear: Record<string, Record<string, number>>
/**
* year (as string) → his own STATEWIDE share of valid votes: the "1×" the LQ
@ -107,14 +113,18 @@ export type MunicipalityMapBundle = {
* the same standard. 2026 reuses the 2022 standard, like `validVotesByYear`.
*/
statewideShareByYear: Record<string, number>
/** codarea → E10 territorial class, aggregated over the slugs in scope. */
territorialClassByCode: Record<string, MunicipalityTerritorialClass>
/** codarea → his placement among the federal-deputy candidates voted there. */
/** map key → E10 territorial class of that unit. */
territorialClassByMapKey: Record<string, MunicipalityTerritorialClass>
/**
* map key → his placement among the federal-deputy candidates voted there. The
* TSE artifact ranks by codarea only, so Salvador's zones all carry the city's
* position (the legend says so).
*/
competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>>
/** codarea → projected 2026 valid votes (E8) — the size of the proportional symbol. */
projectedValidVotesByCode: Record<string, number>
/** IBGE codarea → accessible municipality slugs for map click navigation. */
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode
/** map key → projected 2026 valid votes (E8) — the size of the proportional symbol. */
projectedValidVotesByMapKey: Record<string, number>
/** map key → the municipality it opens on click. */
municipalitiesByMapKey: MunicipalitiesByMapKey
/** Zone municipalities in scope (Salvador) with per-year values. */
zoneBreakdown: MunicipalityZoneBreakdownRow[]
candidateName: string

View file

@ -36,10 +36,13 @@ import type {
MunicipalityMapBundle,
MunicipalityMapComparison,
} from '@/utilities/municipalityMapContract'
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import {
buildMunicipalitiesByMapKey,
mapKeyForMunicipality,
} from '@/utilities/municipalityMapNavigation'
import { projectedValidVotes } from '@/utilities/municipalityPotential'
import {
computeAggregateTerritorialClass,
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClass,
} from '@/utilities/municipalityTerritorialClass'
import {
@ -104,9 +107,9 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const statewideShareByYear: Record<string, number> = {}
const competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>> = {}
const zoneVotesBySlug = new Map<string, Record<string, number>>()
// One polygon per IBGE code, N catalog slugs behind it (Salvador is 19) —
// the same index the map uses to navigate, reused here for the rollups.
const municipalitiesByIbgeCode = buildMunicipalitiesByIbgeCode(municipalities)
// One polygon per catalog unit since B8 F2, so this index — the same one the
// map navigates by — is also the key of every rollup below.
const municipalitiesByMapKey = buildMunicipalitiesByMapKey(municipalities)
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
@ -114,11 +117,11 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const values: Record<string, number> = {}
const validValues: Record<string, number> = {}
for (const municipality of municipalities) {
const mapKey = mapKeyForMunicipality(municipality)
const baseline = getMunicipalityFederalBaseline(municipality.slug)
const votes = baseline.votesByYear[String(year)] ?? 0
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + votes
const valid = baseline.validVotesByYear[String(year)] ?? 0
validValues[municipality.ibgeCode] = (validValues[municipality.ibgeCode] ?? 0) + valid
values[mapKey] = votes
validValues[mapKey] = baseline.validVotesByYear[String(year)] ?? 0
if (municipality.kind === 'zona') {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
bySlug[String(year)] = votes
@ -135,10 +138,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
statewideShareByYear[String(year)] =
statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0
// Rank exists per city only, so every zone of Salvador carries the city's
// position — stated in the legend rather than faked per zone.
const ranks: Record<string, FederalCompetitiveRank> = {}
for (const ibgeCode of Object.keys(municipalitiesByIbgeCode)) {
const rank = getFederalCompetitiveRank(ibgeCode, year)
if (rank) ranks[ibgeCode] = rank
for (const municipality of municipalities) {
const rank = getFederalCompetitiveRank(municipality.ibgeCode, year)
if (rank) ranks[mapKeyForMunicipality(municipality)] = rank
}
competitiveRankByYear[String(year)] = ranks
}
@ -148,14 +153,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
// substitution `validVotesByYear` already makes.
statewideShareByYear['2026'] = statewideShareByYear['2022'] ?? 0
const territorialClassByCode: Record<string, MunicipalityTerritorialClass> = {}
const projectedValidVotesByCode: Record<string, number> = {}
for (const [ibgeCode, entries] of Object.entries(municipalitiesByIbgeCode)) {
const slugs = entries.map((entry) => entry.slug)
territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class
projectedValidVotesByCode[ibgeCode] = slugs.reduce(
(total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)),
0,
const territorialClassByMapKey: Record<string, MunicipalityTerritorialClass> = {}
const projectedValidVotesByMapKey: Record<string, number> = {}
for (const [mapKey, entry] of Object.entries(municipalitiesByMapKey)) {
territorialClassByMapKey[mapKey] = computeMunicipalityTerritorialClass(entry.slug).class
projectedValidVotesByMapKey[mapKey] = projectedValidVotes(
getMunicipalityFederalBaseline(entry.slug),
)
}
@ -178,8 +181,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
scenario,
)
if (votes > 0) {
pledgeValuesByScenario[scenario][municipality.ibgeCode] =
(pledgeValuesByScenario[scenario][municipality.ibgeCode] ?? 0) + votes
pledgeValuesByScenario[scenario][mapKeyForMunicipality(municipality)] = votes
}
if (municipality.kind === 'zona') {
const byScenario = zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes()
@ -239,7 +241,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const solla =
getMunicipalityFederalBaseline(municipality.slug).votesByYear[String(year)] ?? 0
const other = sumVotesForGeography(otherVotes, municipality.geography)
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + (solla - other)
values[mapKeyForMunicipality(municipality)] = solla - other
}
diffByYear[String(year)] = values
}),
@ -258,10 +260,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
values2026ByScenario: pledgeValuesByScenario,
validVotesByYear,
statewideShareByYear,
territorialClassByCode,
territorialClassByMapKey,
competitiveRankByYear,
projectedValidVotesByCode,
municipalitiesByIbgeCode,
projectedValidVotesByMapKey,
municipalitiesByMapKey,
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,

View file

@ -5,12 +5,28 @@ type MunicipalityMapSlugEntry = {
name: string
}
/** IBGE codarea → the accessible catalog units inside it (Salvador has 19). */
export type MunicipalitiesByIbgeCode = Record<string, MunicipalityMapSlugEntry[]>
export type MunicipalityMapNavigation =
| { kind: 'none' }
| { kind: 'navigate'; slug: string }
| { kind: 'zones' }
/**
* Map key → the single unit it paints. Since B8 F2 the map draws one polygon per
* catalog unit — zone municipalities by slug, everything else by IBGE codarea —
* so this index is 1:1 where `MunicipalitiesByIbgeCode` is 1:N.
*/
export type MunicipalitiesByMapKey = Record<string, MunicipalityMapSlugEntry>
export type MunicipalityMapNavigation = { kind: 'none' } | { kind: 'navigate'; slug: string }
/**
* What the choropleth paints a unit by: a zone municipality has no code of its
* own (the whole city shares one), so it is keyed by its immutable catalog slug,
* which is also the polygon's key in `bahia-municipality-zones.topo.json`.
*/
export const mapKeyForMunicipality = (municipality: {
kind: 'municipio' | 'zona'
slug: string
ibgeCode: string
}): string => (municipality.kind === 'zona' ? municipality.slug : municipality.ibgeCode)
export const buildMunicipalitiesByIbgeCode = (
municipalities: readonly AccessibleMunicipality[],
@ -30,12 +46,25 @@ export const buildMunicipalitiesByIbgeCode = (
return byIbge
}
export const resolveMunicipalityMapNavigation = (
ibgeCode: string,
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode,
): MunicipalityMapNavigation => {
const entries = municipalitiesByIbgeCode[ibgeCode]
if (!entries || entries.length === 0) return { kind: 'none' }
if (entries.length === 1) return { kind: 'navigate', slug: entries[0].slug }
return { kind: 'zones' }
export const buildMunicipalitiesByMapKey = (
municipalities: ReadonlyArray<AccessibleMunicipality & { kind: 'municipio' | 'zona' }>,
): MunicipalitiesByMapKey => {
const byMapKey: MunicipalitiesByMapKey = {}
for (const municipality of municipalities) {
byMapKey[mapKeyForMunicipality(municipality)] = {
slug: municipality.slug,
name: municipality.name,
}
}
return byMapKey
}
export const resolveMunicipalityMapNavigation = (
mapKey: string,
municipalitiesByMapKey: MunicipalitiesByMapKey,
): MunicipalityMapNavigation => {
const entry = municipalitiesByMapKey[mapKey]
return entry ? { kind: 'navigate', slug: entry.slug } : { kind: 'none' }
}

View file

@ -0,0 +1,63 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B8 F2 — Salvador used to be one polygon carrying the whole city's votes, so
* the choropleth could not answer "which piece of Salvador is weak / is mine".
* This pins the two halves of that fix: the zone mesh is drawn (one path per
* ZE, the city's own polygon left underneath as a non-interactive base) and a
* zone is addressable on its own — the readout names it and opens it.
*/
test('the dashboard map paints Salvador zone by zone and opens one', async ({ campaign, page }) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Mapa'),
email: `${fixtures.value('map-coordinator')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, coordinator.email!, password)
await expect(page.getByRole('heading', { name: 'Mapa dos Municípios' })).toBeVisible({
timeout: 60_000,
})
await page.waitForSelector('path.leaflet-interactive', { timeout: 60_000 })
// 417 municípios + 19 Salvador zones; Salvador's own polygon is the one path
// that stays out of the pointer's way.
await expect
.poll(
() =>
page.evaluate(() => {
const paths = Array.from(document.querySelectorAll('.leaflet-overlay-pane path'))
return {
total: paths.length,
interactive: paths.filter((path) => path.classList.contains('leaflet-interactive'))
.length,
}
}),
{ timeout: 30_000 },
)
.toEqual({ total: 436, interactive: 435 })
// Insertion order is z-order, so the zone mesh is the tail of the pane.
await page.evaluate(() => {
const paths = Array.from(document.querySelectorAll('.leaflet-overlay-pane path'))
paths.at(-1)?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
})
const readout = page.locator('div[aria-live="polite"]').first()
await expect(readout).toContainText(/Salvador — ZE \d+/)
await readout.getByRole('link', { name: 'Abrir município' }).click()
// The slug is the assertion: the detail page's own <h1> reads the municipality
// ROW, which other specs rename, while the polygon's name comes from the
// static catalog.
await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})

View file

@ -4,14 +4,22 @@ import { statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Position } from 'geojson'
import { describe, expect, it } from 'vitest'
import { loadMunicipalityGeometryModule, loadTerritoryGeometryModule } from '@/lib/bahiaGeometries'
import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
featureCentroid,
featureContainsPoint,
findContainingMunicipality,
resolveNearbyMunicipality,
} from '@/lib/municipalityProximity'
@ -21,6 +29,9 @@ import { featureBounds } from '../helpers/featureBounds'
/** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */
const MAX_TOPO_BYTES = 600 * 1024
/** Square degrees → km² at Salvador's latitude — good to a few percent, which is all this needs. */
const SQUARE_DEGREE_KM2 = 110.57 * 108.5
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..')
const hasNonEmptyGeometry = (
@ -31,6 +42,32 @@ const hasNonEmptyGeometry = (
return true
}
/** Planar shoelace in square degrees — only ever compared against itself. */
const featureArea = (feature: BahiaGeometryFeature): number => {
const polygons =
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
return polygons.reduce((total, [outerRing, ...holes]) => {
const ringArea = (ring: readonly Position[]): number => {
let sum = 0
for (
let current = 0, previous = ring.length - 1;
current < ring.length;
previous = current++
) {
sum += ring[previous][0] * ring[current][1] - ring[current][0] * ring[previous][1]
}
return Math.abs(sum) / 2
}
return (
total + ringArea(outerRing) - holes.reduce((holeTotal, hole) => holeTotal + ringArea(hole), 0)
)
}, 0)
}
describe('Bahia static geometries', () => {
it('keeps committed TopoJSON under the size budget', () => {
const municipalityBytes = statSync(
@ -39,11 +76,16 @@ describe('Bahia static geometries', () => {
const territoryBytes = statSync(
join(repoRoot, 'src/lib/geometries/bahia-identity-territories.topo.json'),
).size
const zoneBytes = statSync(
join(repoRoot, 'src/lib/geometries/bahia-municipality-zones.topo.json'),
).size
expect(municipalityBytes).toBeGreaterThan(10_000)
expect(territoryBytes).toBeGreaterThan(1_000)
expect(zoneBytes).toBeGreaterThan(10_000)
expect(municipalityBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(territoryBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(zoneBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
})
it('exposes 417 municipality features whose codarea set matches the code table', async () => {
@ -87,6 +129,72 @@ describe('Bahia static geometries', () => {
expect(getTerritoryFeature('99')).toBeUndefined()
})
/**
* B8 F2 — the 19 Salvador zones are dissolved from the IBGE neighborhood mesh,
* a different source than the municipal mesh above, so what is pinned here is
* the contract the map depends on: one feature per catalog zona, keyed by slug,
* geometrically inside the city it decomposes.
*/
describe('zone municipality geometries (B8 F2)', () => {
const zoneCatalog = municipalityCatalog.filter((entry) => entry.kind === 'zona')
it('exposes one feature per zona municipality, keyed by catalog slug', async () => {
const { topology, features, getMunicipalityZoneFeature } =
await loadMunicipalityZoneGeometryModule()
expect(Object.keys(topology.objects)).toEqual(['municipalityZones'])
expect(topology.objects.municipalityZones.geometries).toHaveLength(zoneCatalog.length)
expect(features).toHaveLength(zoneCatalog.length)
for (const entry of zoneCatalog) {
const zone = getMunicipalityZoneFeature(entry.slug)
expect(zone, entry.slug).toBeDefined()
expect(zone?.properties.name).toBe(entry.name)
expect(zone?.properties.zoneNumber).toBe(entry.zoneNumber)
expect(zone?.properties.ibgeCode).toBe(entry.ibgeCode)
expect(hasNonEmptyGeometry(zone?.geometry)).toBe(true)
}
expect(getMunicipalityZoneFeature('feira-de-santana')).toBeUndefined()
})
it('keeps every zone inside the municipality it decomposes', async () => {
const [{ getMunicipalityFeature }, { features }] = await Promise.all([
loadMunicipalityGeometryModule(),
loadMunicipalityZoneGeometryModule(),
])
for (const zone of features) {
const city = getMunicipalityFeature(zone.properties.ibgeCode)
expect(city, zone.properties.municipalitySlug).toBeDefined()
expect(
featureContainsPoint(city!, featureCentroid(zone)),
zone.properties.municipalitySlug,
).toBe(true)
}
})
/**
* Salvador's land is ~324 km². It is NOT compared against the municipal
* polygon: that one measures ~690 km², matching the official IBGE area,
* because the municipal limits enclose the bay water around the islands —
* water the neighborhood mesh does not draw.
*
* Since each neighborhood polygon is assigned to exactly one zone, the sum is
* also the overlap check: a neighborhood counted twice, or dropped, moves this
* by its own area.
*/
it('sums to Salvador’s land area, so no neighborhood is dropped or counted twice', async () => {
const { features } = await loadMunicipalityZoneGeometryModule()
const zoneKm2 =
features.reduce((total, zone) => total + featureArea(zone), 0) * SQUARE_DEGREE_KM2
expect(zoneKm2).toBeGreaterThan(280)
expect(zoneKm2).toBeLessThan(360)
})
})
/**
* B14 resolves "onde estou" against this same mesh, so the containment used in
* the field is pinned here with real coordinates — the synthetic squares in

View file

@ -10,10 +10,10 @@ import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const ZONE_MUNICIPALITY_COUNT = municipalityCatalog.filter((entry) => entry.kind === 'zona').length
const ZONE_MUNICIPALITY_CODES = new Set(
municipalityCatalog.filter((entry) => entry.kind === 'zona').map((entry) => entry.ibgeCode),
)
const zoneMunicipalities = municipalityCatalog.filter((entry) => entry.kind === 'zona')
const ZONE_MUNICIPALITY_COUNT = zoneMunicipalities.length
const ZONE_MUNICIPALITY_SLUGS = new Set(zoneMunicipalities.map((entry) => entry.slug))
const ZONE_MUNICIPALITY_CODES = new Set(zoneMunicipalities.map((entry) => entry.ibgeCode))
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -28,7 +28,7 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
payload = await getPayload({ config: await config })
})
it('scopes the map to zone municipalities when kind=zona is in the URL', async () => {
it('paints each zone under its own map key, never pooled under the city code', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const bundle = await loadMunicipalityMapBundle(payload, coordinator, { kind: 'zona' })
@ -36,11 +36,18 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
expect(bundle).not.toBeNull()
expect(bundle!.zoneBreakdown).toHaveLength(ZONE_MUNICIPALITY_COUNT)
const ibgeCodes = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(ibgeCodes.length).toBeGreaterThan(0)
for (const code of ibgeCodes) {
expect(ZONE_MUNICIPALITY_CODES.has(code)).toBe(true)
const mapKeys = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(mapKeys.length).toBeGreaterThan(0)
for (const key of mapKeys) {
expect(ZONE_MUNICIPALITY_SLUGS.has(key), key).toBe(true)
expect(ZONE_MUNICIPALITY_CODES.has(key), key).toBe(false)
}
expect(Object.keys(bundle!.municipalitiesByMapKey).sort()).toEqual([...mapKeys].sort())
// The zones share the city's TSE rank — the artifact ranks by codarea only.
const ranks = bundle!.competitiveRankByYear['2022'] ?? {}
const positions = new Set(mapKeys.map((key) => ranks[key]?.rank))
expect(positions.size).toBe(1)
})
it('returns null when the filtered municipality set is empty', async () => {
@ -62,9 +69,9 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
expect(included!.zoneBreakdown.some((row) => row.slug === administered.slug)).toBe(
administered.kind === 'zona',
)
if (administered.kind !== 'zona') {
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([administered.ibgeCode])
}
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([
administered.kind === 'zona' ? administered.slug : administered.ibgeCode,
])
const excluded =
administered.kind === 'zona'

View file

@ -16,7 +16,8 @@ type OfficialEvidence = {
zoneCount: number
assignments: Array<{
municipalitySlug: string
city: 'Salvador' | 'Camaçari'
/** Hydrated from the catalog since Fase 0 of B8 F2, so the fixture no longer narrows it. */
city: string
zoneNumber: number
source: 'tre-ra-02-2017' | 'tre-voting-locations-curated'
neighborhoods: string[]
@ -60,6 +61,12 @@ describe('Municipality zone neighborhood catalog (Salvador only)', () => {
}
})
it('lists zones in catalog order', () => {
expect(municipalityZoneNeighborhoods.map((entry) => entry.municipalitySlug)).toEqual(
zoneMunicipalities.map((entry) => entry.slug),
)
})
it('keeps Salvador neighborhoods exclusive across zones', () => {
const seen = new Map<string, string>()

View file

@ -4,6 +4,7 @@ import {
buildLayerStyleContext,
canonicalMapKeysKey,
computeChoroplethMax,
featureMapKey,
resolvePathStyle,
} from '@/lib/bahiaMapStyle'
@ -29,6 +30,34 @@ describe('bahiaMapStyle', () => {
})
})
describe('featureMapKey (B8 F2)', () => {
it('keys a zone municipality by its catalog slug', () => {
expect(
featureMapKey({ municipalitySlug: 'salvador-ze-7', ibgeCode: '2927408', zoneNumber: 7 }),
).toBe('salvador-ze-7')
})
it('keys a município by codarea and an identity territory by code', () => {
expect(featureMapKey({ codarea: '2905701', name: 'Barreiras' })).toBe('2905701')
expect(featureMapKey({ code: 'chapada-diamantina', name: 'Chapada Diamantina' })).toBe(
'chapada-diamantina',
)
})
it('prefers the slug when the zone mesh also carries the city code', () => {
expect(featureMapKey({ municipalitySlug: 'salvador-ze-1', codarea: '2927408' })).toBe(
'salvador-ze-1',
)
})
it('returns undefined when no key property is usable', () => {
expect(featureMapKey(undefined)).toBeUndefined()
expect(featureMapKey({ name: 'Sem chave' })).toBeUndefined()
expect(featureMapKey({ codarea: '' })).toBeUndefined()
expect(featureMapKey({ codarea: 2905701 })).toBeUndefined()
})
})
describe('canonicalMapKeysKey', () => {
it('sorts keys for stable identity', () => {
expect(canonicalMapKeysKey(['2927408', '2905701'])).toBe('2905701,2927408')

View file

@ -2,50 +2,69 @@ import { describe, expect, it } from 'vitest'
import {
buildMunicipalitiesByIbgeCode,
buildMunicipalitiesByMapKey,
mapKeyForMunicipality,
resolveMunicipalityMapNavigation,
} from '@/utilities/municipalityMapNavigation'
const abaira = { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108', kind: 'municipio' } as const
const salvadorZe1 = {
slug: 'salvador-ze-1',
name: 'Salvador — ZE 1',
ibgeCode: '2927408',
kind: 'zona',
} as const
const salvadorZe2 = {
slug: 'salvador-ze-2',
name: 'Salvador — ZE 2',
ibgeCode: '2927408',
kind: 'zona',
} as const
describe('municipalityMapNavigation', () => {
it('buildMunicipalitiesByIbgeCode groups municipalities by ibgeCode and sorts by name', () => {
const result = buildMunicipalitiesByIbgeCode([
{ slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' },
{ slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' },
{ slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' },
])
const result = buildMunicipalitiesByIbgeCode([salvadorZe2, salvadorZe1, abaira])
expect(result['2900108']).toEqual([{ slug: 'abaira', name: 'Abaíra' }])
expect(result['2927408']).toEqual([
{ slug: 'salvador-ze-1', name: 'Salvador ZE 1' },
{ slug: 'salvador-ze-2', name: 'Salvador ZE 2' },
{ slug: 'salvador-ze-1', name: 'Salvador — ZE 1' },
{ slug: 'salvador-ze-2', name: 'Salvador — ZE 2' },
])
})
it('resolveMunicipalityMapNavigation returns none when ibgeCode is missing or empty', () => {
const byIbge = buildMunicipalitiesByIbgeCode([
{ slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' },
])
expect(resolveMunicipalityMapNavigation('9999999', byIbge)).toEqual({ kind: 'none' })
expect(resolveMunicipalityMapNavigation('2900108', {})).toEqual({ kind: 'none' })
it('keys zone municipalities by slug and everything else by codarea', () => {
expect(mapKeyForMunicipality(abaira)).toBe('2900108')
expect(mapKeyForMunicipality(salvadorZe1)).toBe('salvador-ze-1')
})
it('resolveMunicipalityMapNavigation returns navigate for a single municipality', () => {
const byIbge = buildMunicipalitiesByIbgeCode([
{ slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' },
])
it('gives every zone its own map key instead of pooling them under the city', () => {
const byMapKey = buildMunicipalitiesByMapKey([salvadorZe1, salvadorZe2, abaira])
expect(resolveMunicipalityMapNavigation('2900108', byIbge)).toEqual({
kind: 'navigate',
slug: 'abaira',
expect(Object.keys(byMapKey).sort()).toEqual(['2900108', 'salvador-ze-1', 'salvador-ze-2'])
expect(byMapKey['2927408']).toBeUndefined()
expect(byMapKey['salvador-ze-2']).toEqual({
slug: 'salvador-ze-2',
name: 'Salvador — ZE 2',
})
})
it('resolveMunicipalityMapNavigation returns zones for multiple municipalities on the same ibgeCode', () => {
const byIbge = buildMunicipalitiesByIbgeCode([
{ slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' },
{ slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' },
])
it('resolveMunicipalityMapNavigation returns none for an unknown or empty key', () => {
const byMapKey = buildMunicipalitiesByMapKey([abaira])
expect(resolveMunicipalityMapNavigation('2927408', byIbge)).toEqual({ kind: 'zones' })
expect(resolveMunicipalityMapNavigation('9999999', byMapKey)).toEqual({ kind: 'none' })
expect(resolveMunicipalityMapNavigation('2900108', {})).toEqual({ kind: 'none' })
})
it('resolveMunicipalityMapNavigation opens the unit behind the key, zone included', () => {
const byMapKey = buildMunicipalitiesByMapKey([abaira, salvadorZe1])
expect(resolveMunicipalityMapNavigation('2900108', byMapKey)).toEqual({
kind: 'navigate',
slug: 'abaira',
})
expect(resolveMunicipalityMapNavigation('salvador-ze-1', byMapKey)).toEqual({
kind: 'navigate',
slug: 'salvador-ze-1',
})
})
})