Deliver B10 hover/tap on Praças map and absorb perf debts into B6.

Adds polygon highlight, vote readout, and navigation (desktop click / mobile 2nd tap) on the filtered Praças choropleth, with simplify cleanup and B6 plan updates for incremental setStyle.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 11:58:49 -03:00
parent 70c61ab49e
commit bb5129d514
8 changed files with 447 additions and 32 deletions

View file

@ -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<featureKey, PathLayer>`) — 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<featureKey, layer>` 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<br/>(split effects geometria vs estilo)"]
B10["B10 Hover/tap Praças ✓"] --> F1
F1["Fase 1 — BahiaMap setStyle<br/>(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<featureKey, layer>` e chamar `setStyle` com `choroplethFillColor` / borda highlight.
- Manter `Map<featureKey, PathLayer>` (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

View file

@ -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)

View file

@ -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<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
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<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const styleContextRef = useRef<LayerStyleContext | null>(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<string, string> | 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<string, string> | 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 (
<div className={cn('relative w-full', className)}>
<div
@ -195,7 +361,7 @@ export const BahiaMap = ({
aria-label={ariaLabel}
aria-busy={status === 'loading'}
className={cn(
'w-full overflow-hidden rounded-lg border border-border bg-muted/30',
'w-full overflow-hidden rounded-lg border border-border bg-muted/30 [&_.leaflet-interactive]:cursor-pointer',
heightClassName,
)}
/>

View file

@ -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 (
<p className="min-h-11 content-center text-sm text-muted-foreground" aria-live="polite">
Passe o cursor ou toque em um município para ver os votos.
</p>
)
}
const formattedValue = formatMetricValue(metricValue, comparisonActive)
return (
<div
className="flex flex-col gap-2 rounded-lg border bg-muted/30 px-3 py-2 sm:flex-row sm:items-center sm:justify-between"
aria-live="polite"
>
<div className="flex min-w-0 flex-col gap-0.5">
<p className="truncate text-sm font-medium">{feature.name}</p>
<p className="text-sm text-muted-foreground">
<span className="sr-only">{metricLabel}: </span>
<span className="tabular-nums">{formattedValue}</span>
{!comparisonActive && metricValue !== undefined ? (
<span className="text-muted-foreground"> {metricLabel}</span>
) : null}
{comparisonActive && metricValue !== undefined ? (
<span className="text-muted-foreground"> (diferença)</span>
) : null}
</p>
{navigation?.kind === 'zones' ? (
<p className="text-xs text-muted-foreground">
Toque de novo no mapa ou role até as Praças por zona abaixo.
</p>
) : null}
</div>
{navigation?.kind === 'navigate' ? (
<Button asChild className="min-h-11 shrink-0">
<Link href={`/campanha/pracas/${navigation.slug}`}>Abrir Praça</Link>
</Button>
) : null}
</div>
)
}

View file

@ -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<PlazaMapYear>(defaultYear)
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
const selectedKeyRef = useRef<string | null>(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 (
<section
aria-labelledby="plaza-map-title"
@ -151,6 +190,9 @@ export const PlazaMapPanel = ({
mode="municipality"
values={values}
fillMode={comparisonActive ? 'diverging' : 'sequential'}
selectedKey={selectedFeature?.key ?? null}
onFeatureSelect={handleFeatureSelect}
onFeatureActivate={handleFeatureActivate}
ariaLabel={
comparisonActive && comparison
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
@ -158,8 +200,16 @@ export const PlazaMapPanel = ({
}
/>
<MapFeatureReadout
feature={selectedFeature}
metricValue={selectedMetricValue}
metricLabel={metricLabel}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div className="flex flex-col gap-2">
<div id="plaza-zone-breakdown" className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
<p className="text-sm text-muted-foreground">
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}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{voteFormatter.format(zone.votesByYear[String(year)] ?? 0)}
{formatElectionNumber(zone.votesByYear[String(year)] ?? 0)}
</span>
</li>
))}

View file

@ -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<string, Record<string, number>>
/** IBGE codarea → accessible plaza slugs for map click navigation. */
plazasByIbgeCode: PlazasByIbgeCode
/** Zone plazas in scope (Salvador/Camaçari) with per-year values. */
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,

View file

@ -0,0 +1,43 @@
export type PlazaMapSlugEntry = {
slug: string
name: string
}
export type PlazasByIbgeCode = Record<string, PlazaMapSlugEntry[]>
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' }
}

View file

@ -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' })
})
})