Deliver B11 percent-of-valid-votes scale on Praças map.
Adds valid-votes denominators to the map bundle, a Total vs % dos válidos selector with fixed 0–100% coloring, and readout/legend alignment via scaleMax; updates roadmap and post-simplify debt docs.
This commit is contained in:
parent
248f896d05
commit
9db80d815b
15 changed files with 278 additions and 49 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B11 entregue em código (2026-07-21; deploy pendente com remodelagem):** escala **% dos válidos** no Mapa das Praças — seletor `Total (votos)` / `% dos válidos` (default %); domínio 0–100% fixo (`scaleMax` em `BahiaMap`); `validVotesByYear` no bundle; 2026 usa válidos 2022; compare desliga %; readout em % + votos absolutos; `Promise.all` nos loads TSE. Plano: [`escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md). Débito perf troca `scaleMode` → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
|
||||
- **Fill-in filtros-auto entregue em código (2026-07-21; deploy pendente com remodelagem):** debounce 1s + Enter imediato na busca de `/campanha/pracas`; remove Buscar; pending UI alinhado a `ActionPlanFilters`; `shouldUpdatePlazaSearchUrl` + no-op `buildPlazaFiltersKey`. Plano: [`filtros-auto-pracas.md`](../../../docs/plans/filtros-auto-pracas.md). Débitos pós-`/simplify` (sync back/forward, shell pending) → gatilhos no plano.
|
||||
- **B10 entregue em código (2026-07-21; deploy pendente com remodelagem):** hover/tap no Mapa das Praças — destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 foca `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`. Plano: [`hover-mapa-pracas.md`](../../../docs/plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
|
||||
- **B9 entregue em código (2026-07-21; deploy pendente com remodelagem):** edição rápida na lista — Assessores / Tendência / `expectedVotes` via `PlazaList*Control` + `listFormActions`; sem migration. Plano: [`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md). Débitos `/simplify` → **A9+** F2 + **C8** F4.
|
||||
|
|
@ -124,6 +125,5 @@ alwaysApply: false
|
|||
- Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33).
|
||||
- Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md).
|
||||
- **B7 entregue (2026-07-21):** mapa em `/campanha/pracas` usa `buildPlazaListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**.
|
||||
- **A9 / B9 / B7 / B10 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **B11**, **R6**, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10), **B8**.
|
||||
- **A9 / B9 / B7 / B10 / B11 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **R6**, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10/B11), **B8**.
|
||||
- **B8 registrado (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. Pesquisa: SSA tem lista oficial TRE-BA zona↔bairro (RA 02/2017); CMS **não** — só locais de votação por ZE 170/171 + curadoria; TSE/TRE não publicam polígonos de ZE. Plano: F1 catálogo+UI na Praça; F2 dissolve malha de bairros (IBGE/GeoSalvador) → TopoJSON. Janela 3; F2 cortável. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
|
||||
- **B11 registrado (2026-07-21):** opção de escala no Mapa das Praças — cor por % dos votos válidos da Praça (0–100% fixos; denominador `electionTally.votosValidos`; 2026 usa válidos 2022); absoluto permanece. Plano: [`docs/plans/escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md).
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-19):** **C6** Escala e DRY pós-C2 shipped and merged to `main` — bulk supporter import via `payload.db.drizzle` inside the existing Payload transaction (`supporterImportBulk.ts`, `ON CONFLICT DO NOTHING` on `(contact_id, nucleus_id)`, 500-row chunks), `skipContactPhoneInvariant` opt-in on `Contact.enforceUniqueContactPhone` (fail-closed: only honored inside an active `req.transactionID`), single-SQL KPI aggregate (`supporterListOverviewAggregate.ts`, `COUNT(*) FILTER` mirroring `buildSupporterListWhere` + access constraint), HMAC-SHA256 single-use import token + ephemeral `supporterImportBatch` staging collection (migration `20260719_011015_add_supporter_import_batch`, TTL 10 min, actor-bound, consumed on confirm), and shared shells (`campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts`) replacing the three per-list pagination components. A `/simplify` pass applied targeted cleanup (identity maps, dead branches, `relationshipId`/`canManageCampaignUsers` reuse, native `base64url`, `rowCount ?? 0` bug fix). Scale/DRY debits the simplify reviewers flagged as larger than cleanup (locks in 1 round-trip, `.returning()`, `pg_trgm`, shared `drizzleBulk.ts`, column-name assertion, migrating the remaining `formActions`) are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`). Production use with real supporter data still waits on the Onda 0 legal batch (Consent keys `apoiador-cadastro` / `apoiador-intencao-voto`).
|
||||
|
||||
**Recently resolved (2026-07-21):** **B11** Escala % dos válidos no Mapa das Praças — `validVotesByYear` in `PlazaMapBundle`, `scaleMode` selector (`absolute` | `percentValid`, default %), `computeValidVoteShares`, readout em % com votos absolutos secundários; `/simplify` fix `scaleMax` (legenda 0–100% alinhada ao fill). Débito perf troca `scaleMode` → **B6** (`docs/plans/escala-dry-pos-b3.md`). Plano: `docs/plans/escala-percentual-mapa-pracas.md`.
|
||||
|
||||
## Posts & Tags (news / publications)
|
||||
|
||||
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.
|
||||
|
|
@ -108,7 +110,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
|
|||
- **Roles:** `coordinator` ("Coordenador Geral") sees/manages everything and is the only one who assigns advisors and decides escalated demands; `advisor` ("Assessor") sees and manages ONLY plazas listed in `plaza.advisors` (list, map, dashboard, leaderships, pledges, demands are all scoped); `leader` ("Liderança") sees only plazas linked through their engaged leadership, declares own votes, opens demands, and writes own updates.
|
||||
- **Vote pledge asymmetry (product-critical):** the leader INFORMS `declaredVotes` per linked plaza and may update it; staff record `estimatedVotes`/`estimateNote`, which field access denies to leaders (same technique as `supportStatus`). Leader-facing view models never aggregate estimates; staff aggregates use `estimatedVotes ?? declaredVotes` (`src/utilities/votePledgeData.ts`). Guarded by `tests/int/campaignVotePledge.int.spec.ts`.
|
||||
- **Political trend:** manual conjuncture reading on the plaza (staff-only group `politicalTrend`: `favoravel|neutra|desfavoravel` + note + derived author/date). The numeric series is deliberately labeled "Evolução de votos" in the UI to keep "tendência" for the political reading.
|
||||
- **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole).
|
||||
- **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) with scale selector **Total (votos)** | **% dos válidos** (default %; fixed 0–100% domain via `validVotesByYear` + `scaleMax` on `BahiaMap`; 2026 uses 2022 valid votes as denominator; compare mode hides % and stays absolute diff) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole).
|
||||
- **URLs:** `/campanha/pracas[/slug]` (seeded slugs from the catalog), `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/planos`, `/campanha/apoiadores`. Route params stay English keys; slugs/enums stay pt data values.
|
||||
- **Invites:** unchanged mechanics (hash-only persistence, `NEXT_PUBLIC_SITE_URL` origin, `no-store` public pages, login invites only for `engajado`); advisor scope now follows leadership↔plaza intersection. Consent still resolves only by stable key `lideranca-autopreenchimento` (fail closed).
|
||||
- **PWA (D1):** unchanged — installable `/campanha`, versioned cache, offline fallback; push is still D2.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-A9 (loader da lista de Praças)
|
||||
|
||||
Status: registrado no roadmap (Fase 1 pendente; **B7 entregue 2026-07-21** — prioridade sobe)
|
||||
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B7 + pós-B9 `/simplify`)
|
||||
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B11 `/simplify`: `Promise.all` TSE em `loadPlazaMapBundle` resolvido; pós-B7 + pós-B9)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, fill-in **A9+** pós-A9)
|
||||
Impeccable: A — N/A (sem superfície UI; otimização de loader)
|
||||
Appetite: ~1–1,5 dia eng (F1 loader + F2 revalidate pós-B9; PR único ou dois commits)
|
||||
|
|
@ -21,11 +21,11 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
|
|||
|
||||
3. **Revalidação full-page após edição inline (B9).** Cada save em `listFormActions.ts` chama `revalidatePath('/campanha/pracas', 'page')` (+ detalhe), o que reroda overview unpaginado, mapa e lista — custo amplificado quando o coordenador edita várias células seguidas (pós-B9).
|
||||
|
||||
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup. **Pós-B7 simplify:** `PlazaListSearchParams` exportado de `plazaUi.ts`; parse único no map loader (`loadScopedPlazas` recebe `PlazaListState`).
|
||||
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup. **Pós-B7 simplify:** `PlazaListSearchParams` exportado de `plazaUi.ts`; parse único no map loader (`loadScopedPlazas` recebe `PlazaListState`). **Pós-B11 simplify:** `Promise.all` nos loads TSE por ano em `loadPlazaMapBundle` (votes + válidos em paralelo; branch `?compare=` também paraleliza os 3 `loadCandidateVotesByCityZone`) — micro-opt que estava fora do escopo A9+; resolvido no cleanup B11, não reabrir aqui.
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-A9):** semântica lista (só `expectedVotes` manual) vs mapa/overview (fallback) — decisão de produto A9; merge de forms Estratégia + Votos no `/editar`; grid cosmético do `PlazaStrategyCard`; `loadPlazaPledges` em abas distintas do detalhe (uma aba por request); débitos adiados/não escopo do plano A9 (nota/autor, chip auto-sugerir, histórico, filtro `?expectedVotes=`); critique Impeccable formal por superfície → **R6**.
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B7):** `Promise.all` nos loads TSE por ano em `plazaMapData` (micro-opt pré-existente; gatilho abaixo); testes int extras em `plazaMapData` (`region`/`leader`/`compare`) — cobertura opcional no próximo toque no loader; split do teste advisor por `kind` (legibilidade).
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B7):** testes int extras em `plazaMapData` (`region`/`leader`/`compare`) — cobertura opcional no próximo toque no loader; split do teste advisor por `kind` (legibilidade).
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** ~75 hooks `useActionState` por página (3 controles × 25 linhas × 2 views) — gatilho: reclamação de perf ou **R6**; merge `listFormActions` ↔ `/editar` → **C8 F4**; hook popover compartilhado / layout responsivo único / lazy advisor options → gatilhos no plano B9.
|
||||
|
||||
|
|
@ -108,7 +108,6 @@ flowchart TD
|
|||
|
||||
- **SQL aggregate espelhando `buildPlazaListWhere`.** Revisitar quando profiling mostrar `aggregatePledgesByPlaza` dominando TTFB com filtros amplos + mapa sempre montado.
|
||||
- **Compartilhar bundle com dashboard geral.** Revisitar se o dashboard passar a carregar mapa+lista na mesma rota.
|
||||
- **Loads TSE sequenciais por ano em `loadPlazaMapBundle`.** Revisitar com `Promise.all` só se profiling com `?compare=` mostrar latência dominante (pré-existente; fora do escopo A9+).
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-B3 (Leaflet / coroplético)
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B10 `/simplify`: hover/select absorvido em Fase 1)
|
||||
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B11 `/simplify`: troca `scaleMode` absorvida em Fase 1; pós-B10: hover/select)
|
||||
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)
|
||||
Appetite: ~1–1,5 dia eng (Fase 1 única: métrica/ano + hover/select incremental; PR único)
|
||||
|
|
@ -11,9 +11,9 @@ Responsável: —
|
|||
|
||||
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** os follow-ups abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos; e, desde **B10** ([hover-mapa-pracas.md](hover-mapa-pracas.md)), cada hover/select no mapa de Praças chama `applyLayerStyles` com `eachLayer` em ~417 municípios.
|
||||
Os revisores (performance) marcaram como **importante e maior que simplify** os follow-ups abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos; e, desde **B10** ([hover-mapa-pracas.md](hover-mapa-pracas.md)), cada hover/select no mapa de Praças chama `applyLayerStyles` com `eachLayer` em ~417 municípios. Desde **B11** ([escala-percentual-mapa-pracas.md](escala-percentual-mapa-pracas.md)), trocar `scaleMode` (`absolute` ↔ `% dos válidos`) também altera `displayValues` e dispara o mesmo rebuild — mesmo com `scaleMax` corrigindo legenda vs fill.
|
||||
|
||||
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).
|
||||
1. **`BahiaMap` rebuild completo do layer GeoJSON** quando só `values` (métrica/escala), `scaleMax` ou `highlightKeys` mudam. O effect em `BahiaMap.tsx` depende de `[highlightKey, mode, scaleMax, 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/escala é `layer.setStyle(...)` (e `fitBounds` só quando o highlight muda).
|
||||
|
||||
2. **Hover/select no mapa de Praças (B10) ainda faz full-scan de estilo.** `mouseover` / `selectedKey` disparam `refreshLayerStyles` → `applyLayerStyles` → `eachLayer` + `setStyle` em todos os polígonos. O simplify pós-B10 já deduplica hover repetido e evita double-refresh quando hover “possui” o estilo, mas o hot path continua O(n) por movimento do cursor. O caminho correto é restyle **incremental** (2-path: anterior + atual, ou `Map<featureKey, PathLayer>`) — mesma refatoração estrutural da Fase 1.
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Os revisores (performance) marcaram como **importante e maior que simplify** os
|
|||
|
||||
## 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.
|
||||
- Trocar métrica (núcleos / estimativa / baseline 2022), **escala** (`absolute` ↔ `% dos válidos` no mapa de Praças pós-B11) 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.
|
||||
- Hover/select no mapa de Praças (B10) restyla só o polígono anterior + o ativo — não `eachLayer` em ~417 paths por `mouseover`.
|
||||
- `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`, `aria-live` do readout B10 inalterado).
|
||||
|
|
@ -36,6 +36,7 @@ Os revisores (performance) marcaram como **importante e maior que simplify** os
|
|||
- **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 **ou** hover denso no mapa de Praças (B10 entregue).
|
||||
- **Dependência suave de B10.** Hover/select já existe em `PlazaMapPanel` + `BahiaMap`; Fase 1 unifica o caminho de estilo incremental — não reabre escopo de navegação/readout do B10.
|
||||
- **Dependência suave de B11.** Seletor `scaleMode` em `PlazaMapPanel` já altera `displayValues`/`scaleMax`; Fase 1 deve cobrir essa troca sem rebuild — não reabre escopo de % válidos nem `validVotesByYear`.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês; strings visíveis em pt-BR inalteradas.
|
||||
|
||||
## Questões em aberto
|
||||
|
|
@ -51,6 +52,7 @@ flowchart TD
|
|||
B3["B3 Leaflet + coroplético"] --> F1
|
||||
B5F1["B5 F1 lazy geometrias ✓"] --> F1
|
||||
B10["B10 Hover/tap Praças ✓"] --> F1
|
||||
B11["B11 Escala % válidos ✓"] --> F1
|
||||
F1["Fase 1 — BahiaMap setStyle<br/>(split effects + hover incremental)"]
|
||||
F1 -.não bloqueia.-> B4["B4 Zonas TSE no mapa"]
|
||||
```
|
||||
|
|
@ -74,6 +76,7 @@ flowchart TD
|
|||
- **Suave:** B3 Mapa Fase 2 (Leaflet) — implementado no branch; merge pendente.
|
||||
- **Suave:** B5 F1 lazy geometrias — entregue com B3.
|
||||
- **Suave:** B10 hover/tap no mapa de Praças — entregue 2026-07-21; Fase 1 consolida o hot path de estilo sem alterar navegação/readout.
|
||||
- **Suave:** B11 escala % válidos — entregue 2026-07-21; troca `scaleMode` é mais um gatilho de rebuild a eliminar via `setStyle`.
|
||||
- Reusa: `choroplethFillColor` / `choroplethMaxValue` (`src/lib/choroplethColorScale.ts`), loaders `loadMunicipalityGeometryModule` / `loadTerritoryGeometryModule`, tipos `ChoroplethValues`, `emphasizeFeature` / `getFeatureStyle` (B10).
|
||||
|
||||
## Não escopo
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala percentual no Mapa das Praças (0–100% dos válidos)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-21
|
||||
Status: entregue 2026-07-21
|
||||
Atualizado em: 2026-07-21 (implementação B11 + `capture-review-debts` pós-`/simplify`)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B11)
|
||||
Impeccable: B — encaixe em `PlazaMapPanel` / `BahiaMap` / `ChoroplethLegend` (sem rota nova)
|
||||
Appetite: ~0,5–1 dia eng; tally por ano no bundle + seletor de escala + legenda 0–100%
|
||||
|
|
@ -28,6 +28,10 @@ Os votos nominais já vêm de `loadCandidateVotesByCityZone` + `sumVotesForGeogr
|
|||
|
||||
Não cobre: filtro URL (**B7**), hover (**B10**), `setStyle` incremental (**B6**), polígonos-zona (**B8**).
|
||||
|
||||
**Já resolvido no simplify/critique (não reabrir):** bug legenda 0–100% vs mapa normalizado pelo max local (`scaleMax` em `BahiaMap`); lookup 2026→2022 redundante no panel (bundle já aliasa `validVotesByYear['2026']`); `PLAZA_MAP_SCALE_MODES`; `displayMax` memoizado; `Promise.all` nos loads TSE por ano (+ compare) em `loadPlazaMapBundle`; guard `valid > 0` em `computeValidVoteShares`; polish leve de copy/controle (sem critique P0–P3 formal).
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B11):** `formatValidVoteShare` compartilhado com `PlazaBaselineCard` (gatilho: 3ª superfície com % válidos); loader genérico `loadTallyByCityZone` (gatilho: 3ª variante de tally); primitivo `validVoteShare` em `electionInsights` (gatilho: 3º call site); `maxLabel` em vez de `formatMax` na legenda; mover `computeValidVoteShares` de módulo; int test de agregação share além de keys 2026→2022 (gatilho: próximo toque em `plazaMapData`); cache cross-request de tally (gatilho: profiling TTFB); rebuild GeoJSON ao trocar `scaleMode` → **B6** [escala-dry-pos-b3.md](escala-dry-pos-b3.md).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Controle no painel do mapa: escala **Total (votos)** | **% dos válidos** (rótulos pt-BR).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-21 (fill-in filtros-auto entregue em código; B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10 → B6)
|
||||
Atualizado em: 2026-07-21 (fill-in filtros-auto + B11 entregues em código; B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10/B11 scale toggle → B6)
|
||||
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
|
|
@ -73,23 +73,24 @@ flowchart TD
|
|||
R2 -.-> B7["B7 Mapa filtrado ✓"]
|
||||
R2 -.-> B8["B8 Polígonos Praças-zona<br/>(SSA/CMS)"]
|
||||
R2 -.-> B10["B10 Hover/tap mapa ✓"]
|
||||
R2 -.-> B11["B11 Escala % válidos<br/>no mapa"]
|
||||
R2 -.-> B11["B11 Escala % válidos<br/>no mapa ✓"]
|
||||
A9 -.métrica 2026.-> B10
|
||||
A9 -.numerador 2026.-> B11
|
||||
B10 -.hover densos.-> B6["B6 setStyle incremental"]
|
||||
B11 -.troca escala.-> B6
|
||||
JUR -.chave push.-> D2
|
||||
C2prod --> C5["C5 GOTV (validar)"]
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7** / **B10** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve o hot path de hover pós-B10 (Janela 3 / gatilho de densidade).
|
||||
Paralelizáveis agora: fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7** / **B10** / **B11** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve hot path de hover pós-B10 e troca de escala pós-B11 (Janela 3 / gatilho de densidade).
|
||||
|
||||
### Sequência por janela
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **B11** escala % dos válidos; R6 critique/polish; Onda 0 jurídica em paralelo.
|
||||
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**B11** escala % dos válidos~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo.
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga.
|
||||
|
||||
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (troca de ano/métrica **e** hover denso no mapa de Praças pós-B10), **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo).
|
||||
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (troca de ano/métrica/escala **e** hover denso no mapa de Praças pós-B10/B11), **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo).
|
||||
|
||||
**Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados).
|
||||
|
||||
|
|
@ -97,7 +98,7 @@ Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9**
|
|||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**A9**~~ (total esperado da Praça — entregue 2026-07-21).
|
||||
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; **B11** (mapa continua em escala absoluta); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins. ~~**B9** / **B10**~~ (entregues — não cortar).
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; B6; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins. ~~**B9** / **B10** / **B11**~~ (entregues — não cortar).
|
||||
|
||||
## Já entregue (resumo)
|
||||
|
||||
|
|
@ -108,6 +109,7 @@ Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9**
|
|||
- **B7 (2026-07-21)** — **Mapa das Praças filtrado pela lista** (`buildPlazaListWhere` em `loadPlazaMapBundle`; `rawSearchParams` na página; empty → omitir painel; int `plazaMapData.int.spec.ts`) — [plano](plans/mapa-pracas-filtrado.md).
|
||||
- **B9 (2026-07-21)** — **Edição rápida na lista de Praças** (Assessores / Tendência / `expectedVotes` via Popovers em `PlazaList*Control`; `listFormActions`; sem migration) — [plano](plans/edicao-rapida-lista-pracas.md). Débitos `/simplify`: **A9+** F2 (revalidate) + **C8** F4 (DRY formActions).
|
||||
- **B10 (2026-07-21)** — **Hover/tap no Mapa das Praças** (destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 → `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`) — [plano](plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
|
||||
- **B11 (2026-07-21)** — **Escala % dos válidos no Mapa das Praças** (`validVotesByYear` no bundle; seletor `Total (votos)` / `% dos válidos`; domínio fixo 0–100%; 2026 usa válidos 2022; compare desliga %; readout em %) — [plano](plans/escala-percentual-mapa-pracas.md). Débito perf troca `scaleMode` → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
|
||||
- **Fill-in filtros-auto (2026-07-21)** — **Filtros auto-aplicados na lista de Praças** (`PlazaFilters`: debounce 1s no `q`, Enter imediato, selects imediatos, remove Buscar; `useTransition` + pending a11y; `shouldUpdatePlazaSearchUrl` + no-op via `buildPlazaFiltersKey`; sem migration) — [plano](plans/filtros-auto-pracas.md). Débitos pós-`/simplify`: sync back/forward `search`↔`state.q` e shell pending compartilhado — gatilhos no plano.
|
||||
|
||||
## Supersedidos pela remodelagem (2026-07-20)
|
||||
|
|
@ -130,9 +132,8 @@ Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9**
|
|||
|
||||
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md)
|
||||
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
|
||||
- **B6** `BahiaMap` setStyle incremental (métrica/ano + hover/select pós-B10 ✓) · gatilho: troca frequente de ano/métrica ou hover denso no mapa de Praças · appetite ~1–1,5 dia · [plano](plans/escala-dry-pos-b3.md)
|
||||
- **B6** `BahiaMap` setStyle incremental (métrica/ano/escala pós-B11 + hover/select pós-B10 ✓) · gatilho: troca frequente de ano/métrica/escala ou hover denso no mapa de Praças · appetite ~1–1,5 dia · [plano](plans/escala-dry-pos-b3.md)
|
||||
- **B8** Polígonos das Praças-zona (Salvador ZE 1–19 / Camaçari ZE 170–171): F1 catálogo zona→bairros + UI na Praça; F2 dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
|
||||
- **B11** Escala de cor por **% dos votos válidos** da Praça (0–100%, não pelo total da maior Praça) · depende de R2 (entregue) · suave: A9 ✓ (numerador 2026) · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/escala-percentual-mapa-pracas.md)
|
||||
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
|
||||
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
|
||||
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ type BahiaMapProps = {
|
|||
values: ChoroplethValues
|
||||
/** 'diverging': positive = campaign red, negative = blue, zero = white. */
|
||||
fillMode?: BahiaMapFillMode
|
||||
/** When set, overrides auto max from `values` (e.g. fixed 1 for 0–100% shares). */
|
||||
scaleMax?: number
|
||||
highlightKeys?: string[]
|
||||
selectedKey?: string | null
|
||||
onFeatureSelect?: (info: BahiaMapFeatureInfo | null) => void
|
||||
|
|
@ -129,6 +131,7 @@ export const BahiaMap = ({
|
|||
mode,
|
||||
values,
|
||||
fillMode = 'sequential',
|
||||
scaleMax,
|
||||
highlightKeys = [],
|
||||
selectedKey = null,
|
||||
onFeatureSelect,
|
||||
|
|
@ -213,7 +216,8 @@ export const BahiaMap = ({
|
|||
layerRef.current?.remove()
|
||||
|
||||
const max =
|
||||
fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values)
|
||||
scaleMax ??
|
||||
(fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values))
|
||||
const keyProperty: FeatureKeyProperty = mode === 'municipality' ? 'codarea' : 'code'
|
||||
const highlightSet = new Set(
|
||||
highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [],
|
||||
|
|
@ -342,7 +346,7 @@ export const BahiaMap = ({
|
|||
layerRef.current = null
|
||||
styleContextRef.current = null
|
||||
}
|
||||
}, [fillMode, highlightKey, mode, values])
|
||||
}, [fillMode, highlightKey, mode, scaleMax, values])
|
||||
|
||||
useEffect(() => {
|
||||
const context = styleContextRef.current
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@ import { formatElectionNumber } from '@/lib/electionInsights'
|
|||
type ChoroplethLegendProps = {
|
||||
max: number
|
||||
metricLabel: string
|
||||
formatMax?: (max: number) => string
|
||||
}
|
||||
|
||||
export const ChoroplethLegend = ({ max, metricLabel }: ChoroplethLegendProps) => (
|
||||
export const ChoroplethLegend = ({
|
||||
max,
|
||||
metricLabel,
|
||||
formatMax = formatElectionNumber,
|
||||
}: ChoroplethLegendProps) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 shrink-0 text-right text-xs tabular-nums text-muted-foreground">0</span>
|
||||
|
|
@ -14,10 +19,10 @@ export const ChoroplethLegend = ({ max, metricLabel }: ChoroplethLegendProps) =>
|
|||
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
|
||||
style={{ background: choroplethGradientCss }}
|
||||
role="img"
|
||||
aria-label={`Escala de cor de 0 a ${formatElectionNumber(max)} para ${metricLabel}`}
|
||||
aria-label={`Escala de cor de 0 a ${formatMax(max)} para ${metricLabel}`}
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(max)}
|
||||
{formatMax(max)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -4,17 +4,26 @@ import type { BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import type { PlazaMapNavigation } from '@/utilities/plazaMapNavigation'
|
||||
import type { PlazaMapScaleMode } from '@/utilities/plazaMapData'
|
||||
|
||||
type MapFeatureReadoutProps = {
|
||||
feature: BahiaMapFeatureInfo | null
|
||||
metricValue: number | undefined
|
||||
rawMetricValue: number | undefined
|
||||
metricLabel: string
|
||||
scaleMode: PlazaMapScaleMode
|
||||
comparisonActive: boolean
|
||||
navigation: PlazaMapNavigation | null
|
||||
}
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('pt-BR', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const formatMetricValue = (
|
||||
value: number | undefined,
|
||||
scaleMode: PlazaMapScaleMode,
|
||||
comparisonActive: boolean,
|
||||
): string => {
|
||||
if (value === undefined) return 'Sem dados'
|
||||
|
|
@ -22,13 +31,16 @@ const formatMetricValue = (
|
|||
const prefix = value > 0 ? '+' : ''
|
||||
return `${prefix}${formatElectionNumber(value)}`
|
||||
}
|
||||
if (scaleMode === 'percentValid') return percentFormatter.format(value)
|
||||
return formatElectionNumber(value)
|
||||
}
|
||||
|
||||
export const MapFeatureReadout = ({
|
||||
feature,
|
||||
metricValue,
|
||||
rawMetricValue,
|
||||
metricLabel,
|
||||
scaleMode,
|
||||
comparisonActive,
|
||||
navigation,
|
||||
}: MapFeatureReadoutProps) => {
|
||||
|
|
@ -40,7 +52,13 @@ export const MapFeatureReadout = ({
|
|||
)
|
||||
}
|
||||
|
||||
const formattedValue = formatMetricValue(metricValue, comparisonActive)
|
||||
const formattedValue = formatMetricValue(metricValue, scaleMode, comparisonActive)
|
||||
const showPercentSecondary =
|
||||
!comparisonActive &&
|
||||
scaleMode === 'percentValid' &&
|
||||
metricValue !== undefined &&
|
||||
rawMetricValue !== undefined &&
|
||||
rawMetricValue > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -52,13 +70,21 @@ export const MapFeatureReadout = ({
|
|||
<p className="text-sm text-muted-foreground">
|
||||
<span className="sr-only">{metricLabel}: </span>
|
||||
<span className="tabular-nums">{formattedValue}</span>
|
||||
{!comparisonActive && metricValue !== undefined ? (
|
||||
{!comparisonActive && metricValue !== undefined && scaleMode === 'absolute' ? (
|
||||
<span className="text-muted-foreground"> {metricLabel}</span>
|
||||
) : null}
|
||||
{!comparisonActive && metricValue !== undefined && scaleMode === 'percentValid' ? (
|
||||
<span className="text-muted-foreground"> dos válidos</span>
|
||||
) : null}
|
||||
{comparisonActive && metricValue !== undefined ? (
|
||||
<span className="text-muted-foreground"> (diferença)</span>
|
||||
) : null}
|
||||
</p>
|
||||
{showPercentSecondary ? (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatElectionNumber(rawMetricValue)} votos
|
||||
</p>
|
||||
) : null}
|
||||
{navigation?.kind === 'zones' ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Toque de novo no mapa ou role até as Praças por zona abaixo.
|
||||
|
|
|
|||
|
|
@ -12,14 +12,18 @@ import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
|||
import {
|
||||
choroplethMaxAbsValue,
|
||||
choroplethMaxValue,
|
||||
computeValidVoteShares,
|
||||
divergingGradientCss,
|
||||
} from '@/lib/choroplethColorScale'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
|
||||
import {
|
||||
PLAZA_MAP_SCALE_MODES,
|
||||
PLAZA_MAP_YEARS,
|
||||
plazaMapScaleModeLabels,
|
||||
plazaMapYearLabels,
|
||||
type PlazaMapBundle,
|
||||
type PlazaMapScaleMode,
|
||||
type PlazaMapYear,
|
||||
} from '@/utilities/plazaMapData'
|
||||
import { resolvePlazaMapNavigation } from '@/utilities/plazaMapNavigation'
|
||||
|
|
@ -39,22 +43,53 @@ export const PlazaMapPanel = ({
|
|||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [year, setYear] = useState<PlazaMapYear>(defaultYear)
|
||||
const [scaleMode, setScaleMode] = useState<PlazaMapScaleMode>('percentValid')
|
||||
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
|
||||
const selectedKeyRef = useRef<string | null>(null)
|
||||
|
||||
const comparison = bundle.comparison
|
||||
const comparisonActive = comparison !== null && year !== 2026
|
||||
const percentScaleActive = !comparisonActive && scaleMode === 'percentValid'
|
||||
|
||||
const values = useMemo(() => {
|
||||
const rawValues = useMemo(() => {
|
||||
if (comparisonActive && comparison) {
|
||||
return comparison.diffByYear[String(year)] ?? {}
|
||||
}
|
||||
return bundle.valuesByYear[String(year)] ?? {}
|
||||
}, [bundle, comparison, comparisonActive, year])
|
||||
|
||||
const max = comparisonActive ? choroplethMaxAbsValue(values) : choroplethMaxValue(values)
|
||||
const metricLabel =
|
||||
year === 2026 ? 'votos estimados 2026' : `votos de ${bundle.candidateName} em ${year}`
|
||||
const validVotesForYear = bundle.validVotesByYear[String(year)] ?? {}
|
||||
|
||||
const displayValues = useMemo(() => {
|
||||
if (percentScaleActive) {
|
||||
return computeValidVoteShares(rawValues, validVotesForYear)
|
||||
}
|
||||
return rawValues
|
||||
}, [percentScaleActive, rawValues, validVotesForYear])
|
||||
|
||||
const displayMax = useMemo(
|
||||
() =>
|
||||
comparisonActive
|
||||
? choroplethMaxAbsValue(displayValues)
|
||||
: percentScaleActive
|
||||
? 1
|
||||
: choroplethMaxValue(displayValues),
|
||||
[comparisonActive, displayValues, percentScaleActive],
|
||||
)
|
||||
|
||||
const metricLabel = useMemo(() => {
|
||||
if (comparisonActive) {
|
||||
return `diferença de votos em ${year}`
|
||||
}
|
||||
if (percentScaleActive) {
|
||||
if (year === 2026) {
|
||||
return 'participação nos válidos 2022 (estimativas 2026)'
|
||||
}
|
||||
return `participação nos válidos (${year})`
|
||||
}
|
||||
if (year === 2026) return 'votos estimados 2026'
|
||||
return `votos de ${bundle.candidateName} em ${year}`
|
||||
}, [bundle.candidateName, comparisonActive, percentScaleActive, year])
|
||||
|
||||
const setCompare = (candidateNumber: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
|
@ -89,7 +124,12 @@ export const PlazaMapPanel = ({
|
|||
)
|
||||
|
||||
const selectedMetricValue =
|
||||
selectedFeature && selectedFeature.key in values ? values[selectedFeature.key] : undefined
|
||||
selectedFeature && selectedFeature.key in displayValues
|
||||
? displayValues[selectedFeature.key]
|
||||
: undefined
|
||||
|
||||
const selectedRawMetricValue =
|
||||
selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined
|
||||
|
||||
const selectedNavigation = useMemo(
|
||||
() =>
|
||||
|
|
@ -112,10 +152,12 @@ export const PlazaMapPanel = ({
|
|||
<p className="text-sm text-muted-foreground">
|
||||
{comparisonActive && comparison
|
||||
? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.`
|
||||
: `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`}
|
||||
: percentScaleActive
|
||||
? `Cor pela participação nos votos válidos — em 2026, estimativas sobre válidos de 2022.`
|
||||
: `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<Field className="w-full sm:w-44">
|
||||
<FieldLabel htmlFor="plaza-map-year">Ano</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
@ -131,6 +173,23 @@ export const PlazaMapPanel = ({
|
|||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
{!comparisonActive ? (
|
||||
<Field className="w-full sm:w-44">
|
||||
<FieldLabel htmlFor="plaza-map-scale">Escala</FieldLabel>
|
||||
<NativeSelect
|
||||
id="plaza-map-scale"
|
||||
value={scaleMode}
|
||||
onChange={(event) => setScaleMode(event.target.value as PlazaMapScaleMode)}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
{PLAZA_MAP_SCALE_MODES.map((mode) => (
|
||||
<NativeSelectOption key={mode} value={mode}>
|
||||
{plazaMapScaleModeLabels[mode]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field className="w-full sm:w-72">
|
||||
<FieldLabel htmlFor="plaza-map-compare">Comparar com</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
@ -170,11 +229,15 @@ export const PlazaMapPanel = ({
|
|||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
|
||||
{comparison.candidateName} na frente.
|
||||
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
|
||||
</p>
|
||||
</div>
|
||||
) : max > 0 ? (
|
||||
<ChoroplethLegend max={max} metricLabel={metricLabel} />
|
||||
) : displayMax > 0 ? (
|
||||
<ChoroplethLegend
|
||||
max={displayMax}
|
||||
metricLabel={metricLabel}
|
||||
formatMax={percentScaleActive ? () => '100%' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
|
||||
)}
|
||||
|
|
@ -188,7 +251,8 @@ export const PlazaMapPanel = ({
|
|||
|
||||
<BahiaMap
|
||||
mode="municipality"
|
||||
values={values}
|
||||
values={displayValues}
|
||||
scaleMax={displayMax > 0 ? displayMax : undefined}
|
||||
fillMode={comparisonActive ? 'diverging' : 'sequential'}
|
||||
selectedKey={selectedFeature?.key ?? null}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
|
|
@ -203,7 +267,9 @@ export const PlazaMapPanel = ({
|
|||
<MapFeatureReadout
|
||||
feature={selectedFeature}
|
||||
metricValue={selectedMetricValue}
|
||||
rawMetricValue={selectedRawMetricValue}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={comparisonActive ? 'absolute' : scaleMode}
|
||||
comparisonActive={comparisonActive}
|
||||
navigation={selectedNavigation}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -47,3 +47,18 @@ export const choroplethMaxAbsValue = (values: Record<string, number>): number =>
|
|||
const entries = Object.values(values).map((value) => Math.abs(value))
|
||||
return entries.length > 0 ? Math.max(...entries) : 0
|
||||
}
|
||||
|
||||
/** Share of valid votes per geography (0–1). Omits entries with no valid denominator or zero votes. */
|
||||
export const computeValidVoteShares = (
|
||||
votes: Record<string, number>,
|
||||
validVotes: Record<string, number>,
|
||||
): Record<string, number> => {
|
||||
const shares: Record<string, number> = {}
|
||||
for (const [code, voteCount] of Object.entries(votes)) {
|
||||
const valid = validVotes[code]
|
||||
if (valid > 0 && voteCount > 0) {
|
||||
shares[code] = voteCount / valid
|
||||
}
|
||||
}
|
||||
return shares
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,38 @@ export const sumVotesForGeography = (
|
|||
return total
|
||||
}
|
||||
|
||||
/** cityCode:zone → votosValidos map for one year (federal T1 tally). */
|
||||
export const loadValidVotesByCityZone = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year }: { year: number },
|
||||
): Promise<Map<string, number>> => {
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'electionTally',
|
||||
where: {
|
||||
and: [
|
||||
{ year: { equals: year } },
|
||||
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
|
||||
{ turn: { equals: '1' } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { cityCode: true, zoneNumber: true, votosValidos: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const validVotes = new Map<string, number>()
|
||||
for (const row of result.docs) {
|
||||
const key = `${row.cityCode}:${row.zoneNumber}`
|
||||
validVotes.set(key, (validVotes.get(key) ?? 0) + (row.votosValidos ?? 0))
|
||||
}
|
||||
return validVotes
|
||||
}
|
||||
|
||||
/** cityCode:zone → votes map for one candidate/year (plaza-level slicing). */
|
||||
export const loadCandidateVotesByCityZone = async (
|
||||
payload: Payload,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '@/utilities/plazaElectionGeography'
|
||||
import {
|
||||
loadCandidateVotesByCityZone,
|
||||
loadValidVotesByCityZone,
|
||||
sumVotesForGeography,
|
||||
} from '@/utilities/plazaElectoralBaseline'
|
||||
import { buildPlazasByIbgeCode, type PlazasByIbgeCode } from '@/utilities/plazaMapNavigation'
|
||||
|
|
@ -29,6 +30,15 @@ export type { PlazasByIbgeCode, PlazaMapSlugEntry } from '@/utilities/plazaMapNa
|
|||
export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
|
||||
export type PlazaMapYear = (typeof PLAZA_MAP_YEARS)[number]
|
||||
|
||||
export type PlazaMapScaleMode = 'absolute' | 'percentValid'
|
||||
|
||||
export const PLAZA_MAP_SCALE_MODES = ['percentValid', 'absolute'] as const satisfies readonly PlazaMapScaleMode[]
|
||||
|
||||
export const plazaMapScaleModeLabels: Record<PlazaMapScaleMode, string> = {
|
||||
absolute: 'Total (votos)',
|
||||
percentValid: '% dos válidos',
|
||||
}
|
||||
|
||||
export const plazaMapYearLabels: Record<PlazaMapYear, string> = {
|
||||
2014: '2014 (TSE)',
|
||||
2018: '2018 (TSE)',
|
||||
|
|
@ -52,6 +62,8 @@ export type PlazaMapComparison = {
|
|||
export type PlazaMapBundle = {
|
||||
/** year (as string) → codarea → value. 2026 = expectedVotes ?? pledge effective total. */
|
||||
valuesByYear: Record<string, Record<string, number>>
|
||||
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
|
||||
validVotesByYear: Record<string, Record<string, number>>
|
||||
/** IBGE codarea → accessible plaza slugs for map click navigation. */
|
||||
plazasByIbgeCode: PlazasByIbgeCode
|
||||
/** Zone plazas in scope (Salvador/Camaçari) with per-year values. */
|
||||
|
|
@ -121,19 +133,32 @@ export const loadPlazaMapBundle = async (
|
|||
if (plazas.length === 0) return null
|
||||
|
||||
const valuesByYear: Record<string, Record<string, number>> = {}
|
||||
const validVotesByYear: Record<string, Record<string, number>> = {}
|
||||
const zoneVotesBySlug = new Map<string, Record<string, number>>()
|
||||
const sollaVotesByYear = new Map<number, Map<string, number>>()
|
||||
|
||||
for (const year of HISTORICAL_SERIES_YEARS) {
|
||||
const votesByCityZone = await loadCandidateVotesByCityZone(payload, user, {
|
||||
year,
|
||||
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
|
||||
})
|
||||
const yearLoads = await Promise.all(
|
||||
HISTORICAL_SERIES_YEARS.map(async (year) => {
|
||||
const [votesByCityZone, validByCityZone] = await Promise.all([
|
||||
loadCandidateVotesByCityZone(payload, user, {
|
||||
year,
|
||||
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
|
||||
}),
|
||||
loadValidVotesByCityZone(payload, user, { year }),
|
||||
])
|
||||
return { year, votesByCityZone, validByCityZone }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const { year, votesByCityZone, validByCityZone } of yearLoads) {
|
||||
sollaVotesByYear.set(year, votesByCityZone)
|
||||
const values: Record<string, number> = {}
|
||||
const validValues: Record<string, number> = {}
|
||||
for (const plaza of plazas) {
|
||||
const votes = sumVotesForGeography(votesByCityZone, plaza.geography)
|
||||
values[plaza.ibgeCode] = (values[plaza.ibgeCode] ?? 0) + votes
|
||||
const valid = sumVotesForGeography(validByCityZone, plaza.geography)
|
||||
validValues[plaza.ibgeCode] = (validValues[plaza.ibgeCode] ?? 0) + valid
|
||||
if (plaza.kind === 'zona') {
|
||||
const bySlug = zoneVotesBySlug.get(plaza.slug) ?? {}
|
||||
bySlug[String(year)] = votes
|
||||
|
|
@ -141,7 +166,9 @@ export const loadPlazaMapBundle = async (
|
|||
}
|
||||
}
|
||||
valuesByYear[String(year)] = values
|
||||
validVotesByYear[String(year)] = validValues
|
||||
}
|
||||
validVotesByYear['2026'] = validVotesByYear['2022'] ?? {}
|
||||
|
||||
const pledgeAggregates = await aggregatePledgesByPlaza(
|
||||
payload,
|
||||
|
|
@ -191,11 +218,16 @@ export const loadPlazaMapBundle = async (
|
|||
|
||||
if (candidate) {
|
||||
const diffByYear: Record<string, Record<string, number>> = {}
|
||||
for (const year of HISTORICAL_SERIES_YEARS) {
|
||||
const otherVotes = await loadCandidateVotesByCityZone(payload, user, {
|
||||
year,
|
||||
candidateNumber: state.compare,
|
||||
})
|
||||
const otherVotesByYear = await Promise.all(
|
||||
HISTORICAL_SERIES_YEARS.map((year) =>
|
||||
loadCandidateVotesByCityZone(payload, user, {
|
||||
year,
|
||||
candidateNumber: state.compare!,
|
||||
}),
|
||||
),
|
||||
)
|
||||
for (const [index, year] of HISTORICAL_SERIES_YEARS.entries()) {
|
||||
const otherVotes = otherVotesByYear[index]!
|
||||
const sollaVotes = sollaVotesByYear.get(year) ?? new Map<string, number>()
|
||||
const values: Record<string, number> = {}
|
||||
for (const plaza of plazas) {
|
||||
|
|
@ -216,6 +248,7 @@ export const loadPlazaMapBundle = async (
|
|||
|
||||
return {
|
||||
valuesByYear,
|
||||
validVotesByYear,
|
||||
plazasByIbgeCode: buildPlazasByIbgeCode(plazas),
|
||||
zoneBreakdown,
|
||||
candidateName: BASELINE_TICKET_2022.candidate.name,
|
||||
|
|
|
|||
|
|
@ -72,4 +72,16 @@ describe('loadPlazaMapBundle — list URL filters', () => {
|
|||
: await loadPlazaMapBundle(payload, advisor, { kind: 'zona' })
|
||||
expect(excluded).toBeNull()
|
||||
})
|
||||
|
||||
it('includes validVotesByYear for TSE years and reuses 2022 for 2026', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
||||
const bundle = await loadPlazaMapBundle(payload, coordinator, {})
|
||||
|
||||
expect(bundle).not.toBeNull()
|
||||
expect(bundle!.validVotesByYear['2014']).toBeDefined()
|
||||
expect(bundle!.validVotesByYear['2018']).toBeDefined()
|
||||
expect(bundle!.validVotesByYear['2022']).toBeDefined()
|
||||
expect(bundle!.validVotesByYear['2026']).toBe(bundle!.validVotesByYear['2022'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
27
tests/unit/choroplethPercentScale.unit.spec.ts
Normal file
27
tests/unit/choroplethPercentScale.unit.spec.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeValidVoteShares } from '@/lib/choroplethColorScale'
|
||||
|
||||
describe('computeValidVoteShares', () => {
|
||||
it('computes share as votes divided by valid votes', () => {
|
||||
expect(
|
||||
computeValidVoteShares({ '2927408': 500 }, { '2927408': 1000 }),
|
||||
).toEqual({ '2927408': 0.5 })
|
||||
})
|
||||
|
||||
it('omits geographies with zero valid votes', () => {
|
||||
expect(
|
||||
computeValidVoteShares({ '2927408': 500 }, { '2927408': 0 }),
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('omits geographies with zero votes', () => {
|
||||
expect(
|
||||
computeValidVoteShares({ '2927408': 0 }, { '2927408': 1000 }),
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('omits geographies missing from valid votes', () => {
|
||||
expect(computeValidVoteShares({ '2927408': 500 }, {})).toEqual({})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue