mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
b2856abceb
commit
e5968f81d5
14 changed files with 2854 additions and 37 deletions
|
|
@ -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
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
305
scripts/build-bahia-geometries.mjs
Normal file
305
scripts/build-bahia-geometries.mjs
Normal 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)
|
||||
})
|
||||
76
src/lib/bahiaGeometries.ts
Normal file
76
src/lib/bahiaGeometries.ts
Normal 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
|
||||
450
src/lib/bahiaMunicipalityCodes.ts
Normal file
450
src/lib/bahiaMunicipalityCodes.ts
Normal 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)
|
||||
1
src/lib/geometries/bahia-identity-territories.topo.json
Normal file
1
src/lib/geometries/bahia-identity-territories.topo.json
Normal file
File diff suppressed because one or more lines are too long
1
src/lib/geometries/bahia-municipalities.topo.json
Normal file
1
src/lib/geometries/bahia-municipalities.topo.json
Normal file
File diff suppressed because one or more lines are too long
1689
tests/fixtures/bahia-municipality-codes.official.json
vendored
Normal file
1689
tests/fixtures/bahia-municipality-codes.official.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
102
tests/int/bahiaGeometries.int.spec.ts
Normal file
102
tests/int/bahiaGeometries.int.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
82
tests/int/bahiaMunicipalityCodes.int.spec.ts
Normal file
82
tests/int/bahiaMunicipalityCodes.int.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue