mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
70c61ab49e
commit
bb5129d514
8 changed files with 447 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
76
src/components/campaign/MapFeatureReadout.tsx
Normal file
76
src/components/campaign/MapFeatureReadout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
43
src/utilities/plazaMapNavigation.ts
Normal file
43
src/utilities/plazaMapNavigation.ts
Normal 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' }
|
||||
}
|
||||
51
tests/unit/plazaMapNavigation.unit.spec.ts
Normal file
51
tests/unit/plazaMapNavigation.unit.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue