diff --git a/docs/plans/escala-dry-pos-b3.md b/docs/plans/escala-dry-pos-b3.md index 7b154c2a..268fbca3 100644 --- a/docs/plans/escala-dry-pos-b3.md +++ b/docs/plans/escala-dry-pos-b3.md @@ -1,28 +1,32 @@ # Escala e DRY pós-B3 (Leaflet / coroplético) Status: registrado no roadmap (fases pendentes) -Atualizado em: 2026-07-19 (`capture-review-debts` pós-B3 + 2× `/simplify`) +Atualizado em: 2026-07-21 (`capture-review-debts` pós-B10 `/simplify`: hover/select absorvido em Fase 1) 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) Responsável: — ## Contexto O B3 ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md) Fase 2) entrega `BahiaMap` + embeds (`NucleusOverviewMap`, `NucleusDetailMap`, `DashboardMap`), agregação servidor em `nucleusChoropleth.ts` / `nucleusChoroplethPageData.ts`, e lazy load de geometrias (B5 F1 ✓). Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: `createCampaignClientDynamic`, `resolveChoroplethNuclei` / single-pass bundle, remoção de `geographyForCityNames` duplicado, `toNucleusElectionGeographyInput` no dashboard, highlight O(k) via `getMunicipalityFeature` / `getTerritoryFeature`, `NucleusDetailMap` `useMemo`, alinhamento de tipos com `NucleusElectionGeographyInput`. -Os revisores (performance) marcaram como **importante e maior que simplify** o follow-up abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos. +Os revisores (performance) marcaram como **importante e maior que simplify** os follow-ups abaixo. Sem registro, cada troca de métrica no coroplético (overview/dashboard) refaz decode + `L.geoJSON` completo em 417 ou 27 polígonos; e, desde **B10** ([hover-mapa-pracas.md](hover-mapa-pracas.md)), cada hover/select no mapa de Praças chama `applyLayerStyles` com `eachLayer` em ~417 municípios. 1. **`BahiaMap` rebuild completo do layer GeoJSON** quando só `values` (métrica) ou `highlightKeys` mudam. O effect em `BahiaMap.tsx` depende de `[highlightKey, mode, values]` e, a cada mudança, remove o layer, recria `L.geoJSON` com todas as features e reaplica estilos — mesmo que `mode` e o módulo de geometria já estejam carregados. O caminho correto para troca de métrica é `layer.setStyle(...)` (e `fitBounds` só quando o highlight muda). -**Já resolvido no simplify/critique (não reabrir):** B5 F1 lazy split mun/TI + `dynamic(..., { ssr: false })`; `createCampaignClientDynamic`; choropleth single-resolve (`resolveChoroplethNuclei`); DRY `geographyForCityNames` → `resolveNucleusElectionGeography`; dashboard `toNucleusElectionGeographyInput`; `ChoroplethMapPanel` passa `max`; detail map `useMemo`; highlight bounds O(k); type alignment `NucleusChoroplethNucleus`. +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. -**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). +**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`. + +**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). ## Objetivos - Trocar métrica (núcleos / estimativa / baseline 2022) ou modo município/TI no coroplético **não** recria o layer GeoJSON inteiro — apenas atualiza `fillColor` / borda via `setStyle` quando a geometria já está montada. +- Hover/select no mapa de Praças (B10) restyla só o polígono anterior + o ativo — não `eachLayer` em ~417 paths por `mouseover`. - `fitBounds` roda só quando `highlightKeys` ou `mode` mudam, não a cada alteração de `values`. -- Guardrails: sem migration, sem collection, sem Consent, sem server action. Client-only; manter acessibilidade (`role="img"`, `aria-busy`, `aria-label`). +- Guardrails: sem migration, sem collection, sem Consent, sem server action. Client-only; manter acessibilidade (`role="img"`, `aria-busy`, `aria-label`, `aria-live` do readout B10 inalterado). ## Decisões travadas @@ -30,13 +34,15 @@ Os revisores (performance) marcaram como **importante e maior que simplify** o f - **Dependência suave de B3.** Só faz sentido com `BahiaMap` no produto; não reabre escopo de coroplético, tiles ou agregação servidor. - **Query duplicada de votos 2022 na lista de núcleos** (`loadNucleusListElectionOverview` + `loadNucleusChoroplethBundle`) **não** entra neste plano — absorvida como **A7 Fase 4** em [escala-dry-pos-a4.md](escala-dry-pos-a4.md) (mesmo módulo `nucleusElectoralBaseline.ts`). - **Factory mun/TI** near-duplicate → **B5 Fase 3** em [escala-dry-pos-b2.md](escala-dry-pos-b2.md). -- **Cortável** se o mapa for raramente usado ou se troca de métrica permanecer aceitável em campo; vira não-cortável quando coordenadores alternarem métricas com frequência no overview filtrado. +- **Cortável** se o mapa for raramente usado ou se troca de métrica permanecer aceitável em campo; vira não-cortável quando coordenadores alternarem métricas com frequência no overview filtrado **ou** hover denso no mapa de Praças (B10 entregue). +- **Dependência suave de B10.** Hover/select já existe em `PlazaMapPanel` + `BahiaMap`; Fase 1 unifica o caminho de estilo incremental — não reabre escopo de navegação/readout do B10. - **i18n e naming** (AGENTS.md): identificadores em inglês; strings visíveis em pt-BR inalteradas. ## Questões em aberto - **Split em dois effects ou ref de layer estável?** **Recomendação:** effect 1 monta mapa + tiles + carrega geometria por `mode` (deps `[mode]`); effect 2 aplica estilo quando `[values, highlightKey]` mudam — se `layerRef.current` existe, `setStyle` + `fitBounds` condicional; senão aguarda effect 1. - **Manter `values` como objeto novo a cada render do parent?** **Recomendação:** não bloquear Fase 1 — `ChoroplethMapPanel` já deriva `values` estáveis por métrica; se profiling mostrar churn, memoizar no parent num follow-up micro (fora de escopo). +- **Índice `Map` vs 2-path hover?** **Opções:** (A) mapa estável no mount do GeoJSON; (B) guardar `previousHoveredKey` / `previousSelectedKey` e restylar só dois layers. **Recomendação:** (A) se o refactor já toca `onEachFeature` — simplifica métrica + hover + highlight programático; (B) como fallback se o mapa de layers for inviável nos embeds de núcleo. ## Abordagem proposta @@ -44,7 +50,8 @@ Os revisores (performance) marcaram como **importante e maior que simplify** o f flowchart TD B3["B3 Leaflet + coroplético"] --> F1 B5F1["B5 F1 lazy geometrias ✓"] --> F1 - F1["Fase 1 — BahiaMap setStyle
(split effects geometria vs estilo)"] + B10["B10 Hover/tap Praças ✓"] --> F1 + F1["Fase 1 — BahiaMap setStyle
(split effects + hover incremental)"] F1 -.não bloqueia.-> B4["B4 Zonas TSE no mapa"] ``` @@ -53,10 +60,12 @@ flowchart TD - `src/components/campaign/BahiaMap.tsx`: - Separar mount do mapa/OSM (`useEffect` once) do load de geometria por `mode`. - Guardar `geometryModule` em ref após primeiro load; recriar layer só quando `mode` muda. - - Em updates de `values` / `highlightKey`: iterar `layerRef.current.eachLayer` ou manter `Map` e chamar `setStyle` com `choroplethFillColor` / borda highlight. + - Manter `Map` (ou equivalente) no mount do GeoJSON para restyle O(1) por chave. + - Em updates de `values` / `highlightKey`: `setStyle` por layer afetado — **não** `eachLayer` full-scan salvo fallback de migração. + - Hover/select (B10): restylar só polígono anterior + ativo (`hoveredKey`, `selectedKey`); unificar callback `style` inicial com o caminho de `applyLayerStyles` / `getFeatureStyle`. - `fitBounds`: highlight quando `highlightSet` não vazio; senão `BAHIA_BOUNDS` — só quando highlight ou `mode` mudam. -- Testes: unit leve com mock de `L.geoJSON` / spy em `setStyle` se viável; senão checklist manual (trocar métrica 3× no overview sem flicker perceptível). -- Critério: DevTools Performance — troca de métrica não dispara novo `import()` de topojson nem aloca 400+ layers. +- Testes: unit leve com mock de `L.geoJSON` / spy em `setStyle` se viável; senão checklist manual (trocar métrica 3× no overview **e** hover 10× no mapa de Praças sem flicker perceptível). +- Critério: DevTools Performance — troca de métrica não dispara novo `import()` de topojson nem aloca 400+ layers; hover não dispara >2 `setStyle` por evento. **Migration:** nenhuma. @@ -64,7 +73,8 @@ flowchart TD - **Suave:** B3 Mapa Fase 2 (Leaflet) — implementado no branch; merge pendente. - **Suave:** B5 F1 lazy geometrias — entregue com B3. -- Reusa: `choroplethFillColor` / `choroplethMaxValue` (`src/lib/choroplethColorScale.ts`), loaders `loadMunicipalityGeometryModule` / `loadTerritoryGeometryModule`, tipos `ChoroplethValues`. +- **Suave:** B10 hover/tap no mapa de Praças — entregue 2026-07-21; Fase 1 consolida o hot path de estilo sem alterar navegação/readout. +- Reusa: `choroplethFillColor` / `choroplethMaxValue` (`src/lib/choroplethColorScale.ts`), loaders `loadMunicipalityGeometryModule` / `loadTerritoryGeometryModule`, tipos `ChoroplethValues`, `emphasizeFeature` / `getFeatureStyle` (B10). ## Não escopo @@ -72,6 +82,17 @@ flowchart TD - Factory TopoJSON mun/TI → **B5 F3** ([escala-dry-pos-b2.md](escala-dry-pos-b2.md)). - Camada de zonas TSE, rota `/mapa`, métricas tendência/prioridade no coroplético → **B3/B4/E1/E2**. - `react-leaflet` — manter `leaflet` direto conforme B3. +- Navegação/readout/2º tap do B10 — permanecem em [hover-mapa-pracas.md](hover-mapa-pracas.md). + +## 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`. +- **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. ## Referências @@ -79,7 +100,9 @@ flowchart TD - `docs/plans/mapa-bahia-geometrias.md` — plano pai B3 - `docs/plans/escala-dry-pos-b2.md` — B5 F1/F3 - `docs/plans/escala-dry-pos-a4.md` — A7 F4 (query duplicada) -- `src/components/campaign/BahiaMap.tsx` — effect que rebuilda layer +- `src/components/campaign/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 +- `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/roadmap.md b/docs/roadmap.md index 751b997b..dff960d5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-21 (B7 entregue; A9+ loader + revalidate pós-B9; débitos B9 `/simplify` → C8 F4 + A9+; Pixel Meta) +Atualizado em: 2026-07-21 (débitos B10 `/simplify` → B6 hover incremental em `escala-dry-pos-b3.md`) 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). @@ -88,7 +88,7 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **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 (mapa com troca de ano/métrica frequente), **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo). +**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), B6 `setStyle` incremental (troca de ano/métrica **e** hover denso no mapa de Praças pós-B10), **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo). **Janela 4 — set → 04/10:** C5 GOTV _(validar)_, congelamento ~20/09 (só bugfix/dados). @@ -126,7 +126,7 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): - **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 ao trocar métrica/ano · gatilho: troca frequente no mapa de Praças · [plano](plans/escala-dry-pos-b3.md) +- **B6** `BahiaMap` setStyle incremental (métrica/ano + hover/select pós-B10) · gatilho: troca frequente de ano/métrica ou hover denso no mapa de Praças · appetite ~1–1,5 dia · [plano](plans/escala-dry-pos-b3.md) - **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) - **B9** Edição rápida na lista de Praças (Assessores, Tendência, **Votos estimados = `expectedVotes`**) · depende de A9 ✓ · appetite ~1–1,5 dia · [plano](plans/edicao-rapida-lista-pracas.md) - **B10** Hover/tap no Mapa das Praças: destaque + votos; desktop click navega; mobile 2º tap na selecionada navega · depende de R2 · suave: A9 ✓/B6/B8 · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/hover-mapa-pracas.md) diff --git a/src/components/campaign/BahiaMap.tsx b/src/components/campaign/BahiaMap.tsx index e0a87fc0..455eb0ca 100644 --- a/src/components/campaign/BahiaMap.tsx +++ b/src/components/campaign/BahiaMap.tsx @@ -38,22 +38,101 @@ const featureKey = ( 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 /** 'diverging': positive = campaign red, negative = blue, zero = white. */ fillMode?: BahiaMapFillMode highlightKeys?: string[] + selectedKey?: string | null + onFeatureSelect?: (info: BahiaMapFeatureInfo | null) => void + onFeatureActivate?: (key: string) => void className?: string heightClassName?: string 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 + export const BahiaMap = ({ mode, values, fillMode = 'sequential', highlightKeys = [], + selectedKey = null, + onFeatureSelect, + onFeatureActivate, className, heightClassName = 'h-[320px]', ariaLabel, @@ -61,9 +140,25 @@ export const BahiaMap = ({ const containerRef = useRef(null) const mapRef = useRef(null) const layerRef = useRef(null) + const styleContextRef = useRef(null) + const touchPinnedRef = useRef(false) + const onFeatureSelectRef = useRef(onFeatureSelect) + const onFeatureActivateRef = useRef(onFeatureActivate) const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') const highlightKey = useMemo(() => highlightKeys.slice().sort().join(','), [highlightKeys]) + useEffect(() => { + onFeatureSelectRef.current = onFeatureSelect + onFeatureActivateRef.current = onFeatureActivate + }, [onFeatureActivate, onFeatureSelect]) + + const refreshLayerStyles = () => { + const layer = layerRef.current + const context = styleContextRef.current + if (!layer || !context) return + applyLayerStyles(layer, context) + } + useEffect(() => { const container = containerRef.current if (!container || mapRef.current) return @@ -95,6 +190,7 @@ export const BahiaMap = ({ map.remove() mapRef.current = null layerRef.current = null + styleContextRef.current = null } }, []) @@ -123,22 +219,82 @@ export const BahiaMap = ({ highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [], ) + const styleContext: LayerStyleContext = { + values, + fillMode, + max, + keyProperty, + highlightSet, + selectedKey, + 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() + } + } + 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) : false + const highlighted = key ? highlightSet.has(key) || key === selectedKey : false - return { - 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, - } + return getFeatureStyle({ + metric, + highlighted, + fillMode, + max, + }) + }, + onEachFeature: (feature, featureLayer) => { + if (!(featureLayer instanceof L.Path)) return + + const properties = feature.properties as Record | undefined + const key = featureKey(properties, keyProperty) + if (!key) return + + const name = properties?.name ?? key + + featureLayer.on('mouseover', (event) => { + const pointerType = getPointerType(event) + if (pointerType === 'mouse') { + touchPinnedRef.current = false + } else if (touchPinnedRef.current) { + return + } + + if (styleContextRef.current?.hoveredKey === key) return + + emphasizeFeature(featureLayer, key) + onFeatureSelectRef.current?.({ key, name }) + }) + + featureLayer.on('mouseout', () => { + if (touchPinnedRef.current) return + if (styleContextRef.current) { + styleContextRef.current.hoveredKey = null + } + refreshLayerStyles() + onFeatureSelectRef.current?.(null) + }) + + featureLayer.on('click', (event) => { + touchPinnedRef.current = getPointerType(event, 'mouse') === 'touch' + + emphasizeFeature(featureLayer, key) + onFeatureActivateRef.current?.(key) + onFeatureSelectRef.current?.({ key, name }) + }) }, }) @@ -184,9 +340,19 @@ export const BahiaMap = ({ cancelled = true layerRef.current?.remove() layerRef.current = null + styleContextRef.current = null } }, [fillMode, highlightKey, mode, values]) + useEffect(() => { + const context = styleContextRef.current + if (!context || context.selectedKey === selectedKey) return + + context.selectedKey = selectedKey + if (context.hoveredKey) return + refreshLayerStyles() + }, [selectedKey]) + return (
diff --git a/src/components/campaign/MapFeatureReadout.tsx b/src/components/campaign/MapFeatureReadout.tsx new file mode 100644 index 00000000..c257116d --- /dev/null +++ b/src/components/campaign/MapFeatureReadout.tsx @@ -0,0 +1,76 @@ +import Link from 'next/link' + +import type { BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap' +import { Button } from '@/components/ui/button' +import { formatElectionNumber } from '@/lib/electionInsights' +import type { PlazaMapNavigation } from '@/utilities/plazaMapNavigation' + +type MapFeatureReadoutProps = { + feature: BahiaMapFeatureInfo | null + metricValue: number | undefined + metricLabel: string + comparisonActive: boolean + navigation: PlazaMapNavigation | null +} + +const formatMetricValue = ( + value: number | undefined, + comparisonActive: boolean, +): string => { + if (value === undefined) return 'Sem dados' + if (comparisonActive) { + const prefix = value > 0 ? '+' : '' + return `${prefix}${formatElectionNumber(value)}` + } + return formatElectionNumber(value) +} + +export const MapFeatureReadout = ({ + feature, + metricValue, + metricLabel, + comparisonActive, + navigation, +}: MapFeatureReadoutProps) => { + if (!feature) { + return ( +

+ Passe o cursor ou toque em um município para ver os votos. +

+ ) + } + + const formattedValue = formatMetricValue(metricValue, comparisonActive) + + return ( +
+
+

{feature.name}

+

+ {metricLabel}: + {formattedValue} + {!comparisonActive && metricValue !== undefined ? ( + {metricLabel} + ) : null} + {comparisonActive && metricValue !== undefined ? ( + (diferença) + ) : null} +

+ {navigation?.kind === 'zones' ? ( +

+ Toque de novo no mapa ou role até as Praças por zona abaixo. +

+ ) : null} +
+ + {navigation?.kind === 'navigate' ? ( + + ) : null} +
+ ) +} diff --git a/src/components/campaign/PlazaMapPanel.tsx b/src/components/campaign/PlazaMapPanel.tsx index 0e50d1e8..1944fa17 100644 --- a/src/components/campaign/PlazaMapPanel.tsx +++ b/src/components/campaign/PlazaMapPanel.tsx @@ -2,10 +2,11 @@ import Link from 'next/link' import { usePathname, useRouter, useSearchParams } from 'next/navigation' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' -import { BahiaMap } from '@/components/campaign/BahiaMap' +import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap' 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 { @@ -13,6 +14,7 @@ import { choroplethMaxValue, divergingGradientCss, } from '@/lib/choroplethColorScale' +import { formatElectionNumber } from '@/lib/electionInsights' import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions' import { PLAZA_MAP_YEARS, @@ -20,8 +22,7 @@ import { type PlazaMapBundle, type PlazaMapYear, } from '@/utilities/plazaMapData' - -const voteFormatter = new Intl.NumberFormat('pt-BR') +import { resolvePlazaMapNavigation } from '@/utilities/plazaMapNavigation' type PlazaMapPanelProps = { bundle: PlazaMapBundle @@ -38,6 +39,8 @@ export const PlazaMapPanel = ({ const pathname = usePathname() const searchParams = useSearchParams() const [year, setYear] = useState(defaultYear) + const [selectedFeature, setSelectedFeature] = useState(null) + const selectedKeyRef = useRef(null) const comparison = bundle.comparison const comparisonActive = comparison !== null && year !== 2026 @@ -60,6 +63,42 @@ export const PlazaMapPanel = ({ router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false }) } + const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => { + selectedKeyRef.current = info?.key ?? null + setSelectedFeature(info) + }, []) + + const handleFeatureActivate = useCallback( + (key: string) => { + if (selectedKeyRef.current !== key) return + + const navigation = resolvePlazaMapNavigation(key, bundle.plazasByIbgeCode) + if (navigation.kind === 'navigate') { + router.push(`/campanha/pracas/${navigation.slug}`) + return + } + + if (navigation.kind === 'zones') { + document.getElementById('plaza-zone-breakdown')?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }) + } + }, + [bundle.plazasByIbgeCode, router], + ) + + const selectedMetricValue = + selectedFeature && selectedFeature.key in values ? values[selectedFeature.key] : undefined + + const selectedNavigation = useMemo( + () => + selectedFeature + ? resolvePlazaMapNavigation(selectedFeature.key, bundle.plazasByIbgeCode) + : null, + [bundle.plazasByIbgeCode, selectedFeature], + ) + return (
+ + {bundle.zoneBreakdown.length > 0 && !comparisonActive ? ( -
+

Praças por zona eleitoral

Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e @@ -178,7 +228,7 @@ export const PlazaMapPanel = ({ {zone.name} - {voteFormatter.format(zone.votesByYear[String(year)] ?? 0)} + {formatElectionNumber(zone.votesByYear[String(year)] ?? 0)} ))} diff --git a/src/utilities/plazaMapData.ts b/src/utilities/plazaMapData.ts index 4efcbdd4..29f1b237 100644 --- a/src/utilities/plazaMapData.ts +++ b/src/utilities/plazaMapData.ts @@ -11,6 +11,7 @@ import { loadCandidateVotesByCityZone, sumVotesForGeography, } from '@/utilities/plazaElectoralBaseline' +import { buildPlazasByIbgeCode, type PlazasByIbgeCode } from '@/utilities/plazaMapNavigation' import { buildPlazaListWhere, parsePlazaListParams, @@ -23,6 +24,8 @@ import { resolvePlazaStaffVoteTotal, } from '@/utilities/votePledgeData' +export type { PlazasByIbgeCode, PlazaMapSlugEntry } from '@/utilities/plazaMapNavigation' + export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const export type PlazaMapYear = (typeof PLAZA_MAP_YEARS)[number] @@ -49,6 +52,8 @@ export type PlazaMapComparison = { export type PlazaMapBundle = { /** year (as string) → codarea → value. 2026 = expectedVotes ?? pledge effective total. */ valuesByYear: Record> + /** IBGE codarea → accessible plaza slugs for map click navigation. */ + plazasByIbgeCode: PlazasByIbgeCode /** Zone plazas in scope (Salvador/Camaçari) with per-year values. */ zoneBreakdown: PlazaZoneBreakdownRow[] candidateName: string @@ -211,6 +216,7 @@ export const loadPlazaMapBundle = async ( return { valuesByYear, + plazasByIbgeCode: buildPlazasByIbgeCode(plazas), zoneBreakdown, candidateName: BASELINE_TICKET_2022.candidate.name, comparison, diff --git a/src/utilities/plazaMapNavigation.ts b/src/utilities/plazaMapNavigation.ts new file mode 100644 index 00000000..83a03d22 --- /dev/null +++ b/src/utilities/plazaMapNavigation.ts @@ -0,0 +1,43 @@ +export type PlazaMapSlugEntry = { + slug: string + name: string +} + +export type PlazasByIbgeCode = Record + +export type PlazaMapNavigation = + | { kind: 'none' } + | { kind: 'navigate'; slug: string } + | { kind: 'zones' } + +type PlazaForIbgeIndex = { + slug: string + name: string + ibgeCode: string +} + +export const buildPlazasByIbgeCode = (plazas: PlazaForIbgeIndex[]): PlazasByIbgeCode => { + const byIbge: PlazasByIbgeCode = {} + + for (const plaza of plazas) { + const entries = byIbge[plaza.ibgeCode] ?? [] + entries.push({ slug: plaza.slug, name: plaza.name }) + byIbge[plaza.ibgeCode] = entries + } + + for (const ibgeCode of Object.keys(byIbge)) { + byIbge[ibgeCode].sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) + } + + return byIbge +} + +export const resolvePlazaMapNavigation = ( + ibgeCode: string, + plazasByIbgeCode: PlazasByIbgeCode, +): PlazaMapNavigation => { + const entries = plazasByIbgeCode[ibgeCode] + if (!entries || entries.length === 0) return { kind: 'none' } + if (entries.length === 1) return { kind: 'navigate', slug: entries[0].slug } + return { kind: 'zones' } +} diff --git a/tests/unit/plazaMapNavigation.unit.spec.ts b/tests/unit/plazaMapNavigation.unit.spec.ts new file mode 100644 index 00000000..86904824 --- /dev/null +++ b/tests/unit/plazaMapNavigation.unit.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { + buildPlazasByIbgeCode, + resolvePlazaMapNavigation, +} from '@/utilities/plazaMapNavigation' + +describe('plazaMapNavigation', () => { + it('buildPlazasByIbgeCode groups plazas by ibgeCode and sorts by name', () => { + const result = buildPlazasByIbgeCode([ + { slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' }, + { slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' }, + { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' }, + ]) + + expect(result['2900108']).toEqual([{ slug: 'abaira', name: 'Abaíra' }]) + expect(result['2927408']).toEqual([ + { slug: 'salvador-ze-1', name: 'Salvador ZE 1' }, + { slug: 'salvador-ze-2', name: 'Salvador ZE 2' }, + ]) + }) + + it('resolvePlazaMapNavigation returns none when ibgeCode is missing or empty', () => { + const byIbge = buildPlazasByIbgeCode([ + { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' }, + ]) + + expect(resolvePlazaMapNavigation('9999999', byIbge)).toEqual({ kind: 'none' }) + expect(resolvePlazaMapNavigation('2900108', {})).toEqual({ kind: 'none' }) + }) + + it('resolvePlazaMapNavigation returns navigate for a single plaza', () => { + const byIbge = buildPlazasByIbgeCode([ + { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' }, + ]) + + expect(resolvePlazaMapNavigation('2900108', byIbge)).toEqual({ + kind: 'navigate', + slug: 'abaira', + }) + }) + + it('resolvePlazaMapNavigation returns zones for multiple plazas on the same ibgeCode', () => { + const byIbge = buildPlazasByIbgeCode([ + { slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' }, + { slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' }, + ]) + + expect(resolvePlazaMapNavigation('2927408', byIbge)).toEqual({ kind: 'zones' }) + }) +})