diff --git a/docs/plans/escala-dry-pos-a4.md b/docs/plans/escala-dry-pos-a4.md index 019698d2..af8c4ae6 100644 --- a/docs/plans/escala-dry-pos-a4.md +++ b/docs/plans/escala-dry-pos-a4.md @@ -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
(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
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` (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 diff --git a/docs/plans/escala-dry-pos-b2.md b/docs/plans/escala-dry-pos-b2.md index 9d53aa80..3092eb91 100644 --- a/docs/plans/escala-dry-pos-b2.md +++ b/docs/plans/escala-dry-pos-b2.md @@ -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
split mun/TI + dynamic import"] F1 --> F2["Fase 2 — ensureCachedDownload
CLI compartilhado"] + F1 --> F3["Fase 3 — buildGeometryModuleFromTopology
(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({ 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` diff --git a/docs/plans/escala-dry-pos-b3.md b/docs/plans/escala-dry-pos-b3.md new file mode 100644 index 00000000..7b154c2a --- /dev/null +++ b/docs/plans/escala-dry-pos-b3.md @@ -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
(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` 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 diff --git a/docs/plans/mapa-bahia-geometrias.md b/docs/plans/mapa-bahia-geometrias.md index c723e630..3ce30538 100644 --- a/docs/plans/mapa-bahia-geometrias.md +++ b/docs/plans/mapa-bahia-geometrias.md @@ -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` — 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ca260f3..7ff97aa5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
(chips opt-in território↔ZE) ✓"] A3["A3 Baseline TSE 2022 — Fase 1
(import, collections) ✓"] A4["A4 Baseline no produto + Gap vs 2022
(detalhe, overview) ✓"] - A7["A7 Escala e DRY pós-A4
(F1 agregar federal ✓ / cityCode / Alert)"] + A7["A7 Escala e DRY pós-A4
(F1 agregar federal ✓ / cityCode / Alert / F4 fetch 2022)"] A5["A5 Insights: conversão, classificação,
alavancagem (+virada), mobilização, competitiva"] A6["A6 Dobradinha 2026"] A8["A8 Perfis eleitorado IBGE
(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
(lazy geometrias / cache CLI)"] + B5["B5 Escala e DRY pós-B2
(F1 lazy ✓ / cache CLI / factory)"] B3["B3 Mapa Fase 2 (Leaflet nas superfícies)"] + B6["B6 Escala e DRY pós-B3
(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) diff --git a/package.json b/package.json index 93646fbc..1a33bfcd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f94c74fd..ea2de46f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/campaign/BahiaMap.tsx b/src/components/campaign/BahiaMap.tsx new file mode 100644 index 00000000..c03d62c6 --- /dev/null +++ b/src/components/campaign/BahiaMap.tsx @@ -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 | 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(null) + const mapRef = useRef(null) + const layerRef = useRef(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: '© OpenStreetMap', + 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 | 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 ( +
+
+ {status === 'loading' ? ( +

Carregando mapa…

+ ) : null} + {status === 'error' ? ( +

+ Não foi possível carregar o mapa. Tente recarregar a página. +

+ ) : null} +
+ ) +} diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx index be9a70c4..95b0aaab 100644 --- a/src/components/campaign/CampaignDashboard.tsx +++ b/src/components/campaign/CampaignDashboard.tsx @@ -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:
+ + ) diff --git a/src/components/campaign/ChoroplethMapPanel.tsx b/src/components/campaign/ChoroplethMapPanel.tsx new file mode 100644 index 00000000..9de5d6eb --- /dev/null +++ b/src/components/campaign/ChoroplethMapPanel.tsx @@ -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 = { + 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 ( +
+
+ + Visualização + onModeChange(event.target.value as BahiaMapMode)} + className="min-h-11 w-full" + > + {(Object.keys(modeLabels) as BahiaMapMode[]).map((entry) => ( + + {modeLabels[entry]} + + ))} + + + + Métrica + onMetricChange(event.target.value as ChoroplethMetric)} + className="min-h-11 w-full" + > + {(Object.keys(choroplethMetricLabels) as ChoroplethMetric[]).map((entry) => ( + + {choroplethMetricLabels[entry]} + + ))} + + +
+ {hasData ? ( +

+ Escala: 0 → {formatElectionNumber(max)} ({choroplethMetricLabels[metric].toLowerCase()}) +

+ ) : ( +

+ Sem dados para esta métrica no conjunto atual. +

+ )} +
+ ) +} + +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(defaultMode) + const [metric, setMetric] = useState(defaultMetric) + + const values = useMemo( + () => (mode === 'municipality' ? bundle.municipality[metric] : bundle.territory[metric]), + [bundle, metric, mode], + ) + const max = choroplethMaxValue(values) + + return ( +
+
+

+ {title} +

+

{description}

+
+ + +
+ ) +} diff --git a/src/components/campaign/DashboardMap.tsx b/src/components/campaign/DashboardMap.tsx new file mode 100644 index 00000000..0f1b05ce --- /dev/null +++ b/src/components/campaign/DashboardMap.tsx @@ -0,0 +1,16 @@ +'use client' + +import { ChoroplethMapPanel } from '@/components/campaign/ChoroplethMapPanel' +import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes' + +export const DashboardMap = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => ( + +) diff --git a/src/components/campaign/DashboardMapDynamic.tsx b/src/components/campaign/DashboardMapDynamic.tsx new file mode 100644 index 00000000..ffb69f37 --- /dev/null +++ b/src/components/campaign/DashboardMapDynamic.tsx @@ -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 }) => ( + +) diff --git a/src/components/campaign/NucleusActiveTab.tsx b/src/components/campaign/NucleusActiveTab.tsx index e2cb9382..9aca3b0f 100644 --- a/src/components/campaign/NucleusActiveTab.tsx +++ b/src/components/campaign/NucleusActiveTab.tsx @@ -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 = ({ )}
+ {hasMapFootprint ? ( + + ) : null} -) + ) +} const TerritoryContent = ({ nucleus }: { nucleus: NucleusTabsViewModel }) => (
diff --git a/src/components/campaign/NucleusDetailMap.tsx b/src/components/campaign/NucleusDetailMap.tsx new file mode 100644 index 00000000..b9fa95dc --- /dev/null +++ b/src/components/campaign/NucleusDetailMap.tsx @@ -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 ( +
+
+

+ Território no mapa +

+

{territoryLabel}

+
+ +
+ ) +} diff --git a/src/components/campaign/NucleusDetailMapDynamic.tsx b/src/components/campaign/NucleusDetailMapDynamic.tsx new file mode 100644 index 00000000..e44be794 --- /dev/null +++ b/src/components/campaign/NucleusDetailMapDynamic.tsx @@ -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 +}) => ( + +) diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx index a7a2329f..8974ea40 100644 --- a/src/components/campaign/NucleusListOverview.tsx +++ b/src/components/campaign/NucleusListOverview.tsx @@ -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 = ({ + +

Demais indicadores

diff --git a/src/components/campaign/NucleusOverviewMap.tsx b/src/components/campaign/NucleusOverviewMap.tsx new file mode 100644 index 00000000..b40989c5 --- /dev/null +++ b/src/components/campaign/NucleusOverviewMap.tsx @@ -0,0 +1,15 @@ +'use client' + +import { ChoroplethMapPanel } from '@/components/campaign/ChoroplethMapPanel' +import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes' + +export const NucleusOverviewMap = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => ( + +) diff --git a/src/components/campaign/NucleusOverviewMapDynamic.tsx b/src/components/campaign/NucleusOverviewMapDynamic.tsx new file mode 100644 index 00000000..5d6b2660 --- /dev/null +++ b/src/components/campaign/NucleusOverviewMapDynamic.tsx @@ -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 +}) => diff --git a/src/components/campaign/createCampaignClientDynamic.tsx b/src/components/campaign/createCampaignClientDynamic.tsx new file mode 100644 index 00000000..53fbbfea --- /dev/null +++ b/src/components/campaign/createCampaignClientDynamic.tsx @@ -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 =

( + importModule: () => Promise>>, + exportName: string, + skeletonHeightClassName: string, +) => + dynamic(() => importModule().then((module) => module[exportName]), { + ssr: false, + loading: () => ( +