Deliver B3 Leaflet choropleth maps and register post-simplify follow-ups.

Add BahiaMap surfaces on nuclei list, detail, and geral dashboard with server-side choropleth aggregation, lazy geometry loading (B5 F1), and roadmap plans for A7 F4, B5 F3, and B6 scale debts.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 17:13:27 -03:00
parent 8420969b4b
commit b8547ff292
36 changed files with 1223 additions and 145 deletions

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-A4 (baseline TSE no produto)
Status: Fase 1 implementada (Fases 2–3 pendentes; merge pendente)
Atualizado em: 2026-07-19 (Fase 1 + `capture-review-debts` pós-`/simplify` — cleanup F1 absorvido; F2 ganha quebra de ciclo de módulo)
Status: Fase 1 implementada (Fases 2–4 pendentes; merge pendente)
Atualizado em: 2026-07-19 (Fase 4 lista+coroplético registrada via `capture-review-debts` pós-B3)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A7)
Responsável: —
@ -14,10 +14,11 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
1. **Detalhe puxa todas as linhas nominais de dep. federal (1º turno) na geografia.** ~~`detailVoteWhere` + `loadElectionVotes`~~ **Resolvido na Fase 1 (2026-07-19):** `loadFederalCandidateTotalsAggregated` (`src/utilities/federalCandidateTotalsAggregate.ts`) agrega via drizzle `SUM(votes) GROUP BY candidate_number`; o detalhe usa Local API só para presidente/governador da chapa (`loadTicketOfficeVotes`). A série histórica E2 (`loadCandidateSeriesByGeography`) já era magra por `candidateNumber`.
2. **Filtro geográfico por `cityName` (texto), não por `cityCode` TSE.** O índice natural das collections de eleição é `(year, office, turn, cityCode, zoneNumber)`; filtrar por nome impede o plano ótimo e complica OR grandes na união do overview (já mitigado no simplify, mas o detalhe/TI ainda sofre).
3. **DRY de UI do Gap "acima".** `NucleusInsights` usa `Alert variant="pending"` para abaixo/neutro, mas o estado "acima" aplica classes ad-hoc com tokens `--estimate-confirmed*`. O slice **A5-1 conversão** reutiliza o mesmo `confirmedInsightAlertClass` para o alerta de taxa de conversão (reduto/consolidado) — interim até a Fase 3. A barra do candidato no card também reimplementa `role="progressbar"` em vez de reusar `Progress` (`NucleusListOverview`).
4. **Query duplicada de votos 2022 na lista de núcleos com coroplético B3.** `nucleusListOverviewPageData.ts` chama em paralelo `loadNucleusListElectionOverview` (union → `loadCandidateSeriesByGeography` multi-ano) e `loadNucleusChoroplethBundle` → `loadBaseline2022VotesByCityNames` (segunda passagem `loadCandidateSeriesByGeography` só 2022 sobre geografia sobreposta). O simplify B3 unificou resolve interno do choropleth, mas não compartilha o resultado da série 2022 já obtida no loader de gap/tendência/conversão.
**Já resolvido no simplify pós-F1 / `capture-review-debts` (não reabrir):** remoção de `cities` derivado em `NucleusElectionGeography` (só `zonesByCity` + `cityZonePairs`); `assertCanReadElectionData` com `asserts` e entrada `CampaignUser | User | null | undefined`; testes reais de `loadFederalCandidateTotalsAggregated` (mock drizzle) em `federalCandidateTotalsAggregate.unit.spec.ts`; int spy estrutural (`whereContainsField*`) em vez de `JSON.stringify(where)`; inline do SQL builder federal; comentário em `aggregateFederalCandidateTotals` como oracle in-memory para unit tests.
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível).
**Explicitamente fora (revisores pediram skip no simplify ou descartados no triage):** map JSX de linhas Lula/Jerônimo, dropar `candidate.rank` / `electorate.aptos` / `ratio` do tipo público só por higiene (só fariam sentido se a F1 deixar de calcular rank), cast `as unknown as RawOverviewNucleus` da B1 (não é débito novo do A4), parity test drizzle vs `aggregateFederalCandidateTotals` (int + unit cobrem comportamento), typed drizzle rows/zod no `drizzleResultRows` (padrão plataforma C6/C8), `requirePostgresDrizzle` em `supporterListOverviewAggregate.ts` (fora do escopo A7 — DRY de ~6 linhas; ver C10 quando o plano existir), micro-opt `aggregateElectorate`/`matchesGeography` com `Set` (impacto negligível), `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)), factory geometrias mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)).
## Objetivos
@ -28,7 +29,7 @@ Os revisores (performance / reuse / quality) marcaram como **importantes e maior
## Decisões travadas
- **Um item A7, três fases ordenadas.** Mesmo racional do B5/C7/C8: um ID de roadmap, PRs por fase. Ordem: agregação do detalhe (custo real) → `cityCode` (escala BA-wide) → DRY de Alert/Progress (barato, UI).
- **Um item A7, quatro fases ordenadas.** Mesmo racional do B5/C7/C8/E7: um ID de roadmap, PRs por fase. Ordem: agregação do detalhe (custo real) → `cityCode` (escala BA-wide) → DRY de Alert/Progress (barato, UI) → fetch único 2022 lista+coroplético (B3).
- **Dependência dura de A4.** Só faz sentido com o loader/UI já no produto; não reabre o escopo do Gap vs 2022 nem do card Baseline.
- **Fase 1 não remove o "mais votado aqui" da UI.** O produto do A4 inclui `winnerFederal`; a otimização agrega no SQL/drizzle (ou limita o payload), não corta a feature. Se produto decidir que rank/winner saem do card, a F1 encolhe para "só o candidato da chapa + tallies".
- **Cortável se a geografia real dos núcleos permanecer pequena** (1–2 municípios tipados). Vira não-cortável de qualidade quando A5/B3 passarem a martelar o mesmo helper em TI inteiro.
@ -50,8 +51,10 @@ flowchart TD
F1["Fase 1 — Agregar federal no detalhe<br/>(GROUP BY / payload estreito)"]
F1 --> F2["Fase 2 — Geografia por cityCode TSE"]
F1 --> F3["Fase 3 — Alert confirmed + Progress"]
F1 --> F4["Fase 4 — Fetch 2022 único<br/>lista overview + coroplético"]
F1 -.mesmo loader.-> A5["A5 Insights"]
F1 -.métricas.-> B3["B3 Leaflet coroplético"]
F4 -.evita 2ª query.-> B3
```
### Fase 1 — Agregação do ranking federal no detalhe ✓ (2026-07-19)
@ -74,7 +77,15 @@ flowchart TD
- `NucleusInsights`: trocar o `cn(...)` ad-hoc e `confirmedInsightAlertClass` (Gap "acima" **e** conversão reduto/consolidado desde A5-1) por `variant={… ? 'confirmed' : 'pending'}` (ou `default` só se neutro — manter pending para noBaseline/noEstimate).
- `NucleusElectoralBaseline`: substituir a barra custom por `Progress` (já usado no overview), preservando `aria-label`.
**Migration:** nenhuma nas Fases 1 e 3. Fase 2 preferencialmente só artefato estático versionado; índice Postgres extra só se o EXPLAIN ainda doer após `cityCode` (aí `pnpm migrate:create` cortável).
### Fase 4 — Fetch único de votos 2022 (lista overview + coroplético B3)
- `loadNucleusListElectionOverview` já obtém `votes2022` via `loadCandidateSeriesByGeography` na união do conjunto filtrado — expor helper ou retorno auxiliar com `Map<cityName, votes>` (ou reutilizar `sumCandidateVotesForGeography` por cidade) sem segunda query.
- `loadNucleusChoroplethBundle` / `nucleusListOverviewPageData.ts`: aceitar `baselineVotesByCity` pré-carregado do overview (ou função `loadNucleusListOverviewElectionAndChoropleth` que faz uma passagem de série + monta gap/trend/conversão + bundle).
- Manter `loadBaseline2022VotesByCityNames` para call sites isolados (dashboard geral, detalhe) até um follow-up opcional.
- Testes: int ou unit garantindo que overview com coroplético não chama `loadCandidateSeriesByGeography` duas vezes para 2022 (spy estrutural como Fase 1 federal).
- **Verificar:** paridade numérica do coroplético `baseline2022Votes` antes/depois.
**Migration:** nenhuma nas Fases 1, 3 e 4. Fase 2 preferencialmente só artefato estático versionado; índice Postgres extra só se o EXPLAIN ainda doer após `cityCode` (aí `pnpm migrate:create` cortável).
## Dependências
@ -86,7 +97,8 @@ flowchart TD
- Novos insights de produto → **A5** (planos `insight-*.md`).
- Dobradinha 2026 → **A6**.
- Lazy load de geometrias / cache CLI → **B5**.
- Lazy load de geometrias / cache CLI / factory mun-TI → **B5** (F1 ✓, F2–F3 pendentes).
- `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)).
- Escala de apoiadores / agenda → **C6/C7/C8**.
- Remover rank/winner da UI por decisão de produto sem agregação — fora; se produto cortar, fechar F1 como "só chapa + tallies".
- Tipagem genérica dos `select` Payload do overview B1.
@ -105,4 +117,6 @@ flowchart TD
- `src/components/campaign/NucleusElectoralBaseline.tsx` — barra do candidato
- `src/components/ui/Alert.tsx` — variant `pending` a espelhar
- `src/components/ui/Progress.tsx` — reuso no card
- `src/utilities/nucleusListOverviewPageData.ts` — `Promise.all` overview + choropleth
- `src/utilities/nucleusChoroplethPageData.ts` — `loadBaseline2022VotesByCityNames`
- AGENTS.md — Election baseline data; Local API `overrideAccess: false`; naming inglês

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-B2 (geometrias + scripts CLI)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-18
Status: Fase 1 entregue com B3 (2026-07-19); Fases 2–3 pendentes
Atualizado em: 2026-07-19 (Fase 3 factory geometrias registrada via `capture-review-debts` pós-B3)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B5)
Responsável: —
@ -11,6 +11,7 @@ O B2 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 1) entregou a fu
1. **Carregamento eager de geometrias no cliente.** `src/lib/bahiaGeometries.ts` importa os dois TopoJSON, roda `topojson-client` `feature()` em 417+27 polígonos e monta índices no load do módulo — custo de bundle/memória antes de qualquer superfície Leaflet (B3).
2. **Cache de download duplicado nos CLIs.** `ensureCachedJson` em `scripts/build-bahia-geometries.mjs` é estruturalmente o mesmo que `ensureCachedZip` em `scripts/seed-tse-results.mjs` (mkdir → hit → `downloadToBuffer` → SHA-256 → write), só muda extensão/parse.
3. **Factory duplicada mun/TI pós-B3 F1.** `bahiaMunicipalityGeometries.ts` e `bahiaTerritoryGeometries.ts` repetem o mesmo pipeline `topojson.feature` → array → `Map` por chave → export do módulo indexado — só mudam topology object, propriedade de chave (`codarea` vs `code`) e getter.
Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarcar o mapa com payload desnecessário no path default, e o próximo script de dado aberto volta a clonar o cache.
@ -24,9 +25,9 @@ Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarca
## Decisões travadas
- **Um item B5, duas fases ordenadas.** Mesmo racional do C6/C7: um ID de roadmap, PRs por fase. Ordem: lazy load (impacta B3) → helper de cache CLI (barato, sem UI).
- **Um item B5, três fases ordenadas.** Mesmo racional do C6/C7/A7: um ID de roadmap, PRs por fase. Ordem: lazy load (impacta B3) → helper de cache CLI (barato, sem UI) → factory geometrias mun/TI (DRY pós-split B3).
- **Dependência dura de B2.** Este item só faz sentido com os `*.topo.json` e `bahiaGeometries` já commitados; não reabre o escopo de geração/simplificação das malhas.
- **Fase 1 preferencialmente junto com B3.** Se o implementador de B3 aplicar o carregamento lazy no mesmo PR das superfícies Leaflet, a Fase 1 deste plano fecha ali (marcar B5 F1 ✓ e seguir só a F2). Se B3 embarcar import estático por pressa, a F1 permanece como débito explícito.
- **2026-07-19 (Fase 1 entregue com B3):** lazy load split mun/TI via `loadMunicipalityGeometryModule` / `loadTerritoryGeometryModule`; map islands `dynamic(..., { ssr: false })`.
- **Cortável se o mapa permanecer fora do caminho crítico.** B3 já é corte seguro no roadmap; B5 F2 (cache CLI) é ainda mais cortável — a duplicação atual é ~20 linhas. F1 só é “não cortável” no sentido de qualidade do B3 (bundle), não de calendário eleitoral.
- **Não extrair helpers de sort pt-BR nem de evidence SHA nos testes.** Decisão dos revisores do simplify (2026-07-18): indirection sem ganho.
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadMunicipalityFeatures`, `loadTerritoryFeatures`, `ensureCachedDownload`), strings visíveis em pt-BR só se algum log de CLI for user-facing (hoje são logs de build).
@ -44,6 +45,7 @@ flowchart TD
B2["B2 geometrias ✓"] --> F1
F1["Fase 1 — Lazy geometrias<br/>split mun/TI + dynamic import"]
F1 --> F2["Fase 2 — ensureCachedDownload<br/>CLI compartilhado"]
F1 --> F3["Fase 3 — buildGeometryModuleFromTopology<br/>(mun/TI DRY)"]
F1 -.preferencialmente.-> B3["B3 Leaflet"]
B2 --> B3
```
@ -63,7 +65,14 @@ flowchart TD
- Continuar usando `downloadToBuffer` (`src/lib/electionResultsZip.ts`).
- Migrar `scripts/build-bahia-geometries.mjs` e `scripts/seed-tse-results.mjs`; sem mudança de comportamento nem de pastas `data/geometries/` / `data/tse/`.
**Migration:** nenhuma. Sem collection, sem Consent, sem server action de produto.
### Fase 3 — Factory compartilhada mun/TI
- Extrair `buildGeometryModuleFromTopology<TProps, TKey>({ topology, objectName, keyProperty, getFeature })` em `src/lib/bahiaGeometryModuleFactory.ts` (ou ao lado de `bahiaGeometriesTypes.ts`).
- `bahiaMunicipalityGeometries.ts` / `bahiaTerritoryGeometries.ts` ficam como thin wrappers: import JSON + chamada à factory com `codarea` / `code`.
- Testes int existentes (`bahiaGeometries.int.spec.ts`) permanecem verdes — sem mudança de API pública dos loaders async.
- **Não** fundir os dois JSON num único bundle (mantém split lazy B5 F1).
**Migration:** nenhuma nas três fases. Sem collection, sem Consent, sem server action de produto.
## Dependências
@ -76,6 +85,8 @@ flowchart TD
- Implementação Leaflet / componentes de mapa → **B3** ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 2).
- Camada de zonas TSE no mapa → **B4**.
- Helper genérico de `localeCompare('pt-BR')` ou de evidence SHA nos testes int — rejeitado no simplify.
- `BahiaMap` `setStyle` incremental → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)).
- Query duplicada lista+coroplético 2022 → **A7 F4** ([escala-dry-pos-a4.md](escala-dry-pos-a4.md)).
- Mudança de qualidade/simplificação das malhas IBGE ou re-download obrigatório — só se a F2 tocar proveniência (não deve).
## Referências
@ -83,7 +94,9 @@ flowchart TD
- `docs/roadmap.md` (Trilha B, item B5; B2/B3)
- `docs/plans/mapa-bahia-geometrias.md` — plano pai; B3 deve preferir a F1 deste item
- `docs/plans/escala-dry-pos-c2.md` / `escala-dry-pos-c3.md` — precedente de registro pós-`/simplify`
- `src/lib/bahiaGeometries.ts` — eager `feature()` + índices
- `src/lib/bahiaGeometries.ts` — lazy loaders async
- `src/lib/bahiaMunicipalityGeometries.ts` / `bahiaTerritoryGeometries.ts` — near-duplicate pós-F1
- `src/lib/bahiaGeometriesTypes.ts` — tipos `*GeometryModule`
- `scripts/build-bahia-geometries.mjs` — `ensureCachedJson`
- `scripts/seed-tse-results.mjs` — `ensureCachedZip`
- `src/lib/electionResultsZip.ts` — `downloadToBuffer`

View file

@ -0,0 +1,85 @@
# Escala e DRY pós-B3 (Leaflet / coroplético)
Status: registrado no roadmap (fases pendentes)
Atualizado em: 2026-07-19 (`capture-review-debts` pós-B3 + 2× `/simplify`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B6)
Impeccable: A — N/A (sem superfície UI nova; otimização do renderer Leaflet existente)
Responsável: —
## Contexto
O B3 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 2) entrega `BahiaMap` + embeds (`NucleusOverviewMap`, `NucleusDetailMap`, `DashboardMap`), agregação servidor em `nucleusChoropleth.ts` / `nucleusChoroplethPageData.ts`, e lazy load de geometrias (B5 F1 ✓). Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: `createCampaignClientDynamic`, `resolveChoroplethNuclei` / single-pass bundle, remoção de `geographyForCityNames` duplicado, `toNucleusElectionGeographyInput` no dashboard, highlight O(k) via `getMunicipalityFeature` / `getTerritoryFeature`, `NucleusDetailMap` `useMemo`, alinhamento de tipos com `NucleusElectionGeographyInput`.
Os revisores (performance) marcaram como **importante e maior que simplify** o follow-up abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos.
1. **`BahiaMap` rebuild completo do layer GeoJSON** quando só `values` (métrica) ou `highlightKeys` mudam. O effect em `BahiaMap.tsx` depende de `[highlightKey, mode, values]` e, a cada mudança, remove o layer, recria `L.geoJSON` com todas as features e reaplica estilos — mesmo que `mode` e o módulo de geometria já estejam carregados. O caminho correto para troca de métrica é `layer.setStyle(...)` (e `fitBounds` só quando o highlight muda).
**Já resolvido no simplify/critique (não reabrir):** B5 F1 lazy split mun/TI + `dynamic(..., { ssr: false })`; `createCampaignClientDynamic`; choropleth single-resolve (`resolveChoroplethNuclei`); DRY `geographyForCityNames` → `resolveNucleusElectionGeography`; dashboard `toNucleusElectionGeographyInput`; `ChoroplethMapPanel` passa `max`; detail map `useMemo`; highlight bounds O(k); type alignment `NucleusChoroplethNucleus`.
**Explicitamente fora (revisores pediram skip ou triage descartou):** colapsar shells finos `DashboardMap` / `NucleusOverviewMap`; `CAMPAIGN_BRAND_PRIMARY` para `#c51414`; `codeForIdentityTerritory` em `bahiaTerritories.ts`; `catch` vazio no `BahiaMap` (logging opcional); flake int `campaignNucleusListOverview` (`totalFiltered` — isolamento de DB pré-existente); métricas E1/E2 no coroplético v1 (decisão de produto B3); critique Impeccable formal nas três superfícies (polish leve sem P0–P3).
## Objetivos
- Trocar métrica (núcleos / estimativa / baseline 2022) ou modo município/TI no coroplético **não** recria o layer GeoJSON inteiro — apenas atualiza `fillColor` / borda via `setStyle` quando a geometria já está montada.
- `fitBounds` roda só quando `highlightKeys` ou `mode` mudam, não a cada alteração de `values`.
- Guardrails: sem migration, sem collection, sem Consent, sem server action. Client-only; manter acessibilidade (`role="img"`, `aria-busy`, `aria-label`).
## Decisões travadas
- **Um item B6, uma fase.** Mesmo racional de débitos pontuais pós-entrega (VR+, RS+): um ID, PR único.
- **Dependência suave de B3.** Só faz sentido com `BahiaMap` no produto; não reabre escopo de coroplético, tiles ou agregação servidor.
- **Query duplicada de votos 2022 na lista de núcleos** (`loadNucleusListElectionOverview` + `loadNucleusChoroplethBundle`) **não** entra neste plano — absorvida como **A7 Fase 4** em [escala-dry-pos-a4.md](escala-dry-pos-a4.md) (mesmo módulo `nucleusElectoralBaseline.ts`).
- **Factory mun/TI** near-duplicate → **B5 Fase 3** em [escala-dry-pos-b2.md](escala-dry-pos-b2.md).
- **Cortável** se o mapa for raramente usado ou se troca de métrica permanecer aceitável em campo; vira não-cortável quando coordenadores alternarem métricas com frequência no overview filtrado.
- **i18n e naming** (AGENTS.md): identificadores em inglês; strings visíveis em pt-BR inalteradas.
## Questões em aberto
- **Split em dois effects ou ref de layer estável?** **Recomendação:** effect 1 monta mapa + tiles + carrega geometria por `mode` (deps `[mode]`); effect 2 aplica estilo quando `[values, highlightKey]` mudam — se `layerRef.current` existe, `setStyle` + `fitBounds` condicional; senão aguarda effect 1.
- **Manter `values` como objeto novo a cada render do parent?** **Recomendação:** não bloquear Fase 1 — `ChoroplethMapPanel` já deriva `values` estáveis por métrica; se profiling mostrar churn, memoizar no parent num follow-up micro (fora de escopo).
## Abordagem proposta
```mermaid
flowchart TD
B3["B3 Leaflet + coroplético"] --> F1
B5F1["B5 F1 lazy geometrias ✓"] --> F1
F1["Fase 1 — BahiaMap setStyle<br/>(split effects geometria vs estilo)"]
F1 -.não bloqueia.-> B4["B4 Zonas TSE no mapa"]
```
### Fase 1 — `setStyle` incremental no `BahiaMap`
- `src/components/campaign/BahiaMap.tsx`:
- Separar mount do mapa/OSM (`useEffect` once) do load de geometria por `mode`.
- Guardar `geometryModule` em ref após primeiro load; recriar layer só quando `mode` muda.
- Em updates de `values` / `highlightKey`: iterar `layerRef.current.eachLayer` ou manter `Map<featureKey, layer>` e chamar `setStyle` com `choroplethFillColor` / borda highlight.
- `fitBounds`: highlight quando `highlightSet` não vazio; senão `BAHIA_BOUNDS` — só quando highlight ou `mode` mudam.
- Testes: unit leve com mock de `L.geoJSON` / spy em `setStyle` se viável; senão checklist manual (trocar métrica 3× no overview sem flicker perceptível).
- Critério: DevTools Performance — troca de métrica não dispara novo `import()` de topojson nem aloca 400+ layers.
**Migration:** nenhuma.
## Dependências
- **Suave:** B3 Mapa Fase 2 (Leaflet) — implementado no branch; merge pendente.
- **Suave:** B5 F1 lazy geometrias — entregue com B3.
- Reusa: `choroplethFillColor` / `choroplethMaxValue` (`src/lib/choroplethColorScale.ts`), loaders `loadMunicipalityGeometryModule` / `loadTerritoryGeometryModule`, tipos `ChoroplethValues`.
## Não escopo
- Query duplicada overview + coroplético na lista → **A7 F4** ([escala-dry-pos-a4.md](escala-dry-pos-a4.md)).
- Factory TopoJSON mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)).
- Camada de zonas TSE, rota `/mapa`, métricas tendência/prioridade no coroplético → **B3/B4/E1/E2**.
- `react-leaflet` — manter `leaflet` direto conforme B3.
## Referências
- `docs/roadmap.md` (Trilha B, item B6; B3/B5)
- `docs/plans/mapa-bahia-geometrias.md` — plano pai B3
- `docs/plans/escala-dry-pos-b2.md` — B5 F1/F3
- `docs/plans/escala-dry-pos-a4.md` — A7 F4 (query duplicada)
- `src/components/campaign/BahiaMap.tsx` — effect que rebuilda layer
- `src/components/campaign/ChoroplethMapPanel.tsx` — troca de métrica
- `src/lib/bahiaGeometries.ts` — lazy loaders
- AGENTS.md — naming inglês; B3 sem migration

View file

@ -1,7 +1,7 @@
# Mapa da Bahia na campanha — geometrias estáticas + Leaflet
Status: Fase 1 entregue (B2); Fase 2 (B3 Leaflet) pendente
Atualizado em: 2026-07-18
Status: Fase 1 entregue (B2); Fase 2 (B3 Leaflet) em implementação
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) — B2 (Fase 1) / B3 (Fase 2) / B4 (camada de zonas); lazy load e DRY de scripts → [B5](escala-dry-pos-b2.md)
Responsável: —
@ -19,7 +19,10 @@ O `electoralNucleus` indexa território por texto (`regions`/`cities`/`neighborh
- **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.
- **Sem migration, sem collection, sem Consent, sem server action nova.** Só dado estático + componentes cliente + agregações por chave (`codarea` / código TI) via `nucleusChoropleth.ts`.
- **Alocação multi-município (B3):** estimativa confirmada dividida igualmente entre `cities[]`; contagem de núcleos +1 por cidade coberta; baseline 2022 por voto TSE por cidade (sem split, sem dupla contagem na união do conjunto filtrado).
- **Métricas v1 do coroplético:** nº de núcleos, estimativa confirmada, votos Solla 2022 (quando seed A3 existir). Tendência E2 e prioridade E1 ficam fora desta entrega.
- **Tiles:** OpenStreetMap raster com atribuição; altura ~280–360px; tokens `data-theme='campaign'`.
## Por que estático e não PostGIS
@ -55,7 +58,7 @@ Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação munic
- `scripts/build-bahia-geometries.mjs` (`pnpm build:geometries`): baixa IBGE Malhas + Localidades (via `downloadToBuffer` de `electionResultsZip.ts`), reconcilia nomes com `canonicalizeMunicipalityName` (só `UnknownMunicipalityError`), constrói topologia com `topojson-server` + `topojson-simplify` (`SIMPLIFY_QUANTILE = 0.35`) + `quantize` (`1e4`), dissolve territórios com `topojson-client` `merge`, emite os `*.topo.json`, `bahiaMunicipalityCodes.ts` e o fixture. Cache em `data/geometries/` (gitignored; `GEOMETRIES_CACHE_DIR`). **Não toca banco** — sem `assertLocalDatabase`. Não roda em build/dev — só quando se atualiza a versão de origem.
## Leaflet na campanha (B3 — pendente)
## Leaflet na campanha (B3 — em implementação)
- 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.
- **Lazy load (B5 F1):** não importar `bahiaGeometries` de forma eager no path default de `/campanha`. Preferir split município/TI + `import()` dinâmico no mount do mapa — ver [escala-dry-pos-b2.md](escala-dry-pos-b2.md). Se B3 aplicar isso no mesmo PR, fecha B5 Fase 1.
@ -65,14 +68,15 @@ Hierarquia TSE: município → (1+) zona → (muitas) seção; a relação munic
- `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` / `nucleusListOverviewPageData` e expor agregados por `codarea`/`code` de território; o cliente junta com a geometria. Sem query espacial.
- **Agregação:** servidor continua a fonte. `nucleusChoropleth.ts` expõe `buildNucleusChoroplethBundle` (métricas por `codarea` e código TI); `loadNucleusListOverviewData` e `getCampaignDashboardPageData` (geral) anexam o bundle ao view model. O cliente junta com a geometria lazy-loaded. Sem query espacial.
- **Soft deps entregues (2026-07-19):** A4 baseline no produto, E1 metas/prioridade, E2 tendência — o coroplético v1 usa baseline 2022; tendência/prioridade ficam para ciclo seguinte.
## Phasing
- **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. Preferir fechar [B5 F1](escala-dry-pos-b2.md) (lazy geometrias) no mesmo PR.
- **Fase 2 — Leaflet nas superfícies (B3):** `BahiaMap` + `NucleusOverviewMap` + `NucleusDetailMap` + `DashboardMap`, com `nucleusChoropleth`. Fecha [B5 F1](escala-dry-pos-b2.md) (lazy geometrias) no mesmo PR.
**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. B5 F1 é dependência suave de qualidade de bundle.
**Sequenciamento:** a Fase 1 é independente e paralelizável. A Fase 2 rende mais depois de B1 ✓ (overview); A4 ✓, E1 ✓ e E2 ✓ já ampliam as métricas disponíveis. B5 F1 fecha no mesmo PR que B3.
## Entrega as-built (B2, 2026-07-18)
@ -115,6 +119,7 @@ Após A2 ✓ + B3. Polígono por dissolução dos municípios membros (abordagem
- **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.
- **2026-07-18 (B5 registrado):** follow-ups do `/simplify` que não entraram no cleanup (lazy geometrias + cache CLI compartilhado) viraram item B5; B3 deve preferir F1.
- **2026-07-19 (auditoria pré-B3):** overview/dashboard não tinham `Record<codarea|code, number>` — corrigido com `nucleusChoropleth.ts`; soft deps A4/E1/E2 marcadas entregues; alocação multi-município e métricas v1 documentadas; B5 F1 no mesmo PR.
## Referências

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); **A7 Fase 1 entregue** (agregação drizzle ranking federal; merge pendente; Fases 2–3 pendentes) — [plano](plans/escala-dry-pos-a4.md); C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); **A7 Fase 1 entregue** (agregação drizzle ranking federal; merge pendente; Fases 2–4 pendentes, F4 lista+coroplético via `capture-review-debts` pós-B3) — [plano](plans/escala-dry-pos-a4.md); C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 (F1 ✓ com B3; Fases 2–3 pendentes, F3 factory via `capture-review-debts` pós-B3); **B6 registrado** — escala/DRY pós-B3 (`BahiaMap` setStyle) — [plano](plans/escala-dry-pos-b3.md); **Trilha E registrada — E1–E5 mapa de projeção por município**, **E1 metas em cenários + prioridade e E3 dobradinhas/encaminhamentos manuais implementados e mesclados em `main` 2026-07-19** (migration `20260719_054522_add_nucleus_goals_strategy`; `/simplify` → E6); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md))
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.
@ -65,13 +65,14 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
### Ciclo 2+ — A4 implementado e mesclado em `main` (2026-07-18)
- **A4 Baseline no produto + Gap vs 2022** — agregação `getNucleusElectoralBaseline` / `loadNucleusBaseline2022Overview` + `computeGapVs2022`; bloco "Baseline eleitoral 2022" e insight Gap na aba Visão geral do núcleo; card "Baseline 2022" no overview da lista filtrada. Chapa tipada via `BASELINE_TICKET_2022` (papéis `candidate` / `president` / `governor`). Sem migration, sem Consent (dado público TSE). Três passagens `/simplify` no PR; débitos maiores → A7. [Plano](plans/baseline-eleitoral-tse.md).
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–3 pendentes (`cityCode` TSE, Alert `confirmed` + `Progress`). Passagem `/simplify` + `capture-review-debts` absorveu cleanup F1 no plano. [Plano](plans/escala-dry-pos-a4.md).
- **A7 Escala e DRY pós-A4 — Fase 1 entregue (2026-07-19; merge pendente)** — agregação drizzle do ranking federal no detalhe (`loadFederalCandidateTotalsAggregated`); Fases 2–4 pendentes (`cityCode` TSE, Alert `confirmed` + `Progress`, fetch único 2022 lista+coroplético B3). Passagem `/simplify` + `capture-review-debts` absorveu cleanup F1 e registrou F4 pós-B3. [Plano](plans/escala-dry-pos-a4.md).
- **A8 Perfis do eleitorado IBGE registrado (2026-07-19)** — perfil médio/comum do território derivado de indicadores municipais do Censo IBGE (artefato estático + ponderação por `cities[]`), exibido na aba Eleitorado junto com `voterProfiles` manuais já existentes; sem migration, sem Consent. [Plano](plans/perfil-eleitorado-ibge.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).
- **B5 Escala e DRY pós-B2 registrado (2026-07-18)** — débitos do `/simplify` que não entraram no cleanup: lazy load de geometrias (split mun/TI + dynamic import, preferencialmente com B3) e helper compartilhado de cache CLI (`ensureCachedDownload` para `build:geometries` / `db:seed:tse`). [Plano](plans/escala-dry-pos-b2.md).
- **B5 Escala e DRY pós-B2 — F1 entregue com B3 (2026-07-19); Fases 2–3 pendentes** — lazy load de geometrias (split mun/TI + dynamic import) ✓; helper compartilhado de cache CLI (`ensureCachedDownload`) e factory mun/TI (`buildGeometryModuleFromTopology`) pendentes. `capture-review-debts` pós-B3 registrou F3. [Plano](plans/escala-dry-pos-b2.md).
- **B6 Escala e DRY pós-B3 registrado (2026-07-19)** — `BahiaMap` `setStyle` incremental (evitar rebuild GeoJSON a cada troca de métrica). `capture-review-debts` pós-2× `/simplify` B3. [Plano](plans/escala-dry-pos-b3.md).
### Ciclo 2+ — C2 e C3 mesclados em `main` (2026-07-18)
@ -109,6 +110,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
| A3/A4 Baseline TSE 2022 + Gap vs 2022 | `Baseline-Eleitoral-2022` (+ bloco "Baseline 2022" em `Lista-Nucleos-Overview`) | [baseline-eleitoral-tse.md](plans/baseline-eleitoral-tse.md) |
| A5 Insights (5 planos) | `Baseline-Eleitoral-2022` (card "Insights do território", uma linha por insight) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) (Fase 1 conversão da base + Fase 2 virada: vencedor dir. **ou** share dir. alto) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) |
| A7 Escala e DRY pós-A4 | — (sem design novo; reusa baseline A4 + Alert/Progress) | [escala-dry-pos-a4.md](plans/escala-dry-pos-a4.md) |
| B6 Escala e DRY pós-B3 | — (sem design novo; reusa mapa B3) | [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md) |
| A8 Perfis do eleitorado (IBGE + manuais) | — (sem design nesta leva; reusa aba Eleitorado + `NucleusIntelligenceDialog`) | [perfil-eleitorado-ibge.md](plans/perfil-eleitorado-ibge.md) |
| C2 Cadastro nominal de apoiadores | `Apoiadores-Lista`, `Apoiador-Ficha`, `Importar-CSV-Apoiadores` | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) |
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
@ -149,7 +151,7 @@ flowchart TD
A2["A2 Zonas TSE + sugestões cruzadas<br/>(chips opt-in território↔ZE) ✓"]
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections) ✓"]
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview) ✓"]
A7["A7 Escala e DRY pós-A4<br/>(F1 agregar federal ✓ / cityCode / Alert)"]
A7["A7 Escala e DRY pós-A4<br/>(F1 agregar federal ✓ / cityCode / Alert / F4 fetch 2022)"]
A5["A5 Insights: conversão, classificação,<br/>alavancagem (+virada), mobilização, competitiva"]
A6["A6 Dobradinha 2026"]
A8["A8 Perfis eleitorado IBGE<br/>(médio calculado + manuais)"]
@ -158,8 +160,9 @@ 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) ✓"]
B5["B5 Escala e DRY pós-B2<br/>(lazy geometrias / cache CLI)"]
B5["B5 Escala e DRY pós-B2<br/>(F1 lazy ✓ / cache CLI / factory)"]
B3["B3 Mapa Fase 2 (Leaflet nas superfícies)"]
B6["B6 Escala e DRY pós-B3<br/>(setStyle Leaflet)"]
B4["B4 Camada de zonas TSE no mapa"]
end
@ -207,7 +210,9 @@ flowchart TD
B2 --> B3
B2 --> B5
B5 -.lazy geometrias.-> B3
B3 --> B6
A4 -.métricas do coroplético.-> B3
A7 -.fetch 2022 único.-> B3
A2 --> B4
B3 --> B4
A1 --> C3
@ -244,7 +249,7 @@ flowchart TD
E6 -.geo cache.-> E7
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓), **FD+** / **FD2** (após polish Field Desk em branch) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9, D1, **E1, E2 e E3** — ver "Ciclo 2", "Ciclo 2+" e "Ciclo 3" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), **E7 (após E2 ✓; Fase 2 int preferível antes de E4)**, **E6 (após E1+E3 ✓)**, **E4 (após E1+E3 ✓; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓), **FD+** / **FD2** (após polish Field Desk em branch) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9, D1, **E1, E2 e E3** — ver "Ciclo 2", "Ciclo 2+" e "Ciclo 3" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2; F4 reduz custo do coroplético na lista), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), **E7 (após E2 ✓; Fase 2 int preferível antes de E4)**, **E6 (após E1+E3 ✓)**, **E4 (após E1+E3 ✓; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; F1 ✓ com B3; F2–F3 pendentes), **B6 (após B3; suave: A7 F4)**, C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@ -284,21 +289,22 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 ✓ + E3 ✓ (suave: E2 ✓ — tendência visível na revisão; E6 F1 — perf da lista) | A5, A7, A8, B3, B5, C4, D2 |
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto; alavancagem inclui Fase 2 virada por vencedor **ou** share da direita) | [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 | A7, A8, B3, B5, C4, E4, D1 |
| 20 | A8 Perfis do eleitorado — perfil médio IBGE (Censo) + manuais na aba Eleitorado _(cortável: manuais já existem)_ | [detalhes](plans/perfil-eleitorado-ibge.md) | A1 ✓ (suave: B2 ✓ — `codarea`) | A5, A7, B3, B5, C4, E4, D1 |
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + Progress) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–3 pendentes; `/simplify` + `capture-review-debts` absorvidos no plano)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, C4, E4, D1 |
| 22 | 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 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, C4, E4, D1 |
| 23 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, A8, B3, C4, E4, D1 |
| 24 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, A8, B3, B5, E4, D1 |
| 25 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 26 | 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, A7, A8, B3, B5, C4, E4 |
| 21 | A7 Escala e DRY pós-A4 (`cityCode` TSE, Alert `confirmed` + `Progress`, fetch único 2022 lista+coroplético) _(Fase 1 agregar federal entregue 2026-07-19; Fases 2–4 pendentes; `capture-review-debts` pós-B3 registrou F4)_ | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, A8, B3, B5, B6, C4, E4, D1 |
| 22 | 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 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, A8, B5, B6, C4, E4, D1 |
| 23 | B5 Escala e DRY pós-B2 (cache CLI + factory mun/TI; F1 lazy ✓) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 entregue com B3) | A5, A7, A8, B3, B6, C4, E4, D1 |
| 24 | B6 Escala e DRY pós-B3 (`BahiaMap` setStyle incremental) _(cortável se troca de métrica for rara)_ | [detalhes](plans/escala-dry-pos-b3.md) | B3 (suave: A7 F4 na lista) | A5, A7, A8, B5, C4, E4, D1 |
| 25 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, A8, B3, B5, B6, E4, D1 |
| 26 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
| 27 | 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, A7, A8, B3, B5, B6, C4, E4 |
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
| Ordem | Item | Plano | Depende de |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 27 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 28 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 29 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
| 30 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
| 28 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
| 29 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
| 30 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
| 31 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
@ -313,7 +319,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): **O0+** (ap
- **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1, F3 e F4** (dedup visual/API e helpers de teste — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, **B6** (`setStyle` Leaflet — cortável se troca de métrica for rara), B5 F2–F3 (cache CLI + factory — F2 é ~20 linhas; F3 é DRY puro), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1, F3 e F4** (dedup visual/API e helpers de teste — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F4 (`cityCode` / Alert DRY / fetch 2022 único — F4 mais valiosa com coroplético B3 na lista; F2–F3 só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -70,6 +70,7 @@
"cross-env": "^7.0.3",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.570.0",
"next": "15.4.11",
"next-themes": "^0.4.6",
@ -94,6 +95,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/react": "16.3.0",
"@types/geojson": "^7946.0.16",
"@types/leaflet": "^1.9.21",
"@types/node": "22.19.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",

View file

@ -71,6 +71,9 @@ importers:
graphql:
specifier: ^16.8.1
version: 16.12.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.570.0
version: 0.570.0(react@19.2.4)
@ -138,6 +141,9 @@ importers:
'@types/geojson':
specifier: ^7946.0.16
version: 7946.0.16
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
'@types/node':
specifier: 22.19.9
version: 22.19.9
@ -2821,6 +2827,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/lodash@4.17.23':
resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
@ -4780,6 +4789,9 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@ -9143,6 +9155,10 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/lodash@4.17.23': {}
'@types/mdast@4.0.4':
@ -11202,6 +11218,8 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
leaflet@1.9.4: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1

View file

@ -0,0 +1,194 @@
'use client'
import L from 'leaflet'
import { useEffect, useMemo, useRef, useState } from 'react'
import 'leaflet/dist/leaflet.css'
import { choroplethFillColor, choroplethMaxValue } from '@/lib/choroplethColorScale'
import {
loadMunicipalityGeometryModule,
loadTerritoryGeometryModule,
type MunicipalityGeometryModule,
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { cn } from '@/lib/utils'
import type { ChoroplethValues } from '@/utilities/nucleusChoroplethTypes'
const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
[-18.5, -46.8],
[-8.5, -37.0],
]
export type BahiaMapMode = 'municipality' | 'territory'
type FeatureKeyProperty = 'codarea' | 'code'
const featureKey = (
properties: Record<string, string> | undefined,
keyProperty: FeatureKeyProperty,
): string | undefined => properties?.[keyProperty]
type BahiaMapProps = {
mode: BahiaMapMode
values: ChoroplethValues
highlightKeys?: string[]
className?: string
heightClassName?: string
ariaLabel: string
}
export const BahiaMap = ({
mode,
values,
highlightKeys = [],
className,
heightClassName = 'h-[320px]',
ariaLabel,
}: BahiaMapProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const highlightKey = useMemo(() => highlightKeys.slice().sort().join(','), [highlightKeys])
useEffect(() => {
const container = containerRef.current
if (!container || mapRef.current) return
const map = L.map(container, {
zoomControl: true,
attributionControl: true,
scrollWheelZoom: false,
})
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18,
}).addTo(map)
mapRef.current = map
const resizeObserver =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
map.invalidateSize()
})
: null
resizeObserver?.observe(container)
return () => {
resizeObserver?.disconnect()
map.remove()
mapRef.current = null
layerRef.current = null
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map) return
let cancelled = false
setStatus('loading')
const renderLayer = async () => {
try {
const geometryModule =
mode === 'municipality'
? await loadMunicipalityGeometryModule()
: await loadTerritoryGeometryModule()
if (cancelled) return
layerRef.current?.remove()
const max = choroplethMaxValue(values)
const keyProperty: FeatureKeyProperty = mode === 'municipality' ? 'codarea' : 'code'
const highlightSet = new Set(
highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [],
)
const layer = L.geoJSON([...geometryModule.features], {
style: (feature) => {
const properties = feature?.properties as Record<string, string> | undefined
const key = featureKey(properties, keyProperty)
const metric = key ? (values[key] ?? 0) : 0
const highlighted = key ? highlightSet.has(key) : false
return {
weight: highlighted ? 2.5 : 1,
color: highlighted ? '#c51414' : '#a8a29e',
fillColor: choroplethFillColor(metric, max),
fillOpacity: highlighted ? 0.92 : metric > 0 ? 0.78 : 0.35,
}
},
})
layer.addTo(map)
layerRef.current = layer
map.invalidateSize()
if (highlightSet.size > 0) {
const highlightedFeatures =
mode === 'municipality'
? [...highlightSet].flatMap((key) => {
const feature = (geometryModule as MunicipalityGeometryModule).getMunicipalityFeature(
key,
)
return feature ? [feature] : []
})
: [...highlightSet].flatMap((key) => {
const feature = (geometryModule as TerritoryGeometryModule).getTerritoryFeature(key)
return feature ? [feature] : []
})
if (highlightedFeatures.length > 0) {
const highlightBounds = L.geoJSON(highlightedFeatures).getBounds()
if (highlightBounds.isValid()) {
map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 })
}
}
} else {
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
}
setStatus('ready')
} catch {
if (!cancelled) setStatus('error')
}
}
void renderLayer()
return () => {
cancelled = true
layerRef.current?.remove()
layerRef.current = null
}
}, [highlightKey, mode, values])
return (
<div className={cn('relative w-full', className)}>
<div
ref={containerRef}
role="img"
aria-label={ariaLabel}
aria-busy={status === 'loading'}
className={cn(
'w-full overflow-hidden rounded-lg border border-border bg-muted/30',
heightClassName,
)}
/>
{status === 'loading' ? (
<p className="sr-only">Carregando mapa…</p>
) : null}
{status === 'error' ? (
<p className="absolute inset-0 flex items-center justify-center bg-background/80 px-4 text-center text-sm text-muted-foreground">
Não foi possível carregar o mapa. Tente recarregar a página.
</p>
) : null}
</div>
)
}

View file

@ -9,6 +9,7 @@ import {
import Link from 'next/link'
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
import { DashboardMapDynamic } from '@/components/campaign/DashboardMapDynamic'
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
import {
CampaignPageShell,
@ -258,6 +259,8 @@ const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now:
</div>
</section>
<DashboardMapDynamic choropleth={view.choropleth} />
<UpcomingActionPlansCard plans={view.upcomingActionPlans} />
</CampaignPageShell>
)

View file

@ -0,0 +1,144 @@
'use client'
import { useMemo, useState } from 'react'
import { BahiaMap, type BahiaMapMode } from '@/components/campaign/BahiaMap'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { choroplethMaxValue } from '@/lib/choroplethColorScale'
import { formatElectionNumber } from '@/lib/electionInsights'
import {
choroplethMetricLabels,
type ChoroplethMetric,
type NucleusChoroplethBundle,
} from '@/utilities/nucleusChoroplethTypes'
const modeLabels: Record<BahiaMapMode, string> = {
municipality: 'Municípios',
territory: 'Territórios de Identidade',
}
type ChoroplethMapControlsProps = {
mode: BahiaMapMode
metric: ChoroplethMetric
max: number
onModeChange: (mode: BahiaMapMode) => void
onMetricChange: (metric: ChoroplethMetric) => void
idPrefix: string
}
const ChoroplethMapControls = ({
mode,
metric,
max,
onModeChange,
onMetricChange,
idPrefix,
}: ChoroplethMapControlsProps) => {
const hasData = max > 0
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<Field className="min-w-[12rem] flex-1">
<FieldLabel htmlFor={`${idPrefix}-mode`}>Visualização</FieldLabel>
<NativeSelect
id={`${idPrefix}-mode`}
value={mode}
onChange={(event) => onModeChange(event.target.value as BahiaMapMode)}
className="min-h-11 w-full"
>
{(Object.keys(modeLabels) as BahiaMapMode[]).map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{modeLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field className="min-w-[12rem] flex-1">
<FieldLabel htmlFor={`${idPrefix}-metric`}>Métrica</FieldLabel>
<NativeSelect
id={`${idPrefix}-metric`}
value={metric}
onChange={(event) => onMetricChange(event.target.value as ChoroplethMetric)}
className="min-h-11 w-full"
>
{(Object.keys(choroplethMetricLabels) as ChoroplethMetric[]).map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{choroplethMetricLabels[entry]}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
{hasData ? (
<p className="text-xs text-muted-foreground">
Escala: 0 → {formatElectionNumber(max)} ({choroplethMetricLabels[metric].toLowerCase()})
</p>
) : (
<p className="text-sm text-muted-foreground">
Sem dados para esta métrica no conjunto atual.
</p>
)}
</div>
)
}
type ChoroplethMapPanelProps = {
bundle: NucleusChoroplethBundle
title: string
description: string
ariaLabel: string
idPrefix: string
defaultMode?: BahiaMapMode
defaultMetric?: ChoroplethMetric
highlightKeys?: string[]
heightClassName?: string
}
export const ChoroplethMapPanel = ({
bundle,
title,
description,
ariaLabel,
idPrefix,
defaultMode = 'municipality',
defaultMetric = 'nucleusCount',
highlightKeys = [],
heightClassName,
}: ChoroplethMapPanelProps) => {
const [mode, setMode] = useState<BahiaMapMode>(defaultMode)
const [metric, setMetric] = useState<ChoroplethMetric>(defaultMetric)
const values = useMemo(
() => (mode === 'municipality' ? bundle.municipality[metric] : bundle.territory[metric]),
[bundle, metric, mode],
)
const max = choroplethMaxValue(values)
return (
<section aria-labelledby={`${idPrefix}-title`} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h3 id={`${idPrefix}-title`} className="text-base font-medium">
{title}
</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<ChoroplethMapControls
mode={mode}
metric={metric}
max={max}
onModeChange={setMode}
onMetricChange={setMetric}
idPrefix={idPrefix}
/>
<BahiaMap
mode={mode}
values={values}
highlightKeys={highlightKeys}
ariaLabel={ariaLabel}
heightClassName={heightClassName}
/>
</section>
)
}

View file

@ -0,0 +1,16 @@
'use client'
import { ChoroplethMapPanel } from '@/components/campaign/ChoroplethMapPanel'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
export const DashboardMap = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => (
<ChoroplethMapPanel
bundle={choropleth}
idPrefix="dashboard-map"
title="Mapa da campanha"
description="Visão por Território de Identidade dos núcleos ativos e estimativas confirmadas."
ariaLabel="Mapa coroplético da Bahia com agregados dos núcleos ativos"
defaultMode="territory"
defaultMetric="confirmedEstimate"
/>
)

View file

@ -0,0 +1,14 @@
'use client'
import { createCampaignClientDynamic } from '@/components/campaign/createCampaignClientDynamic'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
const DashboardMap = createCampaignClientDynamic<{ choropleth: NucleusChoroplethBundle }>(
() => import('@/components/campaign/DashboardMap'),
'DashboardMap',
'h-[360px]',
)
export const DashboardMapDynamic = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => (
<DashboardMap choropleth={choropleth} />
)

View file

@ -6,6 +6,7 @@ import type { Payload } from 'payload'
import { createNucleusUpdateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/nucleusUpdateFormActions'
import { searchPrimaryContactOptionsFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/primaryContactSearchActions'
import { LeadershipNetwork } from '@/components/campaign/LeadershipNetwork'
import { NucleusDetailMapDynamic } from '@/components/campaign/NucleusDetailMapDynamic'
import { NucleusElectoralBaseline } from '@/components/campaign/NucleusElectoralBaseline'
import { NucleusInsights } from '@/components/campaign/NucleusInsights'
import { NucleusIntelligenceDialogShell } from '@/components/campaign/NucleusIntelligenceDialogShell'
@ -43,6 +44,7 @@ import type {
StaffNucleusTabsViewModel,
} from '@/utilities/nucleusViewModels'
import { buildWhatsAppUrl } from '@/utilities/phone'
import { buildNucleusDetailMapHighlight } from '@/utilities/nucleusChoropleth'
const OverviewContent = ({
baseline,
@ -66,7 +68,16 @@ const OverviewContent = ({
searchParams: NucleusDetailSearchParams
updatePreview: NucleusUpdateViewModel[]
voteGoals: NucleusDetailViewModel['voteGoals']
}) => (
}) => {
const mapHighlight = buildNucleusDetailMapHighlight({
cities: nucleus.cities,
regions: nucleus.regions,
tseZones: nucleus.tseZones,
})
const hasMapFootprint =
mapHighlight.codareas.length > 0 || mapHighlight.territoryCodes.length > 0
return (
<>
{nucleus.kind === 'staff' ? (
<>
@ -229,6 +240,13 @@ const OverviewContent = ({
)}
<div className="mt-4 flex flex-col gap-4">
{hasMapFootprint ? (
<NucleusDetailMapDynamic
codareas={mapHighlight.codareas}
territoryCodes={mapHighlight.territoryCodes}
territoryLabel={formatNucleusTerritoryLabel(nucleus)}
/>
) : null}
<NucleusVoteGoals
voteGoals={voteGoals}
confirmedVoteEstimate={confirmedVoteEstimate}
@ -281,7 +299,8 @@ const OverviewContent = ({
</CardContent>
</Card>
</>
)
)
}
const TerritoryContent = ({ nucleus }: { nucleus: NucleusTabsViewModel }) => (
<div className="grid gap-4 lg:grid-cols-2">

View file

@ -0,0 +1,42 @@
'use client'
import { useMemo } from 'react'
import { BahiaMap } from '@/components/campaign/BahiaMap'
type NucleusDetailMapProps = {
codareas: string[]
territoryCodes: string[]
territoryLabel: string
}
export const NucleusDetailMap = ({
codareas,
territoryCodes,
territoryLabel,
}: NucleusDetailMapProps) => {
const highlightKeys = codareas.length > 0 ? codareas : territoryCodes
const mode = codareas.length > 0 ? 'municipality' : 'territory'
const values = useMemo(
() => Object.fromEntries(highlightKeys.map((key) => [key, 1])),
[highlightKeys],
)
return (
<section aria-labelledby="nucleus-detail-map-title" className="flex flex-col gap-2">
<div>
<h3 id="nucleus-detail-map-title" className="text-base font-medium">
Território no mapa
</h3>
<p className="text-sm text-muted-foreground">{territoryLabel}</p>
</div>
<BahiaMap
mode={mode}
values={values}
highlightKeys={highlightKeys}
heightClassName="h-[280px]"
ariaLabel={`Mapa destacando o território do núcleo: ${territoryLabel}`}
/>
</section>
)
}

View file

@ -0,0 +1,25 @@
'use client'
import { createCampaignClientDynamic } from '@/components/campaign/createCampaignClientDynamic'
const NucleusDetailMap = createCampaignClientDynamic<{
codareas: string[]
territoryCodes: string[]
territoryLabel: string
}>(() => import('@/components/campaign/NucleusDetailMap'), 'NucleusDetailMap', 'h-[280px]')
export const NucleusDetailMapDynamic = ({
codareas,
territoryCodes,
territoryLabel,
}: {
codareas: string[]
territoryCodes: string[]
territoryLabel: string
}) => (
<NucleusDetailMap
codareas={codareas}
territoryCodes={territoryCodes}
territoryLabel={territoryLabel}
/>
)

View file

@ -1,6 +1,7 @@
import Link from 'next/link'
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
import { NucleusOverviewMapDynamic } from '@/components/campaign/NucleusOverviewMapDynamic'
import { CampaignMetricStrip, type CampaignMetric } from '@/components/campaign/CampaignMetricStrip'
import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
@ -141,6 +142,8 @@ export const NucleusListOverview = ({
</CardContent>
</Card>
<NucleusOverviewMapDynamic choropleth={view.choropleth} />
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Demais indicadores</h3>
<CampaignMetricStrip className="sm:grid-cols-2" metrics={secondaryMetrics} />

View file

@ -0,0 +1,15 @@
'use client'
import { ChoroplethMapPanel } from '@/components/campaign/ChoroplethMapPanel'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
export const NucleusOverviewMap = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => (
<ChoroplethMapPanel
bundle={choropleth}
idPrefix="nucleus-overview-map"
title="Mapa do conjunto filtrado"
description="Distribuição territorial dos núcleos e estimativas no recorte atual da lista."
ariaLabel="Mapa coroplético da Bahia com os agregados dos núcleos filtrados"
defaultMetric="confirmedEstimate"
/>
)

View file

@ -0,0 +1,16 @@
'use client'
import { createCampaignClientDynamic } from '@/components/campaign/createCampaignClientDynamic'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
const NucleusOverviewMap = createCampaignClientDynamic<{ choropleth: NucleusChoroplethBundle }>(
() => import('@/components/campaign/NucleusOverviewMap'),
'NucleusOverviewMap',
'h-[360px]',
)
export const NucleusOverviewMapDynamic = ({
choropleth,
}: {
choropleth: NucleusChoroplethBundle
}) => <NucleusOverviewMap choropleth={choropleth} />

View file

@ -0,0 +1,18 @@
'use client'
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import { Skeleton } from '@/components/ui/skeleton'
export const createCampaignClientDynamic = <P extends object>(
importModule: () => Promise<Record<string, ComponentType<P>>>,
exportName: string,
skeletonHeightClassName: string,
) =>
dynamic(() => importModule().then((module) => module[exportName]), {
ssr: false,
loading: () => (
<Skeleton className={`${skeletonHeightClassName} w-full rounded-lg`} aria-hidden="true" />
),
})

View file

@ -1,76 +1,37 @@
/**
* Static Bahia municipality and identity-territory geometries for the campaign map.
* Lazy loaders for Bahia map geometries (B3 / B5 F1).
*
* 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).
* TopoJSON decode runs only when a map surface mounts — not on the default `/campanha` graph.
* Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`.
*/
import { feature } from 'topojson-client'
import type { Feature, MultiPolygon, Polygon } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
export type {
BahiaMunicipalityFeature,
BahiaMunicipalityProperties,
BahiaTerritoryFeature,
BahiaTerritoryProperties,
MunicipalityGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
import municipalityTopologyJson from '@/lib/geometries/bahia-municipalities.topo.json'
import territoryTopologyJson from '@/lib/geometries/bahia-identity-territories.topo.json'
import type {
MunicipalityGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
export type BahiaMunicipalityProperties = {
codarea: string
name: string
let municipalityModulePromise: Promise<MunicipalityGeometryModule> | null = null
let territoryModulePromise: Promise<TerritoryGeometryModule> | null = null
export const loadMunicipalityGeometryModule = (): Promise<MunicipalityGeometryModule> => {
municipalityModulePromise ??= import('@/lib/bahiaMunicipalityGeometries').then(
(module) => module.municipalityGeometryModule,
)
return municipalityModulePromise
}
export type BahiaTerritoryProperties = {
code: string
name: string
export const loadTerritoryGeometryModule = (): Promise<TerritoryGeometryModule> => {
territoryModulePromise ??= import('@/lib/bahiaTerritoryGeometries').then(
(module) => module.territoryGeometryModule,
)
return territoryModulePromise
}
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,39 @@
import type { Feature, MultiPolygon, Polygon } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
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>
export type MunicipalityTopology = Topology<{
municipalities: GeometryCollection<BahiaMunicipalityProperties>
}>
export type TerritoryTopology = Topology<{
territories: GeometryCollection<BahiaTerritoryProperties>
}>
export type MunicipalityGeometryModule = {
topology: MunicipalityTopology
features: readonly BahiaMunicipalityFeature[]
getMunicipalityFeature: (codarea: string) => BahiaMunicipalityFeature | undefined
}
export type TerritoryGeometryModule = {
topology: TerritoryTopology
features: readonly BahiaTerritoryFeature[]
getTerritoryFeature: (code: string) => BahiaTerritoryFeature | undefined
}

View file

@ -0,0 +1,25 @@
import { feature } from 'topojson-client'
import municipalityTopologyJson from '@/lib/geometries/bahia-municipalities.topo.json'
import type {
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
MunicipalityTopology,
} from '@/lib/bahiaGeometriesTypes'
const topology = municipalityTopologyJson as unknown as MunicipalityTopology
const municipalityFeatures = feature(
topology,
topology.objects.municipalities,
).features as BahiaMunicipalityFeature[]
const municipalityByCodarea = new Map(
municipalityFeatures.map((entry) => [entry.properties.codarea, entry]),
)
export const municipalityGeometryModule: MunicipalityGeometryModule = {
topology,
features: municipalityFeatures,
getMunicipalityFeature: (codarea: string) => municipalityByCodarea.get(codarea),
}

View file

@ -0,0 +1,25 @@
import { feature } from 'topojson-client'
import territoryTopologyJson from '@/lib/geometries/bahia-identity-territories.topo.json'
import type {
BahiaTerritoryFeature,
TerritoryGeometryModule,
TerritoryTopology,
} from '@/lib/bahiaGeometriesTypes'
const topology = territoryTopologyJson as unknown as TerritoryTopology
const territoryFeatures = feature(
topology,
topology.objects.territories,
).features as BahiaTerritoryFeature[]
const territoryByCode = new Map(
territoryFeatures.map((entry) => [entry.properties.code, entry]),
)
export const territoryGeometryModule: TerritoryGeometryModule = {
topology,
features: territoryFeatures,
getTerritoryFeature: (code: string) => territoryByCode.get(code),
}

View file

@ -0,0 +1,18 @@
/** Sequential fill for campaign choropleth maps (primary red scale). */
export const choroplethFillColor = (value: number, max: number): string => {
if (value <= 0 || max <= 0) return '#f4f4f5'
const ratio = Math.min(1, value / max)
// Light muted rose → campaign primary #c51414
const start = { r: 254, g: 226, b: 226 }
const end = { r: 197, g: 20, b: 20 }
const r = Math.round(start.r + (end.r - start.r) * ratio)
const g = Math.round(start.g + (end.g - start.g) * ratio)
const b = Math.round(start.b + (end.b - start.b) * ratio)
return `rgb(${r}, ${g}, ${b})`
}
export const choroplethMaxValue = (values: Record<string, number>): number => {
const entries = Object.values(values).filter((value) => value > 0)
return entries.length > 0 ? Math.max(...entries) : 0
}

View file

@ -3,6 +3,7 @@ import type { Payload } from 'payload'
import { isSupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
import { loadNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethPageData'
import {
buildGeneralDashboardViewModel,
buildScopedDashboardViewModel,
@ -11,6 +12,7 @@ import {
} from '@/utilities/campaignDashboardViewModels'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
import { nucleusVoteGoalsSelect } from '@/utilities/nucleusViewModels'
import { requireRelationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel } from '@/utilities/voteGoals'
@ -168,6 +170,17 @@ export const getCampaignDashboardPageData = async (
])
const rawNuclei = nucleusResult.docs as unknown as RawDashboardNucleus[]
const choroplethPromise =
user.role === 'geral'
? loadNucleusChoroplethBundle(
payload,
user,
rawNuclei.map((nucleus) => ({
...toNucleusElectionGeographyInput(nucleus),
confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
})),
)
: undefined
const coordinatorIds = [
...new Set(
rawNuclei.flatMap((nucleus) =>
@ -207,12 +220,14 @@ export const getCampaignDashboardPageData = async (
)
if (user.role === 'geral') {
const choropleth = await choroplethPromise!
return buildGeneralDashboardViewModel(
nuclei,
leaderships,
updateResult.totalDocs,
now,
upcomingActionPlans,
choropleth,
)
}

View file

@ -2,6 +2,7 @@ import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { SupportStatus } from '@/lib/schemas/leadership'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
import { sumVoteGoals, type VoteGoalsViewModel } from '@/utilities/voteGoals'
@ -62,6 +63,7 @@ export type GeneralDashboardViewModel = {
withoutRecentUpdate: DashboardQueueItem[]
pendingEstimate: DashboardQueueItem[]
}
choropleth: NucleusChoroplethBundle
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
@ -178,6 +180,7 @@ export const buildGeneralDashboardViewModel = (
updatesThisWeek: number,
now: Date,
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[],
choropleth: NucleusChoroplethBundle,
): GeneralDashboardViewModel => {
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
const confirmedCount = nuclei.filter(
@ -213,6 +216,7 @@ export const buildGeneralDashboardViewModel = (
)
.map(toQueueItem),
},
choropleth,
upcomingActionPlans,
}
}

View file

@ -0,0 +1,165 @@
import { codeForMunicipality } from '@/lib/bahiaMunicipalityCodes'
import {
bahiaIdentityTerritoryRecords,
isBahiaIdentityTerritory,
territoryForCity,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import { resolveNucleusElectionGeography, type NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
import {
emptyNucleusChoroplethBundle,
type ChoroplethMetric,
type ChoroplethValues,
type NucleusChoroplethBundle,
type NucleusChoroplethNucleus,
} from '@/utilities/nucleusChoroplethTypes'
export type {
ChoroplethMetric,
ChoroplethValues,
NucleusChoroplethBundle,
NucleusChoroplethNucleus,
} from '@/utilities/nucleusChoroplethTypes'
export { choroplethMetricLabels, emptyNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
const territoryCodeByName = new Map(
bahiaIdentityTerritoryRecords.map((record) => [record.name, record.code]),
)
type ResolvedChoroplethNucleus = {
nucleus: NucleusChoroplethNucleus
cities: string[]
territoryCodes: string[]
}
const emptyMetricValues = (): Record<ChoroplethMetric, ChoroplethValues> => ({
...emptyNucleusChoroplethBundle().municipality,
})
const addToRecord = (record: ChoroplethValues, key: string, amount: number) => {
record[key] = (record[key] ?? 0) + amount
}
export const codareasForCities = (cities: readonly string[]): string[] => [
...new Set(
cities.flatMap((city) => {
const codarea = codeForMunicipality(city)
return codarea ? [codarea] : []
}),
),
]
export const territoryCodesForNucleus = (
nucleus: Pick<NucleusChoroplethNucleus, 'cities' | 'regions'>,
): string[] => {
const fromRegions = nucleus.regions.flatMap((region) => {
if (!isBahiaIdentityTerritory(region)) return []
const code = territoryCodeByName.get(region)
return code ? [code] : []
})
if (fromRegions.length > 0) return [...new Set(fromRegions)]
return territoriesForCities(nucleus.cities).flatMap((name) => {
const code = territoryCodeByName.get(name)
return code ? [code] : []
})
}
const effectiveCitiesForNucleus = (nucleus: NucleusElectionGeographyInput): string[] => {
const geography = resolveNucleusElectionGeography(nucleus)
return geography ? [...geography.zonesByCity.keys()] : []
}
export const resolveChoroplethNuclei = (
nuclei: readonly NucleusChoroplethNucleus[],
): ResolvedChoroplethNucleus[] =>
nuclei.map((nucleus) => ({
nucleus,
cities: effectiveCitiesForNucleus(nucleus),
territoryCodes: territoryCodesForNucleus(nucleus),
}))
const uniqueCitiesFromResolved = (resolved: readonly ResolvedChoroplethNucleus[]): string[] => {
const cities = new Set<string>()
for (const { cities: nucleusCities } of resolved) {
for (const city of nucleusCities) {
cities.add(city)
}
}
return [...cities]
}
export const buildNucleusDetailMapHighlight = (
nucleus: NucleusElectionGeographyInput,
): { codareas: string[]; territoryCodes: string[] } => {
const cities = effectiveCitiesForNucleus(nucleus)
return {
codareas: codareasForCities(cities),
territoryCodes: territoryCodesForNucleus(nucleus),
}
}
export const choroplethCityNames = (nuclei: readonly NucleusChoroplethNucleus[]): string[] =>
uniqueCitiesFromResolved(resolveChoroplethNuclei(nuclei))
export const buildNucleusChoroplethBundleFromResolved = (
resolved: readonly ResolvedChoroplethNucleus[],
baselineVotesByCity: ReadonlyMap<string, number> = new Map(),
): NucleusChoroplethBundle => {
const municipality = emptyMetricValues()
const territory = emptyMetricValues()
for (const { nucleus, cities, territoryCodes } of resolved) {
if (cities.length > 0) {
const cityShare =
nucleus.confirmedVoteEstimate != null ? nucleus.confirmedVoteEstimate / cities.length : 0
for (const city of cities) {
const codarea = codeForMunicipality(city)
if (!codarea) continue
addToRecord(municipality.nucleusCount, codarea, 1)
if (nucleus.confirmedVoteEstimate != null) {
addToRecord(municipality.confirmedEstimate, codarea, cityShare)
}
}
}
if (territoryCodes.length > 0) {
const territoryShare =
nucleus.confirmedVoteEstimate != null
? nucleus.confirmedVoteEstimate / territoryCodes.length
: 0
for (const code of territoryCodes) {
addToRecord(territory.nucleusCount, code, 1)
if (nucleus.confirmedVoteEstimate != null) {
addToRecord(territory.confirmedEstimate, code, territoryShare)
}
}
}
}
for (const city of uniqueCitiesFromResolved(resolved)) {
const baselineVotes = baselineVotesByCity.get(city) ?? 0
if (baselineVotes <= 0) continue
const codarea = codeForMunicipality(city)
if (codarea) {
addToRecord(municipality.baseline2022Votes, codarea, baselineVotes)
}
const territoryName = territoryForCity(city)
const territoryCode = territoryName ? territoryCodeByName.get(territoryName) : undefined
if (territoryCode) {
addToRecord(territory.baseline2022Votes, territoryCode, baselineVotes)
}
}
return { municipality, territory }
}
export const buildNucleusChoroplethBundle = (
nuclei: readonly NucleusChoroplethNucleus[],
baselineVotesByCity: ReadonlyMap<string, number> = new Map(),
): NucleusChoroplethBundle =>
buildNucleusChoroplethBundleFromResolved(resolveChoroplethNuclei(nuclei), baselineVotesByCity)

View file

@ -0,0 +1,29 @@
import type { Payload } from 'payload'
import type { CampaignUser, User } from '@/payload-types'
import { loadBaseline2022VotesByCityNames } from '@/utilities/nucleusElectoralBaseline'
import {
buildNucleusChoroplethBundleFromResolved,
resolveChoroplethNuclei,
type NucleusChoroplethBundle,
type NucleusChoroplethNucleus,
} from '@/utilities/nucleusChoropleth'
type ChoroplethReader = CampaignUser | User
export const loadNucleusChoroplethBundle = async (
payload: Pick<Payload, 'find'>,
user: ChoroplethReader,
nuclei: readonly NucleusChoroplethNucleus[],
): Promise<NucleusChoroplethBundle> => {
const resolved = resolveChoroplethNuclei(nuclei)
const cityNames = [
...new Set(resolved.flatMap(({ cities }) => cities)),
]
const baselineVotesByCity =
cityNames.length > 0
? await loadBaseline2022VotesByCityNames(payload, user, cityNames)
: new Map<string, number>()
return buildNucleusChoroplethBundleFromResolved(resolved, baselineVotesByCity)
}

View file

@ -0,0 +1,33 @@
import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
export type ChoroplethMetric = 'nucleusCount' | 'confirmedEstimate' | 'baseline2022Votes'
export type ChoroplethValues = Record<string, number>
export type NucleusChoroplethNucleus = NucleusElectionGeographyInput & {
confirmedVoteEstimate: number | null
}
export type NucleusChoroplethBundle = {
municipality: Record<ChoroplethMetric, ChoroplethValues>
territory: Record<ChoroplethMetric, ChoroplethValues>
}
export const choroplethMetricLabels: Record<ChoroplethMetric, string> = {
nucleusCount: 'Núcleos',
confirmedEstimate: 'Estimativa confirmada',
baseline2022Votes: 'Votos Solla 2022',
}
export const emptyNucleusChoroplethBundle = (): NucleusChoroplethBundle => ({
municipality: {
nucleusCount: {},
confirmedEstimate: {},
baseline2022Votes: {},
},
territory: {
nucleusCount: {},
confirmedEstimate: {},
baseline2022Votes: {},
},
})

View file

@ -678,3 +678,34 @@ export const loadNucleusListElectionOverview = async (
conversion,
}
}
/** Solla 2022 federal T1 votes indexed by canonical city name (choropleth B3). */
export const loadBaseline2022VotesByCityNames = async (
payload: Pick<Payload, 'find'>,
user: ElectionDataReader,
cityNames: readonly string[],
): Promise<Map<string, number>> => {
const geography = resolveNucleusElectionGeography({
cities: [...cityNames],
regions: [],
tseZones: [],
})
if (!geography) return new Map()
const seriesByYear = await loadCandidateSeriesByGeography(payload, user, geography, [
ELECTION_YEAR_2022,
])
const votes2022 = seriesByYear.get(ELECTION_YEAR_2022) ?? new Map<string, number>()
const byCity = new Map<string, number>()
for (const city of geography.zonesByCity.keys()) {
const zones = geography.zonesByCity.get(city) ?? []
const total = zones.reduce(
(sum, zoneNumber) => sum + (votes2022.get(cityZoneKey(city, zoneNumber)) ?? 0),
0,
)
if (total > 0) byCity.set(city, total)
}
return byCity
}

View file

@ -6,6 +6,7 @@ import {
loadNucleusListElectionOverview,
toNucleusElectionGeographyInput,
} from '@/utilities/nucleusElectoralBaseline'
import { loadNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethPageData'
import {
buildNucleusListOverviewViewModel,
nucleusListOverviewPreviewLimit,
@ -113,7 +114,7 @@ export const loadNucleusListOverviewData = async (
})
const nucleiById = new Map(nuclei.map((nucleus) => [nucleus.id, nucleus]))
const [updateResult, listElectionOverview] = await Promise.all([
const [updateResult, listElectionOverview, choropleth] = await Promise.all([
payload.find({
collection: 'nucleusUpdate',
where: { nucleus: { in: nuclei.map(({ id }) => id) } },
@ -126,6 +127,7 @@ export const loadNucleusListOverviewData = async (
overrideAccess: false,
}),
loadNucleusListElectionOverview(payload, user, nuclei),
loadNucleusChoroplethBundle(payload, user, nuclei),
])
const updates = updateResult.docs as unknown as RawOverviewUpdate[]
@ -174,5 +176,6 @@ export const loadNucleusListOverviewData = async (
baseline2022: listElectionOverview.baseline2022,
trend: listElectionOverview.trend,
conversion: listElectionOverview.conversion,
choropleth,
})
}

View file

@ -2,6 +2,7 @@ import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
import type { NucleusBaseline2022OverviewAggregate, NucleusConversionOverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
import {
aggregateVoteGoals,
type VoteGoalsSumViewModel,
@ -56,6 +57,7 @@ export type NucleusListOverviewViewModel = {
conversion: NucleusConversionOverviewAggregate | null
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
choropleth: NucleusChoroplethBundle
}
const percentage = (part: number, total: number): number =>
@ -69,6 +71,7 @@ export const buildNucleusListOverviewViewModel = ({
baseline2022 = null,
trend = null,
conversion = null,
choropleth,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
recentUpdates: NucleusListOverviewUpdateRecord[]
@ -77,6 +80,7 @@ export const buildNucleusListOverviewViewModel = ({
baseline2022?: NucleusBaseline2022OverviewAggregate | null
trend?: NucleusTrendOverviewAggregate | null
conversion?: NucleusConversionOverviewAggregate | null
choropleth: NucleusChoroplethBundle
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
const confirmedCount = nuclei.filter(
@ -114,5 +118,6 @@ export const buildNucleusListOverviewViewModel = ({
conversion,
recentUpdates,
upcomingActionPlans,
choropleth,
}
}

View file

@ -7,12 +7,8 @@ import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
bahiaIdentityTerritoriesTopology,
bahiaMunicipalitiesTopology,
bahiaMunicipalityFeatures,
bahiaTerritoryFeatures,
getMunicipalityFeature,
getTerritoryFeature,
loadMunicipalityGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories'
@ -45,58 +41,56 @@ describe('Bahia static geometries', () => {
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)
it('exposes 417 municipality features whose codarea set matches the code table', async () => {
const { topology, features, getMunicipalityFeature } = await loadMunicipalityGeometryModule()
const featureCodes = new Set(
bahiaMunicipalityFeatures.map((entry) => entry.properties.codarea),
)
expect(topology.objects.municipalities.geometries).toHaveLength(417)
expect(features).toHaveLength(417)
const featureCodes = new Set(features.map((entry) => entry.properties.codarea))
const tableCodes = new Set(Object.values(bahiaMunicipalityCodes))
expect(featureCodes).toEqual(tableCodes)
for (const entry of bahiaMunicipalityFeatures) {
for (const entry of features) {
expect(entry.properties.codarea).toMatch(/^29\d{5}$/)
expect(bahiaMunicipalityCodes[entry.properties.name]).toBe(entry.properties.codarea)
expect(hasNonEmptyGeometry(entry.geometry)).toBe(true)
}
expect(getMunicipalityFeature('2927408')?.properties.name).toBe('Salvador')
expect(getMunicipalityFeature('0000000')).toBeUndefined()
})
it('exposes 27 territory features whose codes match bahiaIdentityTerritoryRecords', () => {
expect(bahiaIdentityTerritoriesTopology.objects.territories.geometries).toHaveLength(27)
expect(bahiaTerritoryFeatures).toHaveLength(27)
it('exposes 27 territory features whose codes match bahiaIdentityTerritoryRecords', async () => {
const { topology, features, getTerritoryFeature } = await loadTerritoryGeometryModule()
const featureCodes = bahiaTerritoryFeatures
.map((entry) => entry.properties.code)
.sort()
expect(topology.objects.territories.geometries).toHaveLength(27)
expect(features).toHaveLength(27)
const featureCodes = features.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)
const featureEntry = getTerritoryFeature(record.code)
expect(featureEntry, record.code).toBeDefined()
expect(featureEntry?.properties.name).toBe(record.name)
expect(hasNonEmptyGeometry(featureEntry?.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('01')?.properties.name).toBe('Irecê')
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'])
it('exposes TopoJSON object names expected by consumers', async () => {
const [municipalityModule, territoryModule] = await Promise.all([
loadMunicipalityGeometryModule(),
loadTerritoryGeometryModule(),
])
expect(municipalityModule.topology.type).toBe('Topology')
expect(territoryModule.topology.type).toBe('Topology')
expect(Object.keys(municipalityModule.topology.objects)).toEqual(['municipalities'])
expect(Object.keys(territoryModule.topology.objects)).toEqual(['territories'])
})
})

View file

@ -14,6 +14,7 @@ import { Progress } from '@/components/ui/Progress'
import { Toggle } from '@/components/ui/Toggle'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { buildGeneralDashboardViewModel } from '@/utilities/campaignDashboardViewModels'
import { emptyNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
describe('campaign visual foundation', () => {
it('exposes the current progress value to assistive technology', () => {
@ -110,6 +111,7 @@ describe('campaign visual foundation', () => {
0,
new Date('2026-07-18T12:00:00-03:00'),
[],
emptyNucleusChoroplethBundle(),
)
expect(view.supportCounts).toEqual({

View file

@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { NucleusListOverview } from '@/components/campaign/NucleusListOverview'
import { emptyNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
import { buildNucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels'
const now = new Date('2026-07-18T15:00:00.000Z')
@ -41,6 +42,8 @@ const nucleus = ({
priority,
})
const emptyChoropleth = emptyNucleusChoroplethBundle()
describe('buildNucleusListOverviewViewModel', () => {
it('aggregates confirmed estimates, coverage, and pending suggestions for staff', () => {
const view = buildNucleusListOverviewViewModel({
@ -58,6 +61,7 @@ describe('buildNucleusListOverviewViewModel', () => {
],
recentUpdates: [],
upcomingActionPlans: [],
choropleth: emptyChoropleth,
})
expect(view.totalFiltered).toBe(4)
@ -85,6 +89,7 @@ describe('buildNucleusListOverviewViewModel', () => {
],
recentUpdates: [],
upcomingActionPlans: [],
choropleth: emptyChoropleth,
})
expect(view.estimate).not.toHaveProperty('pendingSuggestionsCount')
@ -109,6 +114,7 @@ describe('buildNucleusListOverviewViewModel', () => {
],
recentUpdates: [],
upcomingActionPlans: [],
choropleth: emptyChoropleth,
})
expect(view.voteGoals).toEqual({ good: 1200, regular: 950, minimum: 600 })
@ -135,6 +141,7 @@ describe('NucleusListOverview', () => {
},
],
upcomingActionPlans: [],
choropleth: emptyChoropleth,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
@ -162,6 +169,7 @@ describe('NucleusListOverview', () => {
],
recentUpdates: [],
upcomingActionPlans: [],
choropleth: emptyChoropleth,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
@ -177,6 +185,7 @@ describe('NucleusListOverview', () => {
recentUpdates: [],
upcomingActionPlans: [],
baseline2022: { gapTotal: 2140, above: 8, below: 6 },
choropleth: emptyChoropleth,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
@ -196,6 +205,7 @@ describe('NucleusListOverview', () => {
recentUpdates: [],
upcomingActionPlans: [],
baseline2022: null,
choropleth: emptyChoropleth,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
@ -210,6 +220,7 @@ describe('NucleusListOverview', () => {
recentUpdates: [],
upcomingActionPlans: [],
trend: { decline: 3, stable: 5, increase: 2, noBaseline: 1 },
choropleth: emptyChoropleth,
})
const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
buildNucleusChoroplethBundle,
codareasForCities,
territoryCodesForNucleus,
} from '@/utilities/nucleusChoropleth'
describe('nucleusChoropleth', () => {
it('maps canonical city names to IBGE codarea', () => {
expect(codareasForCities(['Salvador', 'Feira de Santana'])).toEqual(['2927408', '2910800'])
})
it('splits confirmed estimate equally across multi-city nuclei', () => {
const bundle = buildNucleusChoroplethBundle([
{
cities: ['Salvador', 'Feira de Santana'],
regions: [],
tseZones: [],
confirmedVoteEstimate: 1000,
},
])
expect(bundle.municipality.nucleusCount['2927408']).toBe(1)
expect(bundle.municipality.nucleusCount['2910800']).toBe(1)
expect(bundle.municipality.confirmedEstimate['2927408']).toBe(500)
expect(bundle.municipality.confirmedEstimate['2910800']).toBe(500)
})
it('does not double-count baseline votes for the same city in multiple nuclei', () => {
const baseline = new Map([
['Salvador', 400_000],
['Feira de Santana', 50_000],
])
const bundle = buildNucleusChoroplethBundle(
[
{
cities: ['Salvador'],
regions: [],
tseZones: [],
confirmedVoteEstimate: 1000,
},
{
cities: ['Salvador', 'Feira de Santana'],
regions: [],
tseZones: [],
confirmedVoteEstimate: 2000,
},
],
baseline,
)
expect(bundle.municipality.baseline2022Votes['2927408']).toBe(400_000)
expect(bundle.municipality.baseline2022Votes['2910800']).toBe(50_000)
})
it('resolves territory codes from regions when present', () => {
expect(
territoryCodesForNucleus({
cities: [],
regions: ['Metropolitano de Salvador'],
}),
).toEqual(['26'])
})
})