mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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:
parent
2ac3a3e512
commit
84e796ab49
22 changed files with 1051 additions and 243 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
407
scripts/build-municipality-zone-geometries.mjs
Normal file
407
scripts/build-municipality-zone-geometries.mjs
Normal 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()
|
||||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
src/lib/bahiaMunicipalityZoneGeometries.ts
Normal file
23
src/lib/bahiaMunicipalityZoneGeometries.ts
Normal 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),
|
||||
}
|
||||
1
src/lib/geometries/bahia-municipality-zones.topo.json
Normal file
1
src/lib/geometries/bahia-municipality-zones.topo.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
63
tests/e2e/campaignZoneMap.e2e.spec.ts
Normal file
63
tests/e2e/campaignZoneMap.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue