From 7f437741ba1721a4dba3d6f50db97c14dafc815f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 21 Jul 2026 16:02:20 -0300 Subject: [PATCH] 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. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 5 +- AGENTS.md | 4 +- docs/plans/escala-dry-pos-b3.md | 17 +- docs/plans/escala-percentual-mapa-pracas.md | 6 +- docs/plans/hover-mapa-pracas.md | 10 +- docs/roadmap.md | 16 +- src/components/campaign/BahiaMap.tsx | 335 ++++++++++-------- src/components/campaign/PlazaMapPanel.tsx | 20 +- src/lib/bahiaMapStyle.ts | 109 ++++++ tests/unit/bahiaMapStyle.unit.spec.ts | 91 +++++ 10 files changed, 419 insertions(+), 194 deletions(-) create mode 100644 src/lib/bahiaMapStyle.ts create mode 100644 tests/unit/bahiaMapStyle.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index a6476873..2cb963e0 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 8fc1edbd..523d2107 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/docs/plans/escala-dry-pos-b3.md b/docs/plans/escala-dry-pos-b3.md index ce0f1398..a284e74c 100644 --- a/docs/plans/escala-dry-pos-b3.md +++ b/docs/plans/escala-dry-pos-b3.md @@ -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`) — 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 diff --git a/docs/plans/escala-percentual-mapa-pracas.md b/docs/plans/escala-percentual-mapa-pracas.md index 89e09cff..659ebb8d 100644 --- a/docs/plans/escala-percentual-mapa-pracas.md +++ b/docs/plans/escala-percentual-mapa-pracas.md @@ -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 diff --git a/docs/plans/hover-mapa-pracas.md b/docs/plans/hover-mapa-pracas.md index 75c0721e..7b5cd54c 100644 --- a/docs/plans/hover-mapa-pracas.md +++ b/docs/plans/hover-mapa-pracas.md @@ -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**. diff --git a/docs/roadmap.md b/docs/roadmap.md index 1ffb0f15..346aa213 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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
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 diff --git a/src/components/campaign/BahiaMap.tsx b/src/components/campaign/BahiaMap.tsx index a801e876..3026b482 100644 --- a/src/components/campaign/BahiaMap.tsx +++ b/src/components/campaign/BahiaMap.tsx @@ -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 +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 | 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 - 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 | 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, + 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(null) const mapRef = useRef(null) const layerRef = useRef(null) + const geometryModuleRef = useRef(null) + const pathByKeyRef = useRef>(new Map()) const styleContextRef = useRef(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 | 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 | 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 ( diff --git a/src/components/campaign/PlazaMapPanel.tsx b/src/components/campaign/PlazaMapPanel.tsx index 37bdf60c..348da011 100644 --- a/src/components/campaign/PlazaMapPanel.tsx +++ b/src/components/campaign/PlazaMapPanel.tsx @@ -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) { diff --git a/src/lib/bahiaMapStyle.ts b/src/lib/bahiaMapStyle.ts new file mode 100644 index 00000000..b81a0ed2 --- /dev/null +++ b/src/lib/bahiaMapStyle.ts @@ -0,0 +1,109 @@ +import type { PathOptions } from 'leaflet' + +import { + choroplethFillColor, + choroplethMaxAbsValue, + choroplethMaxValue, + divergingFillColor, +} from '@/lib/choroplethColorScale' + +export type ChoroplethValues = Record + +export type BahiaMapFillMode = 'sequential' | 'diverging' + +export type FeatureKeyProperty = 'codarea' | 'code' + +export type LayerStyleContext = { + values: ChoroplethValues + fillMode: BahiaMapFillMode + max: number + highlightSet: Set + 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 => + 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 | 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, +}) diff --git a/tests/unit/bahiaMapStyle.unit.spec.ts b/tests/unit/bahiaMapStyle.unit.spec.ts new file mode 100644 index 00000000..ffd022c4 --- /dev/null +++ b/tests/unit/bahiaMapStyle.unit.spec.ts @@ -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) + }) + }) +})