Add static Bahia municipality and territory geometries for the campaign map.

Ship TopoJSON meshes, IBGE code lookups, a re-runnable build script, and evidence tests so B3 can render Leaflet surfaces without PostGIS.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 22:07:41 -03:00
parent b2856abceb
commit e5968f81d5
14 changed files with 2854 additions and 37 deletions

View file

@ -11,6 +11,7 @@ alwaysApply: false
- **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo.
- **A2 entregue e mesclado em `main` (2026-07-18):** cadastro estático município↔ZE (`bahiaTseZones`, TSE 2024) + chips opt-in no formulário (`NucleusTerritoryAndZonesFields` / `territorySuggestions`); sem migration. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md).
- **C3 Planos de Ação implementado (2026-07-18):** collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho; access por `coordinators`/`leadership`; migration `20260718_222832_add_action_plan`.
- **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE + TIs por dissolução, `bahiaMunicipalityCodes`, helpers `bahiaGeometries`, script `pnpm build:geometries`. Sem UI (B3). Plano: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md).
- **C7 registrado (2026-07-18):** débitos de escala/DRY pós-C3 do `/simplify` (território/contato compostos, helpers FormData/URL, leituras por aba, RMW/índice) — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); não bloqueia o MVP da agenda.
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
- Pendência operacional (não de código): Consent `lideranca-autopreenchimento` + lote jurídico LGPD antes de dados reais; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).

3
.gitignore vendored
View file

@ -45,6 +45,9 @@ next-env.d.ts
# TSE open-data zip cache (pnpm db:seed:tse)
/data/tse/
# IBGE geometry download cache (pnpm build:geometries)
/data/geometries/
# Playwright
node_modules/
/test-results/

View file

@ -1,21 +1,23 @@
# Mapa da Bahia na campanha — geometrias estáticas + Leaflet
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Status: Fase 1 entregue (B2); Fase 2 (B3 Leaflet) pendente
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) — B2 (Fase 1) / B3 (Fase 2) / B4 (camada de zonas)
Responsável: —
## Contexto
O roadmap (linha 52) prevê "Mapa / PostGIS: Leaflet, geometria/point, imagem Docker PostGIS; âncora `/mapa` do design-ux adiada". Este plano destrincha esse item para a vertical `/campanha`: preparar o webapp para representar o mapa da Bahia (municípios + Territórios de Identidade) e usar esse mapa para visualizar agregados da campanha de forma mais clara.
O roadmap (trilha B) prevê o mapa da Bahia na vertical `/campanha`: preparar geometrias estáticas (municípios + Territórios de Identidade) e usá-las para visualizar agregados da campanha. A âncora `/mapa` do design-ux permanece adiada.
Hoje o `electoralNucleus` (`src/collections/ElectoralNucleus.ts`) indexa território por texto (`region`/`city`/`neighborhood`/`tseZones`) e as agregações saem de queries Payload por essas chaves (`src/utilities/campaignDashboardPageData.ts`, `src/utilities/nucleusViewModels.ts`). Não há nenhuma geometria. A decisão de produto (2026-07-17) é adotar **geometrias estáticas versionadas no repo** (mesmo padrão de `src/lib/bahiaTerritories.ts`), sem PostGIS, e integrar Leaflet nas superfícies já existentes — a âncora `/mapa` do design-ux permanece adiada.
O `electoralNucleus` indexa território por texto (`regions`/`cities`/`neighborhoods`/`tseZones` — arrays `hasMany` desde A1) e as agregações saem de queries Payload por essas chaves. Não há query espacial. A decisão de produto (2026-07-17) é adotar **geometrias estáticas versionadas no repo** (mesmo padrão de `src/lib/bahiaTerritories.ts`), sem PostGIS, e integrar Leaflet nas superfícies já existentes.
## Decisões travadas (confirmadas com o usuário)
- **GeoJSON/TopoJSON estático versionado no repo**, sem PostGIS. Mesmo padrão de `src/lib/bahiaTerritories.ts` (dado versionado com proveniência + fixture + teste int).
- **v1 cobre municípios (IBGE) + Territórios de Identidade (IDE Bahia).** Zonas TSE como camada ficam para um ciclo seguinte (dependem de `zonas-por-municipio`).
- **`/mapa` dedicada do design-ux permanece adiada.** v1 incorpora o mapa em superfícies já existentes: overview da lista de núcleos, detalhe do núcleo e dashboard `/campanha`.
- **v1 cobre municípios (IBGE) + Territórios de Identidade.** Zonas TSE como camada ficam para B4 (dependem de A2 ✓ + B3).
- **Territórios de Identidade por dissolução IBGE** (decisão 2026-07-18): polígonos dos TIs saem de `topojson.merge` dos municípios IBGE conforme a composição oficial em `bahiaIdentityTerritoryRecords` — não do shapefile IDE Bahia. A IDE Bahia fica como referência de validação na proveniência. Garante coerência mapa ↔ composição do app e antecipa a abordagem A do B4.
- **Extensão `*.topo.json`** (não `.topojson`) para import JSON nativo no Next/TS sem config extra.
- **`/mapa` dedicada do design-ux permanece adiada.** v1 (B3) incorpora o mapa em superfícies já existentes: overview da lista de núcleos, detalhe do núcleo e dashboard `/campanha`.
- **Zona segue como granularidade eleitoral de referência** do núcleo — não mapear núcleo a seções (ver "Granularidade seção vs. zona").
- **Sem migration, sem collection, sem Consent, sem server action nova.** Só dado estático + componentes cliente + agregações já existentes.
@ -25,13 +27,13 @@ O `electoralNucleus` já indexa território por texto e as agregações saem de
## Fontes oficiais (pesquisa)
- **Municípios (BA):** IBGE Malha Municipal 2024. GeoJSON/TopoJSON via API `https://servicodados.ibge.gov.br/api/v3/malhas/estados/29?formato=application/vnd.geo+json&intrarregiao=municipio` (resolução 1–2 ideal para web; `codarea` = código IBGE de 7 dígitos). Shapefile em `geoftp.ibge.gov.br/.../municipio_2024/UFs/BA/`. CRS SIRGAS 2000 (EPSG:4674) ≈ WGS84 para web (diferença sub-metro, irrelevante em escala municipal).
- **Territórios de Identidade:** IDE Bahia / SEI publica polígono vetorial oficial 1:100.000 (2019, EPSG:4674): `https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864` (Shapefile + KML). Configuração vigente desde 2016 (a mesma de `bahiaTerritories.ts`). Não precisamos dissolver.
- **Zonas TSE:** o TSE **não** publica polígonos — só o cadastro tabular "Eleitorado por município e zona" (composição município→zona) e os endereços de locais de votação/seções. Polígonos só por construção derivada (aproximação; padrão da literatura). Fica fora da v1.
- **Municípios (BA):** IBGE Malhas API v3 (`qualidade=intermediaria`) + Localidades API v1 para `id`/`nome`. `codarea` = código IBGE de 7 dígitos. CRS SIRGAS 2000 (EPSG:4674) ≈ WGS84 para web.
- **Territórios de Identidade:** composição oficial em `bahiaTerritories.ts` (SECULT/SEPLAN); polígonos = dissolução dos municípios IBGE. IDE Bahia / SEI (referência de validação): `https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864`.
- **Zonas TSE:** o TSE **não** publica polígonos — só o cadastro tabular. Polígonos só por construção derivada. Fica fora da v1 (B4).
## Granularidade seção vs. zona
Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação município↔zona é muitos-para-muitos (uma cidade pode ter várias zonas; uma zona pode cobrir vários municípios pequenos). Decisão (2026-07-17): **manter zona como granularidade eleitoral de referência** do núcleo, não seções, porque:
Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação município↔zona é muitos-para-muitos. Decisão (2026-07-17): **manter zona como granularidade eleitoral de referência** do núcleo, não seções, porque:
1. O núcleo é unidade operacional da campanha, não eleitoral — o coordenador pensa em bairro/município/território.
2. O cruzamento com a baseline TSE 2022 (`baseline-eleitoral-tse`) é por zona.
@ -40,64 +42,71 @@ Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação munic
## Modelagem de dados
- **Arquivos de geometria** (TopoJSON, menor que GeoJSON) commitados no repo:
- `src/lib/geometries/bahia-municipalities.topojson` — uma Feature por município, `properties: { codarea, name }`.
- `src/lib/geometries/bahia-identity-territories.topojson` — uma Feature por território, `properties: { code, name }` (`code` = "01".."27", já existe em `bahiaIdentityTerritoryRecords`).
- **Arquivos de geometria** (TopoJSON) commitados no repo:
- `src/lib/geometries/bahia-municipalities.topo.json` — uma Feature por município, `properties: { codarea, name }` (name = canônico do app).
- `src/lib/geometries/bahia-identity-territories.topo.json` — uma Feature por território, `properties: { code, name }` (`code` = "01".."27").
- **Join por chave estável:**
- Municípios: o núcleo guarda `city` (nome). Precisamos de `name → codarea`. Criar `src/lib/bahiaMunicipalityCodes.ts` (record nome→código IBGE de 7 dígitos) com proveniência + fixture `tests/fixtures/bahia-municipality-codes.official.json` + teste `tests/int/bahiaMunicipalityCodes.int.spec.ts` (espelha `bahiaTerritories`). Necessário porque nomes IBGE ≠ nomes SECULT canônicos (acentos, "Dias D'Ávila", etc.) — a tabela reconcilia.
- Municípios: o núcleo guarda `cities[]` (array de nomes, desde A1). Tabela `src/lib/bahiaMunicipalityCodes.ts` (nome canônico → código IBGE de 7 dígitos) + fixture `tests/fixtures/bahia-municipality-codes.official.json` + teste `tests/int/bahiaMunicipalityCodes.int.spec.ts`. Reconciliação IBGE→canônico via `canonicalizeMunicipalityName` (`src/lib/electionResults.ts`).
- Territórios: join por `code` (01–27) já presente em `bahiaIdentityTerritoryRecords`; sem tabela extra.
- **Proveniência e versionamento:** cada arquivo `.topojson` e cada tabela estática com cabeçalho/documentação de proveniência (URL, versão/ano, SHA-256 do download de origem), no estilo de `bahiaTerritories.ts`.
- **Helpers:** `src/lib/bahiaGeometries.ts` — `getMunicipalityFeature(codarea)`, `getTerritoryFeature(code)`, topologias tipadas (framework-free para B3).
- **Proveniência e versionamento:** cada artefato com cabeçalho/documentação de proveniência (URL, SHA-256 do download de origem), no estilo de `bahiaTerritories.ts` / `bahiaTseZones.ts`.
## Script de geração (one-off, re-executável)
- `scripts/build-bahia-geometries.mjs`: baixa IBGE (API) + IDE Bahia (shapefile), converte/simplifica com `topojson-server` + `topojson-simplify` (ou `@turf/simplify`), emite os `.topojson` e a tabela `bahiaMunicipalityCodes.ts`. Não roda em build/dev — só quando se atualiza a versão de origem. Mesmo guard de não-produção dos outros scripts (não toca prod).
- `scripts/build-bahia-geometries.mjs` (`pnpm build:geometries`): baixa IBGE Malhas + Localidades, reconcilia nomes, constrói topologia com `topojson-server` + `topojson-simplify` + `quantize`, dissolve territórios com `topojson-client` `merge`, emite os `*.topo.json`, `bahiaMunicipalityCodes.ts` e o fixture. Cache em `data/geometries/` (gitignored). **Não toca banco** — sem `assertLocalDatabase`. Não roda em build/dev — só quando se atualiza a versão de origem.
## Leaflet na campanha
## Leaflet na campanha (B3 — pendente)
- Dependência: `leaflet` + `topojson-client` (cliente). `react-leaflet` é opcional; preferir wrapper cliente fino com `leaflet` direto e `dynamic(() => import(...), { ssr: false })` para evitar SSR.
- Dependência: `leaflet` (+ `topojson-client` já instalado na Fase 1). `react-leaflet` é opcional; preferir wrapper cliente fino com `leaflet` direto e `dynamic(() => import(...), { ssr: false })` para evitar SSR.
- **Base tiles:** OpenStreetMap raster (ou CartoDB Positron) com atribuição obrigatória; ou fundo neutro sem tiles (só coroplético) como opção mais leve. Decidir na implementação; default OSM.
- **Componentes novos (todos `src/components/campaign/`, PascalCase):**
- `BahiaMap.tsx` — cliente, recebe `geometry` (TopoJSON) + `values: Record<string, number>` (chave→métrica) + `mode: 'municipality' | 'territory'` + `colorScale`; renderiza coroplético Leaflet.
- `NucleusOverviewMap.tsx` — mapa no overview de `/campanha/nucleos`, escopado pelos mesmos filtros da lista, com toggle município/território e escolha da métrica (estimativa confirmada, nº de núcleos, baseline 2022 Solla quando disponível).
- `NucleusDetailMap.tsx` — mapa pequeno no detalhe do núcleo destacando seus municípios/território (+ marcadores ponto das zonas TSE, quando a camada de zonas existir).
- `DashboardMap.tsx` — coroplético por território no dashboard `/campanha`.
- **Agregação:** servidor continua a fonte. Reusar `campaignDashboardPageData` / `nucleusViewModels` e expor agregados por `codarea`/`code` de território; o cliente junta com a geometria. Sem query espacial.
- **Agregação:** servidor continua a fonte. Reusar `campaignDashboardPageData` / `nucleusViewModels` / `nucleusListOverviewPageData` e expor agregados por `codarea`/`code` de território; o cliente junta com a geometria. Sem query espacial.
## Phasing
- **Fase 1 — Fundação de geometrias:** `bahiaMunicipalityCodes.ts` + fixture + teste; `.topojson` de municípios e territórios; script de geração; helpers `getMunicipalityFeature(codarea)`, `getTerritoryFeature(code)`.
- **Fase 2 — Leaflet nas superfícies:** `BahiaMap` + `NucleusOverviewMap` + `NucleusDetailMap` + `DashboardMap`, com agregados existentes.
- **Fase 1 — Fundação de geometrias (B2) ✓ entregue 2026-07-18:** `bahiaMunicipalityCodes.ts` + fixture + teste; `*.topo.json` de municípios e territórios; script `pnpm build:geometries`; helpers em `bahiaGeometries.ts`.
- **Fase 2 — Leaflet nas superfícies (B3):** `BahiaMap` + `NucleusOverviewMap` + `NucleusDetailMap` + `DashboardMap`, com agregados existentes.
**Sequenciamento (2026-07-17):** a Fase 1 é independente e paralelizável a qualquer momento. A Fase 2 rende mais **depois** de [overview-lista-nucleos.md](overview-lista-nucleos.md) (o `NucleusOverviewMap` é um bloco daquele painel) e ganha muito valor com [baseline-eleitoral-tse.md](baseline-eleitoral-tse.md) implementado (coroplético de baseline 2022 e da classificação territorial é onde o mapa vira instrumento de decisão, não só visual). Nenhum dos dois é bloqueante — o mapa funciona só com estimativa/nº de núcleos — mas a ordem recomendada no roadmap coloca overview e baseline antes.
**Sequenciamento:** a Fase 1 é independente e paralelizável. A Fase 2 rende mais depois de B1 ✓ (overview) e ganha valor com A4 (baseline no produto). Nenhum dos dois bloqueia B3 — o mapa funciona só com estimativa/nº de núcleos.
## Ciclo seguinte — Zonas TSE como camada
## Ciclo seguinte — Zonas TSE como camada (B4)
Após `zonas-por-municipio`. Polígono por dissolução dos municípios membros (abordagem A, default) ou por geocodificação dos endereços das seções → casco convexo/côncavo (abordagem B, questão em aberto). Ponto representativo por centroide da zona. Item separado no roadmap.
Após A2 ✓ + B3. Polígono por dissolução dos municípios membros (abordagem A, default — já validada na Fase 1 para TIs) ou por geocodificação dos endereços das seções → casco (abordagem B, questão em aberto). Ponto representativo por centroide da zona.
- **(A) Dissolução dos municípios membros:** o TSE publica quais municípios compõem cada zona; como a v1 já terá os polígonos municipais (IBGE), basta dissolver os municípios de cada zona. Limpo, alinhado às divisas oficiais, sem geocodificação, sem serviço externo. Mais fiel porque usa a composição oficial.
- **(B) Geocodificar endereços das seções/locais de votação → casco:** pegar os locais de votação de cada zona, geocodificar (OSM Nominatim com rate-limit, IBGE logradouros, ou geocoder pago) e gerar o polígono. Independente do mapeamento município→zona, mas: milhares de endereços para geocodificar em batch offline, locais de votação se concentram em centros urbanos (casco irregular, áreas rurais cobertas mal), e pode divergir da composição oficial do TSE.
- **(A) Dissolução dos municípios membros:** o TSE publica quais municípios compõem cada zona (`bahiaTseZones`); com os polígonos municipais IBGE, basta dissolver. Limpo, alinhado às divisas oficiais.
- **(B) Geocodificar endereços das seções/locais de votação → casco:** milhares de endereços, casco irregular em áreas rurais, pode divergir da composição oficial.
## Não escopo
- PostGIS (adiado; só se surgir query espacial real).
- `/mapa` dedicada do design-ux (adiada).
- Camada de Zonas TSE — polígono ou ponto (ciclo seguinte; depende de `zonas-por-municipio`).
- Camada de Zonas TSE — polígono ou ponto (B4; depende de A2 ✓ + B3).
- Mapear núcleo a seções eleitorais (zona segue como referência).
- Geocodificação de endereços de apoiadores.
- Outros estados (todo núcleo é BA).
## Dependências
- Reusa `bahiaIdentityTerritoryRecords`/`bahiaMunicipalities` (`src/lib/bahiaTerritories.ts`), `CitiesByState.BA` (`src/lib/cities`), `campaignDashboardPageData` / `nucleusViewModels` (agregados existentes), e o padrão de fixture/teste de `bahiaTerritories`.
- **Camada de Zonas** depende de `docs/plans/zonas-por-municipio.md`.
- Reusa `bahiaIdentityTerritoryRecords`/`bahiaMunicipalities` (`src/lib/bahiaTerritories.ts`), `canonicalizeMunicipalityName` (`src/lib/electionResults.ts`), `CitiesByState.BA` (`src/lib/cities`), `campaignDashboardPageData` / `nucleusViewModels` / `nucleusListOverviewPageData` (agregados para B3), e o padrão de fixture/teste de `bahiaTerritories`.
- **Camada de Zonas (B4)** depende de A2 (`docs/plans/zonas-por-municipio.md`) + B3.
## Revisões
- **2026-07-18 (auditoria pré-B2 + entrega Fase 1):** territórios por dissolução IBGE (não shapefile IDE Bahia); extensão `*.topo.json`; reuso de `canonicalizeMunicipalityName`; núcleo usa `cities[]` (A1); script sem guard de banco; referências de linha do roadmap antigas removidas; Fase 1 implementada e marcada entregue.
## Referências
- `docs/roadmap.md` (item "Mapa / PostGIS", linha 52; "Import do cadastro oficial de zonas TSE", linha 59)
- `docs/roadmap.md` (B2 / B3 / B4 na trilha B)
- `src/lib/bahiaTerritories.ts` + `tests/fixtures/bahia-identity-territories.official.json` + `tests/int/bahiaTerritories.int.spec.ts` (padrão a espelhar)
- `src/collections/ElectoralNucleus.ts`, `src/utilities/campaignDashboardPageData.ts`, `src/utilities/nucleusViewModels.ts` (agregados a reusar)
- `src/lib/bahiaMunicipalityCodes.ts`, `src/lib/bahiaGeometries.ts`, `src/lib/geometries/*.topo.json`
- `scripts/build-bahia-geometries.mjs`
- `src/collections/ElectoralNucleus.ts`, `src/utilities/campaignDashboardPageData.ts`, `src/utilities/nucleusViewModels.ts`, `src/utilities/nucleusListOverviewPageData.ts` (agregados a reusar no B3)
- `docs/plans/zonas-por-municipio.md` (dependência da camada de Zonas)
- `docs/plans/baseline-eleitoral-tse.md` (baseline por zona, consumidor do mapa)
- AGENTS.md (naming, "Bahia implícita", padrão de dado estático versionado)
- IBGE — API de Malhas: https://servicodados.ibge.gov.br/api/docs/malhas
- IDE Bahia / SEI — Territórios de Identidade 1:100.000: https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864
- IDE Bahia / SEI — Territórios de Identidade 1:100.000 (referência): https://metadados.ide.ba.gov.br/geonetwork/srv/api/records/90b140bf-17df-496f-b048-5783fdf02864

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6/C7 registrados — escala/DRY pós-C2 e pós-C3)
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6/C7 registrados — escala/DRY pós-C2 e pós-C3; B2 fundação de geometrias do mapa entregue)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -60,6 +60,10 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
- **A2 Zonas TSE + sugestões cruzadas** — cadastro estático `bahiaTseZones` (TSE 2024 `detalhe_votacao_munzona` BA, 417 municípios), motor puro `territorySuggestions` (inclui `outsideZones`), coordenador `NucleusTerritoryAndZonesFields` com chips `{rótulo} +` opt-in (município/TI → ZEs; irmãos do TI e cidades da ZE → Municípios), `TseZoneInput` controlado. Sem migration, sem igualdade forçada no save. [Plano](plans/zonas-por-municipio.md).
### Ciclo 2+ — B2 fundação de geometrias do mapa (2026-07-18)
- **B2 Mapa Fase 1 (geometrias)** — TopoJSON estático de municípios IBGE (`bahia-municipalities.topo.json`) + Territórios de Identidade por dissolução (`bahia-identity-territories.topo.json`), tabela `bahiaMunicipalityCodes` (nome canônico → `codarea`), helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`), script re-executável `pnpm build:geometries`. Sem migration, sem UI (Leaflet = B3). [Plano](plans/mapa-bahia-geometrias.md).
### Ciclo 2+ — C2 e C3 mesclados em `main` (2026-07-18)
- **C2 Cadastro nominal de apoiadores** — collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves estáveis `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico (`getConsentByKey` / `requireConsentByKey`, falha fechada), actions (create / intenção de voto / import CSV só `geral` / `removeSupporterData`), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard de import, kit mínimo `wa.me`). Telefone obrigatório no v1; `lideranca` sem acesso à área. Engenharia pronta e mesclada — produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). [Plano](plans/cadastro-nominal-apoiadores.md).
@ -109,7 +113,7 @@ flowchart TD
subgraph TrilhaB["Trilha B — superfícies de coordenação"]
B1["B1 Overview da lista de núcleos ✓"]
B2["B2 Mapa Fase 1 (geometrias)"]
B2["B2 Mapa Fase 1 (geometrias) ✓"]
B3["B3 Mapa Fase 2 (Leaflet nas superfícies)"]
B4["B4 Camada de zonas TSE no mapa"]
end
@ -155,7 +159,7 @@ flowchart TD
JUR -.chave de push.-> D2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2) e D2 push (D1 pronto; falta a chave jurídica).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, B2, C1, C2, C3 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A4 (A2/A3 + B1 prontos), B3 (B1 ✓ + B2 ✓; suave: A4 para coroplético), C4 (A1 + C3 prontos), C6 (merge de C2 ✓), C7 (após C3; suave: C6 F2) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@ -185,7 +189,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, além do
| Ordem | Item | Plano | Depende de | Paralelizável com |
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- |
| 12 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B2/B3, C4, D1 |
| 13 | B2 + B3 Mapa da Bahia (geometrias + Leaflet) | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1+B2 (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
| 13 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
| 14 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, D1 |
| 15 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 16 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, C4 |

View file

@ -27,6 +27,7 @@
"db:pull": "node scripts/db-pull.mjs",
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
"db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.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",
"generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap",
@ -79,6 +80,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"topojson-client": "^3.1.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
@ -87,9 +89,14 @@
"@playwright/test": "1.58.2",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/react": "16.3.0",
"@types/geojson": "^7946.0.16",
"@types/node": "22.19.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"@types/topojson-server": "^3.0.4",
"@types/topojson-simplify": "^3.0.3",
"@types/topojson-specification": "^1.0.5",
"@types/yauzl": "^3.4.0",
"@vercel/blob": "2.3.1",
"@vitejs/plugin-react": "4.5.2",
@ -103,6 +110,8 @@
"prettier": "^3.4.2",
"schema-dts": "^2.0.0",
"tailwindcss": "^4.1.18",
"topojson-server": "^3.0.1",
"topojson-simplify": "^3.0.3",
"tsx": "4.21.0",
"typescript": "5.7.3",
"typescript-eslint": "^8.55.0",

View file

@ -110,6 +110,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.18)
topojson-client:
specifier: ^3.1.0
version: 3.1.0
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@ -129,6 +132,9 @@ importers:
'@testing-library/react':
specifier: 16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/geojson':
specifier: ^7946.0.16
version: 7946.0.16
'@types/node':
specifier: 22.19.9
version: 22.19.9
@ -138,6 +144,18 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
'@types/topojson-server':
specifier: ^3.0.4
version: 3.0.4
'@types/topojson-simplify':
specifier: ^3.0.3
version: 3.0.3
'@types/topojson-specification':
specifier: ^1.0.5
version: 1.0.5
'@types/yauzl':
specifier: ^3.4.0
version: 3.4.0
@ -177,6 +195,12 @@ importers:
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
topojson-server:
specifier: ^3.0.1
version: 3.0.1
topojson-simplify:
specifier: ^3.0.3
version: 3.0.3
tsx:
specifier: 4.21.0
version: 4.21.0
@ -2773,6 +2797,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@ -2816,6 +2843,18 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/topojson-client@3.1.5':
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
'@types/topojson-server@3.0.4':
resolution: {integrity: sha512-5+ieK8ePfP+K2VH6Vgs1VCt+fO1U8XZHj0UsF+NktaF0DavAo1q3IvCBXgokk/xmtvoPltSUs6vxuR/zMdOE1g==}
'@types/topojson-simplify@3.0.3':
resolution: {integrity: sha512-sBO5UZ0O2dB0bNwo0vut2yLHhj3neUGi9uL7/ROdm8Gs6dtt4jcB9OGDKr+M2isZwQM2RuzVmifnMZpxj4IGNw==}
'@types/topojson-specification@1.0.5':
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@ -6021,6 +6060,18 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
topojson-client@3.1.0:
resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
hasBin: true
topojson-server@3.0.1:
resolution: {integrity: sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw==}
hasBin: true
topojson-simplify@3.0.3:
resolution: {integrity: sha512-V+pBjLVzSQ3+hSOxBiV01OVXgFiCmMO8ia3huxKEyIMTC1ApQHBcdXdOqcQ6U2JJJD31TZduwY6KyF15R8sUgg==}
hasBin: true
tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
@ -9061,6 +9112,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
@ -9103,6 +9156,25 @@ snapshots:
'@types/statuses@2.0.6': {}
'@types/topojson-client@3.1.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/topojson-specification': 1.0.5
'@types/topojson-server@3.0.4':
dependencies:
'@types/geojson': 7946.0.16
'@types/topojson-specification': 1.0.5
'@types/topojson-simplify@3.0.3':
dependencies:
'@types/geojson': 7946.0.16
'@types/topojson-specification': 1.0.5
'@types/topojson-specification@1.0.5':
dependencies:
'@types/geojson': 7946.0.16
'@types/trusted-types@2.0.7':
optional: true
@ -12744,6 +12816,19 @@ snapshots:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
topojson-client@3.1.0:
dependencies:
commander: 2.20.3
topojson-server@3.0.1:
dependencies:
commander: 2.20.3
topojson-simplify@3.0.3:
dependencies:
commander: 2.20.3
topojson-client: 3.1.0
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.23

View file

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

View file

@ -0,0 +1,76 @@
/**
* Static Bahia municipality and identity-territory geometries for the campaign map.
*
* TopoJSON files are generated by `pnpm build:geometries` from IBGE Malhas (municípios)
* and dissolved by the official Territórios de Identidade composition in
* bahiaTerritories. See scripts/build-bahia-geometries.mjs for full provenance.
*
* Framework-free: safe to import from client components in B3 (Leaflet surfaces).
*/
import { feature } from 'topojson-client'
import type { Feature, MultiPolygon, Polygon } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
import municipalityTopologyJson from '@/lib/geometries/bahia-municipalities.topo.json'
import territoryTopologyJson from '@/lib/geometries/bahia-identity-territories.topo.json'
export type BahiaMunicipalityProperties = {
codarea: string
name: string
}
export type BahiaTerritoryProperties = {
code: string
name: string
}
export type BahiaMunicipalityFeature = Feature<
Polygon | MultiPolygon,
BahiaMunicipalityProperties
>
export type BahiaTerritoryFeature = Feature<Polygon | MultiPolygon, BahiaTerritoryProperties>
type MunicipalityTopology = Topology<{
municipalities: GeometryCollection<BahiaMunicipalityProperties>
}>
type TerritoryTopology = Topology<{
territories: GeometryCollection<BahiaTerritoryProperties>
}>
export const bahiaMunicipalitiesTopology =
municipalityTopologyJson as unknown as MunicipalityTopology
export const bahiaIdentityTerritoriesTopology =
territoryTopologyJson as unknown as TerritoryTopology
const municipalityFeatures = feature(
bahiaMunicipalitiesTopology,
bahiaMunicipalitiesTopology.objects.municipalities,
).features as BahiaMunicipalityFeature[]
const territoryFeatures = feature(
bahiaIdentityTerritoriesTopology,
bahiaIdentityTerritoriesTopology.objects.territories,
).features as BahiaTerritoryFeature[]
const municipalityByCodarea = new Map(
municipalityFeatures.map((entry) => [entry.properties.codarea, entry]),
)
const territoryByCode = new Map(
territoryFeatures.map((entry) => [entry.properties.code, entry]),
)
export const getMunicipalityFeature = (
codarea: string,
): BahiaMunicipalityFeature | undefined => municipalityByCodarea.get(codarea)
export const getTerritoryFeature = (code: string): BahiaTerritoryFeature | undefined =>
territoryByCode.get(code)
export const bahiaMunicipalityFeatures: readonly BahiaMunicipalityFeature[] =
municipalityFeatures
export const bahiaTerritoryFeatures: readonly BahiaTerritoryFeature[] = territoryFeatures

View file

@ -0,0 +1,450 @@
/**
* Official Bahia municipality name → IBGE 7-digit code (codarea) mapping.
*
* Source (IBGE Localidades API v1 — municípios da UF BA; accessed 2026-07-19):
* https://servicodados.ibge.gov.br/api/v1/localidades/estados/29/municipios
* Download SHA-256: ee2524f732a80d234f129b2e26a90f9bc080a52844a01e5359e789c613d7ee4f
*
* Mesh provenance (IBGE Malhas API v3 — qualidade intermediaria; accessed 2026-07-19):
* https://servicodados.ibge.gov.br/api/v3/malhas/estados/29?formato=application/vnd.geo+json&intrarregiao=municipio&qualidade=intermediaria
* Download SHA-256: 909017a4f91e7950cea123d2601a53b64c70acdb2b256548be25ca9e703cc572
*
* Municipality spellings follow the canonical application names in bahiaTerritories.
* IBGE variants (Araçás, Iuiu, Muquém do São Francisco, Santa Terezinha) were
* reconciled via canonicalizeMunicipalityName. Coverage is independently checked
* by the official test evidence fixture.
*/
import { isBahiaMunicipality } from '@/lib/bahiaTerritories'
export const bahiaMunicipalityCodes: Readonly<Record<string, string>> = {
"Abaíra": "2900108",
"Abaré": "2900207",
"Acajutiba": "2900306",
"Adustina": "2900355",
"Água Fria": "2900405",
"Aiquara": "2900603",
"Alagoinhas": "2900702",
"Alcobaça": "2900801",
"Almadina": "2900900",
"Amargosa": "2901007",
"Amélia Rodrigues": "2901106",
"América Dourada": "2901155",
"Anagé": "2901205",
"Andaraí": "2901304",
"Andorinha": "2901353",
"Angical": "2901403",
"Anguera": "2901502",
"Antas": "2901601",
"Antônio Cardoso": "2901700",
"Antônio Gonçalves": "2901809",
"Aporá": "2901908",
"Apuarema": "2901957",
"Araças": "2902054",
"Aracatu": "2902005",
"Araci": "2902104",
"Aramari": "2902203",
"Arataca": "2902252",
"Aratuípe": "2902302",
"Aurelino Leal": "2902401",
"Baianópolis": "2902500",
"Baixa Grande": "2902609",
"Banzaê": "2902658",
"Barra": "2902708",
"Barra da Estiva": "2902807",
"Barra do Choça": "2902906",
"Barra do Mendes": "2903003",
"Barra do Rocha": "2903102",
"Barreiras": "2903201",
"Barro Alto": "2903235",
"Barro Preto": "2903300",
"Barrocas": "2903276",
"Belmonte": "2903409",
"Belo Campo": "2903508",
"Biritinga": "2903607",
"Boa Nova": "2903706",
"Boa Vista do Tupim": "2903805",
"Bom Jesus da Lapa": "2903904",
"Bom Jesus da Serra": "2903953",
"Boninal": "2904001",
"Bonito": "2904050",
"Boquira": "2904100",
"Botuporã": "2904209",
"Brejões": "2904308",
"Brejolândia": "2904407",
"Brotas de Macaúbas": "2904506",
"Brumado": "2904605",
"Buerarema": "2904704",
"Buritirama": "2904753",
"Caatiba": "2904803",
"Cabaceiras do Paraguaçu": "2904852",
"Cachoeira": "2904902",
"Caculé": "2905008",
"Caém": "2905107",
"Caetanos": "2905156",
"Caetité": "2905206",
"Cafarnaum": "2905305",
"Cairu": "2905404",
"Caldeirão Grande": "2905503",
"Camacan": "2905602",
"Camaçari": "2905701",
"Camamu": "2905800",
"Campo Alegre de Lourdes": "2905909",
"Campo Formoso": "2906006",
"Canápolis": "2906105",
"Canarana": "2906204",
"Canavieiras": "2906303",
"Candeal": "2906402",
"Candeias": "2906501",
"Candiba": "2906600",
"Cândido Sales": "2906709",
"Cansanção": "2906808",
"Canudos": "2906824",
"Capela do Alto Alegre": "2906857",
"Capim Grosso": "2906873",
"Caraíbas": "2906899",
"Caravelas": "2906907",
"Cardeal da Silva": "2907004",
"Carinhanha": "2907103",
"Casa Nova": "2907202",
"Castro Alves": "2907301",
"Catolândia": "2907400",
"Catu": "2907509",
"Caturama": "2907558",
"Central": "2907608",
"Chorrochó": "2907707",
"Cícero Dantas": "2907806",
"Cipó": "2907905",
"Coaraci": "2908002",
"Cocos": "2908101",
"Conceição da Feira": "2908200",
"Conceição do Almeida": "2908309",
"Conceição do Coité": "2908408",
"Conceição do Jacuípe": "2908507",
"Conde": "2908606",
"Condeúba": "2908705",
"Contendas do Sincorá": "2908804",
"Coração de Maria": "2908903",
"Cordeiros": "2909000",
"Coribe": "2909109",
"Coronel João Sá": "2909208",
"Correntina": "2909307",
"Cotegipe": "2909406",
"Cravolândia": "2909505",
"Crisópolis": "2909604",
"Cristópolis": "2909703",
"Cruz das Almas": "2909802",
"Curaçá": "2909901",
"Dário Meira": "2910008",
"Dias d'Ávila": "2910057",
"Dom Basílio": "2910107",
"Dom Macedo Costa": "2910206",
"Elísio Medrado": "2910305",
"Encruzilhada": "2910404",
"Entre Rios": "2910503",
"Érico Cardoso": "2900504",
"Esplanada": "2910602",
"Euclides da Cunha": "2910701",
"Eunápolis": "2910727",
"Fátima": "2910750",
"Feira da Mata": "2910776",
"Feira de Santana": "2910800",
"Filadélfia": "2910859",
"Firmino Alves": "2910909",
"Floresta Azul": "2911006",
"Formosa do Rio Preto": "2911105",
"Gandu": "2911204",
"Gavião": "2911253",
"Gentio do Ouro": "2911303",
"Glória": "2911402",
"Gongogi": "2911501",
"Governador Mangabeira": "2911600",
"Guajeru": "2911659",
"Guanambi": "2911709",
"Guaratinga": "2911808",
"Heliópolis": "2911857",
"Iaçu": "2911907",
"Ibiassucê": "2912004",
"Ibicaraí": "2912103",
"Ibicoara": "2912202",
"Ibicuí": "2912301",
"Ibipeba": "2912400",
"Ibipitanga": "2912509",
"Ibiquera": "2912608",
"Ibirapitanga": "2912707",
"Ibirapuã": "2912806",
"Ibirataia": "2912905",
"Ibitiara": "2913002",
"Ibititá": "2913101",
"Ibotirama": "2913200",
"Ichu": "2913309",
"Igaporã": "2913408",
"Igrapiúna": "2913457",
"Iguaí": "2913507",
"Ilhéus": "2913606",
"Inhambupe": "2913705",
"Ipecaetá": "2913804",
"Ipiaú": "2913903",
"Ipirá": "2914000",
"Ipupiara": "2914109",
"Irajuba": "2914208",
"Iramaia": "2914307",
"Iraquara": "2914406",
"Irará": "2914505",
"Irecê": "2914604",
"Itabela": "2914653",
"Itaberaba": "2914703",
"Itabuna": "2914802",
"Itacaré": "2914901",
"Itaeté": "2915007",
"Itagi": "2915106",
"Itagibá": "2915205",
"Itagimirim": "2915304",
"Itaguaçu da Bahia": "2915353",
"Itaju do Colônia": "2915403",
"Itajuípe": "2915502",
"Itamaraju": "2915601",
"Itamari": "2915700",
"Itambé": "2915809",
"Itanagra": "2915908",
"Itanhém": "2916005",
"Itaparica": "2916104",
"Itapé": "2916203",
"Itapebi": "2916302",
"Itapetinga": "2916401",
"Itapicuru": "2916500",
"Itapitanga": "2916609",
"Itaquara": "2916708",
"Itarantim": "2916807",
"Itatim": "2916856",
"Itiruçu": "2916906",
"Itiúba": "2917003",
"Itororó": "2917102",
"Ituaçu": "2917201",
"Ituberá": "2917300",
"Iuiú": "2917334",
"Jaborandi": "2917359",
"Jacaraci": "2917409",
"Jacobina": "2917508",
"Jaguaquara": "2917607",
"Jaguarari": "2917706",
"Jaguaripe": "2917805",
"Jandaíra": "2917904",
"Jequié": "2918001",
"Jeremoabo": "2918100",
"Jiquiriçá": "2918209",
"Jitaúna": "2918308",
"João Dourado": "2918357",
"Juazeiro": "2918407",
"Jucuruçu": "2918456",
"Jussara": "2918506",
"Jussari": "2918555",
"Jussiape": "2918605",
"Lafaiete Coutinho": "2918704",
"Lagoa Real": "2918753",
"Laje": "2918803",
"Lajedão": "2918902",
"Lajedinho": "2919009",
"Lajedo do Tabocal": "2919058",
"Lamarão": "2919108",
"Lapão": "2919157",
"Lauro de Freitas": "2919207",
"Lençóis": "2919306",
"Licínio de Almeida": "2919405",
"Livramento de Nossa Senhora": "2919504",
"Luís Eduardo Magalhães": "2919553",
"Macajuba": "2919603",
"Macarani": "2919702",
"Macaúbas": "2919801",
"Macururé": "2919900",
"Madre de Deus": "2919926",
"Maetinga": "2919959",
"Maiquinique": "2920007",
"Mairi": "2920106",
"Malhada": "2920205",
"Malhada de Pedras": "2920304",
"Manoel Vitorino": "2920403",
"Mansidão": "2920452",
"Maracás": "2920502",
"Maragogipe": "2920601",
"Maraú": "2920700",
"Marcionílio Souza": "2920809",
"Mascote": "2920908",
"Mata de São João": "2921005",
"Matina": "2921054",
"Medeiros Neto": "2921104",
"Miguel Calmon": "2921203",
"Milagres": "2921302",
"Mirangaba": "2921401",
"Mirante": "2921450",
"Monte Santo": "2921500",
"Morpará": "2921609",
"Morro do Chapéu": "2921708",
"Mortugaba": "2921807",
"Mucugê": "2921906",
"Mucuri": "2922003",
"Mulungu do Morro": "2922052",
"Mundo Novo": "2922102",
"Muniz Ferreira": "2922201",
"Muquém de São Francisco": "2922250",
"Muritiba": "2922300",
"Mutuípe": "2922409",
"Nazaré": "2922508",
"Nilo Peçanha": "2922607",
"Nordestina": "2922656",
"Nova Canaã": "2922706",
"Nova Fátima": "2922730",
"Nova Ibiá": "2922755",
"Nova Itarana": "2922805",
"Nova Redenção": "2922854",
"Nova Soure": "2922904",
"Nova Viçosa": "2923001",
"Novo Horizonte": "2923035",
"Novo Triunfo": "2923050",
"Olindina": "2923100",
"Oliveira dos Brejinhos": "2923209",
"Ouriçangas": "2923308",
"Ourolândia": "2923357",
"Palmas de Monte Alto": "2923407",
"Palmeiras": "2923506",
"Paramirim": "2923605",
"Paratinga": "2923704",
"Paripiranga": "2923803",
"Pau Brasil": "2923902",
"Paulo Afonso": "2924009",
"Pé de Serra": "2924058",
"Pedrão": "2924108",
"Pedro Alexandre": "2924207",
"Piatã": "2924306",
"Pilão Arcado": "2924405",
"Pindaí": "2924504",
"Pindobaçu": "2924603",
"Pintadas": "2924652",
"Piraí do Norte": "2924678",
"Piripá": "2924702",
"Piritiba": "2924801",
"Planaltino": "2924900",
"Planalto": "2925006",
"Poções": "2925105",
"Pojuca": "2925204",
"Ponto Novo": "2925253",
"Porto Seguro": "2925303",
"Potiraguá": "2925402",
"Prado": "2925501",
"Presidente Dutra": "2925600",
"Presidente Jânio Quadros": "2925709",
"Presidente Tancredo Neves": "2925758",
"Queimadas": "2925808",
"Quijingue": "2925907",
"Quixabeira": "2925931",
"Rafael Jambeiro": "2925956",
"Remanso": "2926004",
"Retirolândia": "2926103",
"Riachão das Neves": "2926202",
"Riachão do Jacuípe": "2926301",
"Riacho de Santana": "2926400",
"Ribeira do Amparo": "2926509",
"Ribeira do Pombal": "2926608",
"Ribeirão do Largo": "2926657",
"Rio de Contas": "2926707",
"Rio do Antônio": "2926806",
"Rio do Pires": "2926905",
"Rio Real": "2927002",
"Rodelas": "2927101",
"Ruy Barbosa": "2927200",
"Salinas da Margarida": "2927309",
"Salvador": "2927408",
"Santa Bárbara": "2927507",
"Santa Brígida": "2927606",
"Santa Cruz Cabrália": "2927705",
"Santa Cruz da Vitória": "2927804",
"Santa Inês": "2927903",
"Santa Luzia": "2928059",
"Santa Maria da Vitória": "2928109",
"Santa Rita de Cássia": "2928406",
"Santa Teresinha": "2928505",
"Santaluz": "2928000",
"Santana": "2928208",
"Santanópolis": "2928307",
"Santo Amaro": "2928604",
"Santo Antônio de Jesus": "2928703",
"Santo Estêvão": "2928802",
"São Desidério": "2928901",
"São Domingos": "2928950",
"São Felipe": "2929107",
"São Félix": "2929008",
"São Félix do Coribe": "2929057",
"São Francisco do Conde": "2929206",
"São Gabriel": "2929255",
"São Gonçalo dos Campos": "2929305",
"São José da Vitória": "2929354",
"São José do Jacuípe": "2929370",
"São Miguel das Matas": "2929404",
"São Sebastião do Passé": "2929503",
"Sapeaçu": "2929602",
"Sátiro Dias": "2929701",
"Saubara": "2929750",
"Saúde": "2929800",
"Seabra": "2929909",
"Sebastião Laranjeiras": "2930006",
"Senhor do Bonfim": "2930105",
"Sento Sé": "2930204",
"Serra do Ramalho": "2930154",
"Serra Dourada": "2930303",
"Serra Preta": "2930402",
"Serrinha": "2930501",
"Serrolândia": "2930600",
"Simões Filho": "2930709",
"Sítio do Mato": "2930758",
"Sítio do Quinto": "2930766",
"Sobradinho": "2930774",
"Souto Soares": "2930808",
"Tabocas do Brejo Velho": "2930907",
"Tanhaçu": "2931004",
"Tanque Novo": "2931053",
"Tanquinho": "2931103",
"Taperoá": "2931202",
"Tapiramutá": "2931301",
"Teixeira de Freitas": "2931350",
"Teodoro Sampaio": "2931400",
"Teofilândia": "2931509",
"Teolândia": "2931608",
"Terra Nova": "2931707",
"Tremedal": "2931806",
"Tucano": "2931905",
"Uauá": "2932002",
"Ubaíra": "2932101",
"Ubaitaba": "2932200",
"Ubatã": "2932309",
"Uibaí": "2932408",
"Umburanas": "2932457",
"Una": "2932507",
"Urandi": "2932606",
"Uruçuca": "2932705",
"Utinga": "2932804",
"Valença": "2932903",
"Valente": "2933000",
"Várzea da Roça": "2933059",
"Várzea do Poço": "2933109",
"Várzea Nova": "2933158",
"Varzedo": "2933174",
"Vera Cruz": "2933208",
"Vereda": "2933257",
"Vitória da Conquista": "2933307",
"Wagner": "2933406",
"Wanderley": "2933455",
"Wenceslau Guimarães": "2933505",
"Xique-Xique": "2933604",
}
export const codeForMunicipality = (name: string): string | undefined => {
if (!isBahiaMunicipality(name)) return undefined
return bahiaMunicipalityCodes[name]
}
const municipalityByCode = new Map(
Object.entries(bahiaMunicipalityCodes).map(([name, code]) => [code, name]),
)
export const municipalityForCode = (codarea: string): string | undefined =>
municipalityByCode.get(codarea)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
// @vitest-environment node
import { statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
bahiaIdentityTerritoriesTopology,
bahiaMunicipalitiesTopology,
bahiaMunicipalityFeatures,
bahiaTerritoryFeatures,
getMunicipalityFeature,
getTerritoryFeature,
} from '@/lib/bahiaGeometries'
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories'
/** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */
const MAX_TOPO_BYTES = 600 * 1024
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..')
const hasNonEmptyGeometry = (
geometry: { type: string; coordinates?: unknown } | null | undefined,
): boolean => {
if (!geometry || geometry.type === 'GeometryCollection') return false
if (!Array.isArray(geometry.coordinates) || geometry.coordinates.length === 0) return false
return true
}
describe('Bahia static geometries', () => {
it('keeps committed TopoJSON under the size budget', () => {
const municipalityBytes = statSync(
join(repoRoot, 'src/lib/geometries/bahia-municipalities.topo.json'),
).size
const territoryBytes = statSync(
join(repoRoot, 'src/lib/geometries/bahia-identity-territories.topo.json'),
).size
expect(municipalityBytes).toBeGreaterThan(10_000)
expect(territoryBytes).toBeGreaterThan(1_000)
expect(municipalityBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(territoryBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
})
it('exposes 417 municipality features whose codarea set matches the code table', () => {
expect(bahiaMunicipalitiesTopology.objects.municipalities.geometries).toHaveLength(417)
expect(bahiaMunicipalityFeatures).toHaveLength(417)
const featureCodes = new Set(
bahiaMunicipalityFeatures.map((entry) => entry.properties.codarea),
)
const tableCodes = new Set(Object.values(bahiaMunicipalityCodes))
expect(featureCodes).toEqual(tableCodes)
for (const entry of bahiaMunicipalityFeatures) {
expect(entry.properties.codarea).toMatch(/^29\d{5}$/)
expect(bahiaMunicipalityCodes[entry.properties.name]).toBe(entry.properties.codarea)
expect(hasNonEmptyGeometry(entry.geometry)).toBe(true)
}
})
it('exposes 27 territory features whose codes match bahiaIdentityTerritoryRecords', () => {
expect(bahiaIdentityTerritoriesTopology.objects.territories.geometries).toHaveLength(27)
expect(bahiaTerritoryFeatures).toHaveLength(27)
const featureCodes = bahiaTerritoryFeatures
.map((entry) => entry.properties.code)
.sort()
const recordCodes = bahiaIdentityTerritoryRecords.map((entry) => entry.code).sort()
expect(featureCodes).toEqual(recordCodes)
for (const record of bahiaIdentityTerritoryRecords) {
const feature = getTerritoryFeature(record.code)
expect(feature, record.code).toBeDefined()
expect(feature?.properties.name).toBe(record.name)
expect(hasNonEmptyGeometry(feature?.geometry)).toBe(true)
}
})
it('looks up municipality and territory features by stable keys', () => {
const salvador = getMunicipalityFeature('2927408')
expect(salvador?.properties.name).toBe('Salvador')
expect(hasNonEmptyGeometry(salvador?.geometry)).toBe(true)
const irece = getTerritoryFeature('01')
expect(irece?.properties.name).toBe('Irecê')
expect(hasNonEmptyGeometry(irece?.geometry)).toBe(true)
expect(getMunicipalityFeature('0000000')).toBeUndefined()
expect(getTerritoryFeature('99')).toBeUndefined()
})
it('exposes TopoJSON object names expected by consumers', () => {
expect(bahiaMunicipalitiesTopology.type).toBe('Topology')
expect(bahiaIdentityTerritoriesTopology.type).toBe('Topology')
expect(Object.keys(bahiaMunicipalitiesTopology.objects)).toEqual(['municipalities'])
expect(Object.keys(bahiaIdentityTerritoriesTopology.objects)).toEqual(['territories'])
})
})

View file

@ -0,0 +1,82 @@
// @vitest-environment node
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
bahiaMunicipalityCodes,
codeForMunicipality,
municipalityForCode,
} from '@/lib/bahiaMunicipalityCodes'
import { bahiaMunicipalities } from '@/lib/bahiaTerritories'
type OfficialEvidence = {
municipalityCount: number
assignments: Array<{ municipality: string; code: string }>
evidenceSha256: string
}
const officialEvidence = JSON.parse(
readFileSync(
new URL('../fixtures/bahia-municipality-codes.official.json', import.meta.url),
'utf8',
),
) as OfficialEvidence
describe('Bahia municipality name → IBGE code mapping', () => {
it('covers all 417 Bahia municipalities with 7-digit BA codes', () => {
expect(Object.keys(bahiaMunicipalityCodes)).toHaveLength(417)
expect(bahiaMunicipalities).toHaveLength(417)
expect(new Set(Object.keys(bahiaMunicipalityCodes))).toHaveLength(417)
expect(new Set(Object.values(bahiaMunicipalityCodes))).toHaveLength(417)
for (const city of bahiaMunicipalities) {
const code = bahiaMunicipalityCodes[city]
expect(code, city).toBeDefined()
expect(code).toMatch(/^29\d{5}$/)
}
})
it('is a bijection with the canonical bahiaMunicipalities set', () => {
expect(Object.keys(bahiaMunicipalityCodes).sort((left, right) => left.localeCompare(right, 'pt-BR'))).toEqual(
[...bahiaMunicipalities].sort((left, right) => left.localeCompare(right, 'pt-BR')),
)
})
it('matches the independently downloaded official municipality→code evidence', () => {
expect(officialEvidence.municipalityCount).toBe(417)
expect(
officialEvidence.assignments.map(({ municipality, code }) => ({ municipality, code })),
).toEqual(
Object.entries(bahiaMunicipalityCodes)
.sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
.map(([municipality, code]) => ({ municipality, code })),
)
})
it('matches every official assignment by a fixed evidence checksum', () => {
const assignmentRows = Object.entries(bahiaMunicipalityCodes)
.sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
.map(([municipality, code]) => `M\t${municipality}\t${code}\n`)
.join('')
expect(createHash('sha256').update(assignmentRows).digest('hex')).toBe(
officialEvidence.evidenceSha256,
)
})
it('supports bidirectional lookups', () => {
expect(codeForMunicipality('Salvador')).toBe('2927408')
expect(codeForMunicipality('Feira de Santana')).toBe('2910800')
expect(codeForMunicipality("Dias d'Ávila")).toBe('2910057')
expect(codeForMunicipality('Araças')).toBe('2902054')
expect(codeForMunicipality('Município inexistente')).toBeUndefined()
expect(codeForMunicipality('')).toBeUndefined()
expect(municipalityForCode('2927408')).toBe('Salvador')
expect(municipalityForCode('2910800')).toBe('Feira de Santana')
expect(municipalityForCode('0000000')).toBeUndefined()
})
})