Deliver B6 incremental BahiaMap setStyle and absorb post-simplify debts.

Keep GeoJSON stable across metric/scale/hover changes via pathByKeyRef and O(2) restyle; extract bahiaMapStyle helpers with unit tests and close B10/B11 perf debt refs in plans.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 16:02:20 -03:00
parent e099b70002
commit 7f437741ba
10 changed files with 419 additions and 194 deletions

View file

@ -7,10 +7,11 @@ alwaysApply: false
## Status
- **B6 entregue em código (2026-07-21; deploy pendente com remodelagem):** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers puros em `bahiaMapStyle.ts` + unit `bahiaMapStyle.unit.spec.ts`. Plano: [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md). Fecha débitos de perf de **B10** e **B11**.
- **A9+ entregue em código (2026-07-21; deploy pendente com remodelagem):** loader compartilhado da lista — `loadPlazaListPageBundle` (1× `aggregatePledgesByPlaza`), `buildPlazaMapBundleFromPlazas`, `plazaRevalidation.ts` + hidden `plazaSlug`; int `plazaPageData.int.spec.ts`. Plano: [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md). Débito restante: twin `listFormActions` ↔ `/editar` → **C8** F4.
- **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).
- **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).
- **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).
- **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).
- **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` → **C8** F4 (prep revalidate/parsing no A9+).
- **A9 entregue em código (2026-07-21; deploy pendente com remodelagem):** `plaza.expectedVotes` staff-only, fallback `expectedVotes ?? effectiveTotal` em mapa 2026/overview/dashboard, UI `/editar` + leitura lista/detalhe; migration `20260721_133444_add_plaza_expected_votes`. Plano: [`estimativa-votos-praca.md`](../../../docs/plans/estimativa-votos-praca.md).
- **REMODELAGEM ENTREGUE EM CÓDIGO (2026-07-21; deploy pendente — migração `20260721_020109_remodel_plazas` é destrutiva para a vertical campanha, revisar SQL antes do build de produção). Suítes verdes: unit 187, int 306 (incl. assimetria declarado×estimado, access por papel, workflow de demandas), e2e 9. Critique/polish visual fino registrado como R6 no roadmap.** Feedback da coordenação invalidou o modelo de núcleo abstrato. Decisões travadas: 436 Praças pré-definidas (415 municípios + 19 zonas de Salvador + 2 de Camaçari; zonas compartilhadas cortadas na divisa municipal); código `plaza`; roles renomeados para `coordinator`/`advisor`/`leader` (labels "Coordenador Geral"/"Assessor"/"Liderança"); assessor vê só as Praças que administra; liderança = 1 registro por pessoa com N Praças e N organizações; votos por liderança×Praça (`declaredVotes` visível à liderança, `estimatedVotes` staff-only); demandas com workflow assessor→coordenador geral + comprovantes staff-only; tendência política manual (staff); comparativo multi-candidato/multi-ano por Praça; mapa divergente vermelho↔branco↔azul. Reset dos dados de campanha em produção (site público intocado). Plano-mestre: [`docs/plans/remodelagem-pracas.md`](../../../docs/plans/remodelagem-pracas.md). As entradas abaixo descrevem a era Núcleos — padrões de infra (locks, transações, consent por chave, shells, mapa, dados eleitorais) continuam válidos; superfícies e modelo de Núcleo foram substituídos.

View file

@ -47,7 +47,9 @@ 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`.
**Recently resolved (2026-07-21):** **B6** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers em `src/lib/bahiaMapStyle.ts`. Fecha débitos de perf de B10/B11. Plano: `docs/plans/escala-dry-pos-b3.md`.
**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). Plano: `docs/plans/escala-percentual-mapa-pracas.md`.
## Posts & Tags (news / publications)

View file

@ -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-B11 `/simplify`: troca `scaleMode` absorvida em Fase 1; pós-B10: hover/select)
Status: entregue em código (2026-07-21)
Atualizado em: 2026-07-21 (B6 implementado + triage `capture-review-debts` pós-`/simplify`: gatilhos absorvidos; sem item novo no roadmap)
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)
@ -17,9 +17,9 @@ Os revisores (performance) marcaram como **importante e maior que simplify** os
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.
**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`. **Pós-B10 simplify:** dedup de hover (`styleContextRef.hoveredKey` único); merge dos effects de callback-ref; `emphasizeFeature` compartilhado; skip de refresh quando hover inalterado ou hover possui estilo; `formatElectionNumber` na zone breakdown de `PlazaMapPanel`; `useMemo` de `selectedNavigation`.
**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`. **Pós-B10 simplify:** dedup de hover (`styleContextRef.hoveredKey` único); merge dos effects de callback-ref; `emphasizeFeature` compartilhado; skip de refresh quando hover inalterado ou hover possui estilo; `formatElectionNumber` na zone breakdown de `PlazaMapPanel`; `useMemo` de `selectedNavigation`. **B6 entrega:** split effects (`[mode]` mount vs `[values, fillMode, scaleMax, highlightKey, mode]` restyle); `pathByKeyRef` + hover/select 2-path; `fitBounds` só em `mode`/`highlightKey`; helpers puros em `src/lib/bahiaMapStyle.ts` + `tests/unit/bahiaMapStyle.unit.spec.ts`; `PlazaMapPanel.displayMax` via `computeChoroplethMax`. **Pós-B6 `/simplify`:** remoção de `keyProperty` morto em `LayerStyleContext`; DRY `keyPropertyForMode` / `featureKeyFromProperties`; fix selectedKey após `mouseout` com hover; `syncMountStyleContext()`; remoção de `mode` não usado em `buildLayerStyleContext`.
**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). **Triage `capture-review-debts` 2026-07-21 pós-B10:** extrair `DivergingChoroplethLegend` (1 consumer — polish opcional em **R6**); helper `scrollIntoView` (1 call site em `#plaza-zone-breakdown`); throttle/defer do `setState` do readout no hover (trade-off produto; sem evidência de perf); mudar contrato activate/select para remover `selectedKeyRef` (intencional para 2º tap mobile — gatilho: 3º consumidor do mapa precisar API diferente); migração campanha-wide `Intl` → `formatElectionNumber` (B10 zone list já corrigida; gatilho: **E6** F4 em núcleos ou 3ª superfície Praça com formatter local).
**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). **Triage `capture-review-debts` 2026-07-21 pós-B10:** extrair `DivergingChoroplethLegend` (1 consumer — polish opcional em **R6**); helper `scrollIntoView` (1 call site em `#plaza-zone-breakdown`); throttle/defer do `setState` do readout no hover (trade-off produto; sem evidência de perf); mudar contrato activate/select para remover `selectedKeyRef` (intencional para 2º tap mobile — gatilho: 3º consumidor do mapa precisar API diferente); migração campanha-wide `Intl` → `formatElectionNumber` (B10 zone list já corrigida; gatilho: **E6** F4 em núcleos ou 3ª superfície Praça com formatter local). **Triage `capture-review-debts` 2026-07-21 pós-B6:** `useReducer` para contexto de estilo (1 call site — YAGNI); refactor `highlightKeys` string↔`Set` sem ganho medido; brand tokens / `DivergingChoroplethLegend` → **R6** (inalterado).
## Objetivos
@ -89,13 +89,16 @@ flowchart TD
## Rabbit holes
- **Refatorar todos os embeds de uma vez.** `NucleusOverviewMap`, `DashboardMap` e `PlazaMapPanel` compartilham `BahiaMap` — mudança de API de estilo pode quebrar highlight programático. **Mitigação:** Fase 1 valida nos três embeds + mapa de Praças com hover; não introduzir `react-leaflet`.
- **Refatorar todos os embeds de uma vez.** Pós-remodelagem só `PlazaMapPanel` consome `BahiaMap` — validar troca de ano/escala/compare + hover denso. **Mitigação:** um renderer, índice de layers compartilhado; `territory` mode mantido na API.
- **Separar componente só para Praças.** Duplicar `BahiaMap` para otimizar hover fragmenta o coroplético. **Mitigação:** um renderer, índice de layers compartilhado.
## Adiado com gatilho
- **Contrato activate/select sem `selectedKeyRef`.** Revisitar quando um 3º consumidor do mapa precisar semântica diferente de 2º tap mobile (hoje intencional em B10).
- **`formatElectionNumber` campanha-wide.** Revisitar via **E6** F4 (núcleos) ou quando uma 3ª superfície Praça reintroduzir `Intl.NumberFormat` local.
- **Remover `restyleAllPaths()` pós-mount assíncrono.** Hoje necessário para race entre mount do GeoJSON e props iniciais; revisitar se Leaflet expuser callback estável pós-`addData` ou se profiling mostrar custo irrelevante.
- **Restyle incremental só em mudança de `highlightKey` (sem `restyleAllPaths`).** Revisitar quando highlight programático (ex. deep-link futuro) gerar jank medido em DevTools.
- **Cache de bounds em `fitMapToHighlights`.** Revisitar com **B12** (viewport) ou se `fitBounds` repetido aparecer no profiling após filtro URL estável.
## Referências
@ -104,8 +107,8 @@ flowchart TD
- `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; `applyLayerStyles` / hover B10
- `src/components/campaign/PlazaMapPanel.tsx` — consumidor B10 com `selectedKey` + readout
- `src/components/campaign/ChoroplethMapPanel.tsx` — troca de métrica
- `src/components/campaign/PlazaMapPanel.tsx` — consumidor B10/B11 com `selectedKey` + readout
- `src/components/campaign/ChoroplethLegend.tsx` — legenda do mapa de Praças
- `docs/plans/hover-mapa-pracas.md` — plano pai B10 (navegação fora de escopo B6)
- `src/lib/bahiaGeometries.ts` — lazy loaders
- AGENTS.md — naming inglês; B3 sem migration

View file

@ -26,11 +26,11 @@ O **Mapa das Praças** (`PlazaMapPanel` em `/campanha/pracas`) pinta municípios
Os votos nominais já vêm de `loadCandidateVotesByCityZone` + `sumVotesForGeography` em `plazaMapData.ts`. O denominador pedido — **votos válidos** (sem nulos, brancos nem abstenções) — já existe em `electionTally.votosValidos` (seed TSE; usado no detalhe via `plazaElectoralBaseline` / `PlazaBaselineCard`). Pedido de produto (2026-07-21): opção de escala **percentual na Praça**, domínio fixo **0% → 100%**.
Não cobre: filtro URL (**B7**), hover (**B10**), `setStyle` incremental (**B6**), polígonos-zona (**B8**).
Não cobre: filtro URL (**B7**), hover (**B10**), polígonos-zona (**B8**). ~~`setStyle` incremental~~ → **B6 ✓**.
**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).
**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
@ -90,7 +90,7 @@ Componentes:
- Hover com readout → **B10** ([hover-mapa-pracas.md](hover-mapa-pracas.md)); quando ambos existirem, o readout em modo % deve mostrar % (não só votos) — coordenar na implementação do que estiver segundo.
- Diff percentual no modo Comparar → adiado (rabbit hole abaixo).
- Polígonos Praças-zona → **B8**.
- `setStyle` incremental → **B6**.
- ~~`setStyle` incremental~~ → **B6 ✓**.
- Alterar classificação territorial A5 / limiares — já entregue; só compartilha a noção de `% dos válidos`.
## Rabbit holes

View file

@ -1,13 +1,13 @@
# Hover no Mapa das Praças (destaque + total de votos + navegação)
Status: entregue 2026-07-21 (branch; deploy pendente com remodelagem)
Atualizado em: 2026-07-21 (navegação: desktop click / mobile 2º tap; débitos perf → B6)
Atualizado em: 2026-07-21 (navegação: desktop click / mobile 2º tap; B6 ✓ setStyle incremental)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B10 — entregue 2026-07-21)
Impeccable: B — encaixe em `PlazaMapPanel` / `BahiaMap` (sem rota nova)
Appetite: ~0,5–1 dia eng; handlers Leaflet + faixa + mapa ibge→slug + teste/checklist
Responsável: —
_Revisão 2026-07-21 (pós-implementação + `/simplify`): `BahiaMap` com `onFeatureSelect` / `onFeatureActivate`; `MapFeatureReadout`; `plazasByIbgeCode` + `resolvePlazaMapNavigation`; SSA/CMS N>1 → scroll `#plaza-zone-breakdown`. Cleanup: dedup hover, merge effects, `emphasizeFeature`. Débito hot path O(n) `eachLayer` → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md))._
_Revisão 2026-07-21 (pós-implementação + `/simplify`): `BahiaMap` com `onFeatureSelect` / `onFeatureActivate`; `MapFeatureReadout`; `plazasByIbgeCode` + `resolvePlazaMapNavigation`; SSA/CMS N>1 → scroll `#plaza-zone-breakdown`. Cleanup: dedup hover, merge effects, `emphasizeFeature`. Débito hot path O(n) `eachLayer` → **B6 ✓** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md))._
## Design (Impeccable)
@ -28,7 +28,7 @@ O **Mapa das Praças** (`PlazaMapPanel` em `/campanha/pracas`) já pinta municí
Pedido de produto (2026-07-21): hover destaca + mostra votos; **click navega**. Em mobile (sem hover): **primeiro toque seleciona** (mesmo readout); **segundo toque na já selecionada** navega.
Não cobre: filtro URL (**B7**), `setStyle` incremental ao trocar ano (**B6**), polígonos por zona (**B8**).
Não cobre: filtro URL (**B7**), polígonos por zona (**B8**). ~~`setStyle` incremental ao trocar ano~~ → **B6 ✓**.
## Objetivos
@ -78,13 +78,13 @@ Componentes:
## Dependências
- **Dura:** R2 (mapa Praças) — entregue.
- **Suave:** A9 (métrica 2026); B6 (`setStyle`); B7 (filtro → `plazasByIbgeCode` só do conjunto filtrado); B8 (aí o 2º tap em SSA/CMS pode passar a ir à Praça-zona).
- **Suave:** A9 (métrica 2026); ~~B6 (`setStyle`)~~ entregue; B7 (filtro → `plazasByIbgeCode` só do conjunto filtrado); B8 (aí o 2º tap em SSA/CMS pode passar a ir à Praça-zona).
- Reusa: `PlazaMapPanel`, `BahiaMap`, `plazaMapData`, formatters existentes.
## Não escopo
- Filtrar mapa pela URL da lista → **B7** ([mapa-pracas-filtrado.md](mapa-pracas-filtrado.md)).
- Rebuild/perf do layer ao trocar ano → **B6** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)).
- ~~Rebuild/perf do layer ao trocar ano~~ → **B6 ✓** ([escala-dry-pos-b3.md](escala-dry-pos-b3.md)).
- Polígonos / hover por Praça-zona SSA/CMS → **B8** ([poligonos-pracas-zona.md](poligonos-pracas-zona.md)).
- Redesenho do painel / mover filtros → **R6**.

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (B12 registrado — aproximar mapa ao filtro; A9+/B7/B9–B11/filtros-auto entregues; débitos B9 → C8 F4; B10/B11 → B6)
Atualizado em: 2026-07-21 (B6 entregue em código; B12 registrado — aproximar mapa ao filtro; A9+/B7/B9–B11/filtros-auto entregues; débitos B9 → C8 F4)
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).
@ -78,13 +78,13 @@ flowchart TD
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"]
B10 -.hover densos.-> B6["B6 setStyle incremental ✓"]
B11 -.troca escala.-> B6
JUR -.chave push.-> D2
C2prod --> C5["C5 GOTV (validar)"]
```
Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da lista (**C8** F4, …). ~~**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).
Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da lista (**C8** F4, …). ~~**A9** / **A9+** / **B9** / **B7** / **B10** / **B11** / **B6** / **filtros-auto**~~ entregues 2026-07-21.
### Sequência por janela
@ -92,7 +92,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
**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/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 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), ~~**B6** `setStyle` incremental~~ entregue, **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).
@ -100,7 +100,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
**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; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); **B12** (mapa continua usável com pan/zoom manual); débitos/fill-ins. ~~**B9** / **B10** / **B11**~~ (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; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); **B12** (mapa continua usável com pan/zoom manual); débitos/fill-ins. ~~**B9** / **B10** / **B11** / **B6**~~ (entregues — não cortar).
## Já entregue (resumo)
@ -111,8 +111,9 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
- **A9+ (2026-07-21)** — **Loader compartilhado da lista de Praças** (`loadPlazaListPageBundle`: 1× `aggregatePledgesByPlaza` + `buildPlazaMapBundleFromPlazas`; `plazaRevalidation.ts` + `plazaSlug` para revalidate estreita no detalhe; int `plazaPageData.int.spec.ts`; sem migration) — [plano](plans/escala-dry-pos-a9.md). Débito restante pós-`/simplify`: twin completo `listFormActions` ↔ `/editar` → **C8** F4.
- **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`: **C8** F4 (DRY formActions; prep revalidate/parsing feito no A9+).
- **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).
- **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).
- **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).
- **B6 (2026-07-21)** — **`BahiaMap` setStyle incremental** (layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers em `bahiaMapStyle.ts`) — [plano](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)
@ -135,7 +136,6 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins da
- **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/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)
- **B12** Aproximar o Mapa das Praças ao footprint filtrado (`fitToKeys` no `BahiaMap`; sem re-zoom em Ano/Escala) · Janela 1 · cortável · gatilho do B7 · [plano](plans/aproximar-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

View file

@ -13,14 +13,18 @@ import {
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import {
choroplethFillColor,
choroplethMaxAbsValue,
choroplethMaxValue,
divergingFillColor,
} from '@/lib/choroplethColorScale'
buildHighlightSet,
buildLayerStyleContext,
featureKeyFromProperties,
keyPropertyForMode,
resolvePathStyle,
type BahiaMapFillMode,
type ChoroplethValues,
type LayerStyleContext,
} from '@/lib/bahiaMapStyle'
import { cn } from '@/lib/utils'
export type ChoroplethValues = Record<string, number>
export type { BahiaMapFillMode, ChoroplethValues }
const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
[-18.5, -46.8],
@ -29,42 +33,11 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
export type BahiaMapMode = 'municipality' | 'territory'
type FeatureKeyProperty = 'codarea' | 'code'
const featureKey = (
properties: Record<string, string> | undefined,
keyProperty: FeatureKeyProperty,
): string | undefined => properties?.[keyProperty]
export type BahiaMapFillMode = 'sequential' | 'diverging'
export type BahiaMapFeatureInfo = {
key: string
name: string
}
type FeatureStyleInput = {
metric: number
highlighted: boolean
fillMode: BahiaMapFillMode
max: number
}
const getFeatureStyle = ({
metric,
highlighted,
fillMode,
max,
}: FeatureStyleInput): L.PathOptions => ({
weight: highlighted ? 2.5 : 1,
color: highlighted ? '#c51414' : '#a8a29e',
fillColor:
fillMode === 'diverging'
? divergingFillColor(metric, max)
: choroplethFillColor(metric, max),
fillOpacity: highlighted ? 0.92 : metric !== 0 ? 0.78 : 0.35,
})
type BahiaMapProps = {
mode: BahiaMapMode
values: ChoroplethValues
@ -81,52 +54,42 @@ type BahiaMapProps = {
ariaLabel: string
}
type LayerStyleContext = {
values: ChoroplethValues
fillMode: BahiaMapFillMode
max: number
keyProperty: FeatureKeyProperty
highlightSet: Set<string>
selectedKey: string | null
hoveredKey: string | null
}
const resolveLayerKey = (
layer: L.Layer,
keyProperty: FeatureKeyProperty,
): string | undefined => {
if (!(layer instanceof L.Path)) return undefined
const feature = (layer as L.Path & { feature?: Feature }).feature
const properties = feature?.properties as Record<string, string> | undefined
return featureKey(properties, keyProperty)
}
const applyLayerStyles = (layer: L.GeoJSON, context: LayerStyleContext) => {
const activeKey = context.hoveredKey ?? context.selectedKey
layer.eachLayer((featureLayer) => {
if (!(featureLayer instanceof L.Path)) return
const key = resolveLayerKey(featureLayer, context.keyProperty)
const metric = key ? (context.values[key] ?? 0) : 0
const highlighted = key
? context.highlightSet.has(key) || key === activeKey
: false
featureLayer.setStyle(
getFeatureStyle({
metric,
highlighted,
fillMode: context.fillMode,
max: context.max,
}),
)
})
}
const getPointerType = (event: L.LeafletMouseEvent, fallback = 'mouse') =>
event.originalEvent instanceof PointerEvent ? event.originalEvent.pointerType : fallback
const fitMapToHighlights = (
map: L.Map,
mode: BahiaMapMode,
highlightSet: Set<string>,
geometryModule: MunicipalityGeometryModule | TerritoryGeometryModule,
) => {
if (highlightSet.size === 0) {
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
return
}
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) {
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
return
}
const highlightBounds = L.geoJSON(highlightedFeatures).getBounds()
if (highlightBounds.isValid()) {
map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 })
}
}
export const BahiaMap = ({
mode,
values,
@ -143,23 +106,74 @@ export const BahiaMap = ({
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(null)
const pathByKeyRef = useRef<Map<string, L.Path>>(new Map())
const styleContextRef = useRef<LayerStyleContext | null>(null)
const geometryReadyRef = useRef(false)
const touchPinnedRef = useRef(false)
const onFeatureSelectRef = useRef(onFeatureSelect)
const onFeatureActivateRef = useRef(onFeatureActivate)
const stylePropsRef = useRef({
values,
fillMode,
scaleMax,
highlightKey: '',
selectedKey,
})
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const highlightKey = useMemo(() => highlightKeys.slice().sort().join(','), [highlightKeys])
stylePropsRef.current = {
values,
fillMode,
scaleMax,
highlightKey,
selectedKey,
}
useEffect(() => {
onFeatureSelectRef.current = onFeatureSelect
onFeatureActivateRef.current = onFeatureActivate
}, [onFeatureActivate, onFeatureSelect])
const refreshLayerStyles = () => {
const layer = layerRef.current
const restyleFeature = (key: string) => {
const context = styleContextRef.current
if (!layer || !context) return
applyLayerStyles(layer, context)
const path = pathByKeyRef.current.get(key)
if (!context || !path) return
path.setStyle(resolvePathStyle(context, key))
}
const restyleAllPaths = () => {
const context = styleContextRef.current
if (!context) return
for (const [key, path] of pathByKeyRef.current) {
path.setStyle(resolvePathStyle(context, key))
}
}
const setHoveredKey = (nextKey: string | null, bringToFrontLayer?: L.Path) => {
const context = styleContextRef.current
if (!context) return
const previousKey = context.hoveredKey
if (previousKey === nextKey) return
if (previousKey) {
restyleFeature(previousKey)
}
context.hoveredKey = nextKey
if (nextKey) {
restyleFeature(nextKey)
;(bringToFrontLayer ?? pathByKeyRef.current.get(nextKey))?.bringToFront()
return
}
if (context.selectedKey && context.selectedKey !== previousKey) {
restyleFeature(context.selectedKey)
}
}
useEffect(() => {
@ -193,7 +207,10 @@ export const BahiaMap = ({
map.remove()
mapRef.current = null
layerRef.current = null
geometryModuleRef.current = null
pathByKeyRef.current = new Map()
styleContextRef.current = null
geometryReadyRef.current = false
}
}, [])
@ -203,6 +220,7 @@ export const BahiaMap = ({
let cancelled = false
setStatus('loading')
geometryReadyRef.current = false
const renderLayer = async () => {
try {
@ -214,60 +232,37 @@ export const BahiaMap = ({
if (cancelled) return
layerRef.current?.remove()
pathByKeyRef.current = new Map()
const max =
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) : [],
)
geometryModuleRef.current = geometryModule
const styleContext: LayerStyleContext = {
values,
fillMode,
max,
keyProperty,
highlightSet,
selectedKey,
hoveredKey: null,
const keyProperty = keyPropertyForMode(mode)
const syncMountStyleContext = () => {
styleContextRef.current = buildLayerStyleContext({
...stylePropsRef.current,
hoveredKey: null,
})
}
styleContextRef.current = styleContext
const emphasizeFeature = (featureLayer: L.Path, nextKey: string) => {
const context = styleContextRef.current
if (!context) return
const alreadyEmphasized = context.hoveredKey === nextKey
context.hoveredKey = nextKey
if (!alreadyEmphasized) {
refreshLayerStyles()
featureLayer.bringToFront()
}
}
syncMountStyleContext()
const mountContext = styleContextRef.current!
const layer = L.geoJSON([...geometryModule.features], {
style: (feature?: Feature) => {
const properties = feature?.properties as Record<string, string> | undefined
const key = featureKey(properties, keyProperty)
const metric = key ? (values[key] ?? 0) : 0
const highlighted = key ? highlightSet.has(key) || key === selectedKey : false
return getFeatureStyle({
metric,
highlighted,
fillMode,
max,
})
const key = featureKeyFromProperties(properties, keyProperty)
const context = styleContextRef.current ?? mountContext
return resolvePathStyle(context, key ?? '')
},
onEachFeature: (feature, featureLayer) => {
if (!(featureLayer instanceof L.Path)) return
const properties = feature.properties as Record<string, string> | undefined
const key = featureKey(properties, keyProperty)
const key = featureKeyFromProperties(properties, keyProperty)
if (!key) return
const name = properties?.name ?? key
pathByKeyRef.current.set(key, featureLayer)
featureLayer.on('mouseover', (event) => {
const pointerType = getPointerType(event)
@ -279,23 +274,20 @@ export const BahiaMap = ({
if (styleContextRef.current?.hoveredKey === key) return
emphasizeFeature(featureLayer, key)
setHoveredKey(key, featureLayer)
onFeatureSelectRef.current?.({ key, name })
})
featureLayer.on('mouseout', () => {
if (touchPinnedRef.current) return
if (styleContextRef.current) {
styleContextRef.current.hoveredKey = null
}
refreshLayerStyles()
setHoveredKey(null)
onFeatureSelectRef.current?.(null)
})
featureLayer.on('click', (event) => {
touchPinnedRef.current = getPointerType(event, 'mouse') === 'touch'
emphasizeFeature(featureLayer, key)
setHoveredKey(key, featureLayer)
onFeatureActivateRef.current?.(key)
onFeatureSelectRef.current?.({ key, name })
})
@ -304,34 +296,19 @@ export const BahiaMap = ({
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] })
}
syncMountStyleContext()
restyleAllPaths()
fitMapToHighlights(
map,
mode,
styleContextRef.current!.highlightSet,
geometryModule,
)
geometryReadyRef.current = true
setStatus('ready')
} catch {
if (!cancelled) setStatus('error')
@ -344,17 +321,61 @@ export const BahiaMap = ({
cancelled = true
layerRef.current?.remove()
layerRef.current = null
styleContextRef.current = null
pathByKeyRef.current = new Map()
geometryModuleRef.current = null
geometryReadyRef.current = false
}
}, [fillMode, highlightKey, mode, scaleMax, values])
// Geometry layer rebuilds only when `mode` changes; metric/hover updates use separate effects.
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional [mode]-only mount
}, [mode])
useEffect(() => {
if (!geometryReadyRef.current) return
const map = mapRef.current
const geometryModule = geometryModuleRef.current
if (!map || !geometryModule || !layerRef.current) return
fitMapToHighlights(map, mode, buildHighlightSet(highlightKey), geometryModule)
}, [highlightKey, mode])
useEffect(() => {
const previousContext = styleContextRef.current
const context = buildLayerStyleContext({
values,
fillMode,
scaleMax,
highlightKey,
selectedKey,
hoveredKey: previousContext?.hoveredKey ?? null,
})
styleContextRef.current = context
if (pathByKeyRef.current.size === 0) return
restyleAllPaths()
// `selectedKey` changes use the dedicated 2-path effect below (not full O(n) restyle).
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedKey excluded on purpose
}, [values, fillMode, scaleMax, highlightKey, mode])
useEffect(() => {
const context = styleContextRef.current
if (!context || context.selectedKey === selectedKey) return
if (!context || pathByKeyRef.current.size === 0) {
if (context) context.selectedKey = selectedKey
return
}
if (context.selectedKey === selectedKey) return
const previousSelected = context.selectedKey
context.selectedKey = selectedKey
if (context.hoveredKey) return
refreshLayerStyles()
if (previousSelected) {
restyleFeature(previousSelected)
}
if (selectedKey) {
restyleFeature(selectedKey)
}
}, [selectedKey])
return (

View file

@ -9,9 +9,8 @@ import { ChoroplethLegend } from '@/components/campaign/ChoroplethLegend'
import { MapFeatureReadout } from '@/components/campaign/MapFeatureReadout'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { computeChoroplethMax } from '@/lib/bahiaMapStyle'
import {
choroplethMaxAbsValue,
choroplethMaxValue,
computeValidVoteShares,
divergingGradientCss,
} from '@/lib/choroplethColorScale'
@ -67,15 +66,14 @@ export const PlazaMapPanel = ({
return rawValues
}, [percentScaleActive, rawValues, validVotesForYear])
const displayMax = useMemo(
() =>
comparisonActive
? choroplethMaxAbsValue(displayValues)
: percentScaleActive
? 1
: choroplethMaxValue(displayValues),
[comparisonActive, displayValues, percentScaleActive],
)
const displayMax = useMemo(() => {
const fillMode = comparisonActive ? 'diverging' : 'sequential'
return computeChoroplethMax(
displayValues,
fillMode,
percentScaleActive ? 1 : undefined,
)
}, [comparisonActive, displayValues, percentScaleActive])
const metricLabel = useMemo(() => {
if (comparisonActive) {

109
src/lib/bahiaMapStyle.ts Normal file
View file

@ -0,0 +1,109 @@
import type { PathOptions } from 'leaflet'
import {
choroplethFillColor,
choroplethMaxAbsValue,
choroplethMaxValue,
divergingFillColor,
} from '@/lib/choroplethColorScale'
export type ChoroplethValues = Record<string, number>
export type BahiaMapFillMode = 'sequential' | 'diverging'
export type FeatureKeyProperty = 'codarea' | 'code'
export type LayerStyleContext = {
values: ChoroplethValues
fillMode: BahiaMapFillMode
max: number
highlightSet: Set<string>
selectedKey: string | null
hoveredKey: string | null
}
type FeatureStyleInput = {
metric: number
highlighted: boolean
fillMode: BahiaMapFillMode
max: number
}
export const getFeatureStyle = ({
metric,
highlighted,
fillMode,
max,
}: FeatureStyleInput): PathOptions => ({
weight: highlighted ? 2.5 : 1,
color: highlighted ? '#c51414' : '#a8a29e',
fillColor:
fillMode === 'diverging'
? divergingFillColor(metric, max)
: choroplethFillColor(metric, max),
fillOpacity: highlighted ? 0.92 : metric !== 0 ? 0.78 : 0.35,
})
export const computeChoroplethMax = (
values: ChoroplethValues,
fillMode: BahiaMapFillMode,
scaleMax?: number,
): number =>
scaleMax ??
(fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values))
export const buildHighlightSet = (highlightKey: string): Set<string> =>
new Set(highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [])
export const keyPropertyForMode = (mode: 'municipality' | 'territory'): FeatureKeyProperty =>
mode === 'municipality' ? 'codarea' : 'code'
export const featureKeyFromProperties = (
properties: Record<string, string> | undefined,
keyProperty: FeatureKeyProperty,
): string | undefined => properties?.[keyProperty]
export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => {
if (!key) {
return getFeatureStyle({
metric: 0,
highlighted: false,
fillMode: context.fillMode,
max: context.max,
})
}
const activeKey = context.hoveredKey ?? context.selectedKey
const metric = context.values[key] ?? 0
const highlighted = context.highlightSet.has(key) || key === activeKey
return getFeatureStyle({
metric,
highlighted,
fillMode: context.fillMode,
max: context.max,
})
}
export const buildLayerStyleContext = ({
values,
fillMode,
scaleMax,
highlightKey,
selectedKey,
hoveredKey = null,
}: {
values: ChoroplethValues
fillMode: BahiaMapFillMode
scaleMax?: number
highlightKey: string
selectedKey: string | null
hoveredKey?: string | null
}): LayerStyleContext => ({
values,
fillMode,
max: computeChoroplethMax(values, fillMode, scaleMax),
highlightSet: buildHighlightSet(highlightKey),
selectedKey,
hoveredKey,
})

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import {
buildLayerStyleContext,
computeChoroplethMax,
resolvePathStyle,
} from '@/lib/bahiaMapStyle'
describe('bahiaMapStyle', () => {
const baseContext = buildLayerStyleContext({
values: { '2927408': 500, '2905701': 200 },
fillMode: 'sequential',
highlightKey: '',
selectedKey: null,
})
describe('computeChoroplethMax', () => {
it('uses scaleMax when provided', () => {
expect(computeChoroplethMax({ '2927408': 999 }, 'sequential', 1)).toBe(1)
})
it('derives max from positive values in sequential mode', () => {
expect(computeChoroplethMax({ '2927408': 100, '2905701': 300 }, 'sequential')).toBe(300)
})
it('uses absolute max in diverging mode', () => {
expect(computeChoroplethMax({ '2927408': -40, '2905701': 100 }, 'diverging')).toBe(100)
})
})
describe('resolvePathStyle', () => {
it('highlights hovered feature over selected', () => {
const context = {
...baseContext,
selectedKey: '2905701',
hoveredKey: '2927408',
}
expect(resolvePathStyle(context, '2927408').weight).toBe(2.5)
expect(resolvePathStyle(context, '2905701').weight).toBe(1)
})
it('highlights selected feature when not hovering', () => {
const context = {
...baseContext,
selectedKey: '2905701',
hoveredKey: null,
}
expect(resolvePathStyle(context, '2905701').weight).toBe(2.5)
})
it('reveals selected highlight after hover clears on a different feature', () => {
const context = {
...baseContext,
selectedKey: '2905701',
hoveredKey: null,
}
expect(resolvePathStyle(context, '2927408').weight).toBe(1)
expect(resolvePathStyle(context, '2905701').weight).toBe(2.5)
})
it('highlights keys in highlightSet', () => {
const context = {
...baseContext,
highlightSet: new Set(['2927408']),
}
expect(resolvePathStyle(context, '2927408').weight).toBe(2.5)
})
it('uses diverging fill for negative metrics', () => {
const context = buildLayerStyleContext({
values: { '2927408': -100 },
fillMode: 'diverging',
scaleMax: 200,
highlightKey: '',
selectedKey: null,
})
expect(resolvePathStyle(context, '2927408').fillColor).toMatch(/^rgb\(/)
})
it('returns neutral style for missing feature key', () => {
const style = resolvePathStyle(baseContext, '')
expect(style.fillOpacity).toBe(0.35)
expect(style.weight).toBe(1)
})
})
})