diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index edff25d6..bb9b8816 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B12 entregue em código (2026-07-21; deploy pendente com remodelagem):** `fitToKeys` + `interactiveKeys` no Mapa das Praças (`plazasByIbgeCode`); `canonicalMapKeysKey`; hover stroke-only (fill inalterado); clear síncrono no mouseout; fit ao footprint filtrado sem re-zoom em Ano/Escala. Plano: [`aproximar-mapa-pracas.md`](../../../docs/plans/aproximar-mapa-pracas.md). - **B6 entregue em código (2026-07-21; deploy pendente com remodelagem):** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers puros em `bahiaMapStyle.ts` + unit `bahiaMapStyle.unit.spec.ts`. Plano: [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md). Fecha débitos de perf de **B10** e **B11**. - **B8 F1 entregue em código (2026-07-21; deploy pendente com remodelagem):** catálogo estático `plazaZoneNeighborhoods` (21 Praças-zona SSA/CMS; Salvador = TRE-BA RA 02/2017; Camaçari = locais de votação + curadoria) + fixture `plaza-zone-neighborhoods.official.json` + int `plazaZoneNeighborhoods.int.spec.ts`; card **Bairros desta Praça** na aba Visão geral de `/campanha/pracas/[slug]` quando `kind === 'zona'` (staff e líder). Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). **F2 pendente** (dissolve IBGE → TopoJSON + mapa). - **C8 F4 entregue em código (2026-07-21; deploy pendente com remodelagem):** DRY plaza staff form actions — `plazaStaffFormActions.ts` (votos estimados, tendência, assessores) compartilhado entre lista e `/editar`; `editar/formActions.ts` mantém só `updatePlazaStrategy`. Plano: [`escala-dry-pos-c6.md`](../../../docs/plans/escala-dry-pos-c6.md). diff --git a/AGENTS.md b/AGENTS.md index 523d2107..26d4826e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-21):** **B11** Escala % dos válidos no Mapa das Praças — `validVotesByYear` in `PlazaMapBundle`, `scaleMode` selector (`absolute` | `percentValid`, default %), `computeValidVoteShares`, readout em % com votos absolutos secundários; `/simplify` fix `scaleMax` (legenda 0–100% alinhada ao fill). Plano: `docs/plans/escala-percentual-mapa-pracas.md`. +**Recently resolved (2026-07-21):** **B12** Aproximar mapa ao footprint filtrado + correção hover — `fitToKeys` / `interactiveKeys` em `BahiaMap` (`plazasByIbgeCode`); `canonicalMapKeysKey`; hover stroke-only; clear síncrono no mouseout; fit só quando footprint muda. Plano: `docs/plans/aproximar-mapa-pracas.md`. + ## Posts & Tags (news / publications) The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`. diff --git a/docs/plans/aproximar-mapa-pracas.md b/docs/plans/aproximar-mapa-pracas.md index 8cf44af2..145aa4f7 100644 --- a/docs/plans/aproximar-mapa-pracas.md +++ b/docs/plans/aproximar-mapa-pracas.md @@ -1,12 +1,14 @@ # Aproximar o Mapa das Praças à região filtrada -Status: rascunho +Status: entregue 2026-07-21 (branch; deploy pendente com remodelagem) Atualizado em: 2026-07-21 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B12) Impeccable: B — encaixe em `PlazaMapPanel` / `BahiaMap` (sem rota nova) Appetite: ~0,5 dia eng; `fitToKeys` no Leaflet + chaves estáveis do bundle + teste/checklist Responsável: — +_Revisão 2026-07-21 (implementação): escopo ampliado com correção de três bugs de hover reportados em campo — (1) ênfase agressiva (fillOpacity + stroke grosso), (2) hover em municípios fora do filtro B7, (3) ênfase sticky no mouseout (`setHoveredKey` restyleava antes de limpar `hoveredKey` + `selectedKey` espelhava hover). Solução: `interactiveKeys` compartilhando chaves com `fitToKeys` (`plazasByIbgeCode`); hover stroke-only (fill inalterado, weight 2); clear síncrono de `hoveredKey`/`selectedKey` no mouseout desktop; touch pinned inalterado._ + ## Design (Impeccable) Âncoras: `PRODUCT.md` / `DESIGN.md` (register product — Field Desk) · tema `data-theme='campaign'` · shells existentes (`PlazaMapPanel`, `BahiaMap`). @@ -63,8 +65,8 @@ flowchart LR Componentes: -- **`BahiaMap`** (`src/components/campaign/BahiaMap.tsx`): nova prop opcional `fitToKeys?: string[]` (default `[]`). Efeito (ou ramo) que, quando a chave canônica de `fitToKeys` muda: resolve features via `getMunicipalityFeature` / `getTerritoryFeature` (mesmo path de `highlightKeys`), `fitBounds` com padding/`maxZoom` atuais; se vazio → `BAHIA_BOUNDS`. **Não** alterar o estilo das layers. Depth check: não criar `BahiaMapViewport.ts` pass-through; reusar o bloco já existente de highlight bounds. -- **`PlazaMapPanel`** (`src/components/campaign/PlazaMapPanel.tsx`): `fitToKeys={Object.keys(bundle.plazasByIbgeCode)}` (memo estável se útil). Não passar as mesmas chaves em `highlightKeys`. +- **`BahiaMap`** (`src/components/campaign/BahiaMap.tsx`): props `fitToKeys?: string[]` e `interactiveKeys?: string[]` (default `[]` = fit Bahia / tudo interativo). Efeito quando `canonicalMapKeysKey(fitToKeys)` muda → `fitMapToHighlights` (sem alterar estilo). `interactiveKeys` com gate em handlers + `interactive: false` nos paths fora do set. Hover stroke-only + clear síncrono no mouseout. Reusa `fitMapToHighlights` e `canonicalMapKeysKey` em `bahiaMapStyle.ts`. +- **`PlazaMapPanel`** (`src/components/campaign/PlazaMapPanel.tsx`): memo `scopedKeys = Object.keys(bundle.plazasByIbgeCode)` → `fitToKeys` + `interactiveKeys`. Não passar as mesmas chaves em `highlightKeys`. - **Teste:** unit do helper de chave canônica / “quando fitKey muda”; checklist manual: filtrar TI → aproxima; limpar filtros → Bahia; trocar Ano/Escala → viewport estável; assessor com poucas Praças → aproxima sem filtro URL. - **Migration:** Sem migration, sem collection, sem server action. @@ -93,6 +95,7 @@ Componentes: - **Controle “Ver Bahia toda”.** Revisitar se, após B12, usuários pedirem reset sem limpar filtros. - **Threshold N→`BAHIA_BOUNDS`.** Revisitar se fit em ~400 `codarea` causar jank perceptível. +- **Restyle diff só nos paths cujo `interactive` mudou.** Revisitar se troca frequente de filtro causar jank no O(n) `restyleAllPaths` (hoje full pass quando `interactiveKey` muda). - **`flyTo` / `animate: true`.** Revisitar se critique pedir transição; default Leaflet `fitBounds` basta no v1. ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index 3c397d96..8cdbb5c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-21 (B6 + B8 F1 + fill-in **C8** F4 entregues; B12 registrado; A9+/B7/B9–B11/filtros-auto entregues) +Atualizado em: 2026-07-21 (B12 entregue; B6 + B8 F1 + fill-in **C8** F4 entregues; A9+/B7/B9–B11/filtros-auto entregues) 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). @@ -72,7 +72,7 @@ flowchart TD A9 --> A9plus["A9+ Loader lista ✓"] A9plus --> B9["B9 Edição rápida lista ✓"] R2 -.-> B7["B7 Mapa filtrado ✓"] - B7 --> B12["B12 Aproximar mapa
ao filtro"] + B7 --> B12["B12 Aproximar mapa
ao filtro ✓"] R2 -.-> B8["B8 Polígonos Praças-zona
(SSA/CMS)"] R2 -.-> B10["B10 Hover/tap mapa ✓"] R2 -.-> B11["B11 Escala % válidos
no mapa ✓"] @@ -84,11 +84,11 @@ flowchart TD C2prod --> C5["C5 GOTV (validar)"] ``` -Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins (O0+, RS+, …). ~~**A9** / **A9+** / **B9** / **B7** / **B10** / **B11** / **B6** / **filtros-auto** / **C8** F4~~ entregues 2026-07-21. +Paralelizáveis agora: fill-ins (O0+, RS+, …). ~~**A9** / **A9+** / **B9** / **B7** / **B10** / **B11** / **B6** / **B12** / **filtros-auto** / **C8** F4~~ entregues 2026-07-21. ### Sequência por janela -**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**B11** escala % dos válidos~~ / ~~**filtros-auto** lista de Praças~~ entregues; **B12** aproximar mapa ao footprint filtrado (quick win, ~0,5 dia); **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo. +**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**B11** escala % dos válidos~~ / ~~**B12** aproximar mapa ao footprint filtrado~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo. **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga. @@ -100,7 +100,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins (O0 **Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**A9**~~ (total esperado da Praça — entregue 2026-07-21). -**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); **B12** (mapa continua usável com pan/zoom manual); débitos/fill-ins. ~~**B9** / **B10** / **B11** / **B6**~~ (entregues — não cortar). +**Cortes seguros** (se o prazo apertar, nesta ordem): R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins. ~~**B9** / **B10** / **B11** / **B6** / **B12**~~ (entregues — não cortar). ## Já entregue (resumo) @@ -113,6 +113,7 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins (O0 - **B9 (2026-07-21)** — **Edição rápida na lista de Praças** (Assessores / Tendência / `expectedVotes` via Popovers em `PlazaList*Control`; `plazaStaffFormActions`; sem migration) — [plano](plans/edicao-rapida-lista-pracas.md). - **B10 (2026-07-21)** — **Hover/tap no Mapa das Praças** (destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 → `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`) — [plano](plans/hover-mapa-pracas.md). - **B11 (2026-07-21)** — **Escala % dos válidos no Mapa das Praças** (`validVotesByYear` no bundle; seletor `Total (votos)` / `% dos válidos`; domínio fixo 0–100%; 2026 usa válidos 2022; compare desliga %; readout em %) — [plano](plans/escala-percentual-mapa-pracas.md). +- **B12 (2026-07-21)** — **Aproximar mapa ao footprint filtrado + correção hover** (`fitToKeys` + `interactiveKeys` em `BahiaMap` a partir de `plazasByIbgeCode`; `canonicalMapKeysKey`; hover stroke-only sem alterar fill; clear síncrono no mouseout; fit só quando footprint muda — sem re-zoom em Ano/Escala) — [plano](plans/aproximar-mapa-pracas.md). - **B6 (2026-07-21)** — **`BahiaMap` setStyle incremental** (layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers em `bahiaMapStyle.ts`) — [plano](plans/escala-dry-pos-b3.md). - **B8 F1 (2026-07-21)** — **Catálogo bairros das Praças-zona** (`plazaZoneNeighborhoods`: Salvador TRE-BA RA 02/2017 + Camaçari curado; fixture+int; card **Bairros desta Praça** no overview de Praças `kind=zona`; sem migration) — [plano](plans/poligonos-pracas-zona.md). **F2 pendente** (F2 prep catálogo ~½ dia + TopoJSON + mapa). Débitos pós-`/simplify`: hidratação via `plazaCatalog`, canônico JSON→TS, teste ordem slugs → F2 prep no plano (S6 CSS compartilhado adiado até F2/R6). - **Fill-in filtros-auto (2026-07-21)** — **Filtros auto-aplicados na lista de Praças** (`PlazaFilters`: debounce 1s no `q`, Enter imediato, selects imediatos, remove Buscar; `useTransition` + pending a11y; `shouldUpdatePlazaSearchUrl` + no-op via `buildPlazaFiltersKey`; sem migration) — [plano](plans/filtros-auto-pracas.md). Débitos pós-`/simplify`: sync back/forward `search`↔`state.q` e shell pending compartilhado — gatilhos no plano. @@ -139,7 +140,6 @@ Paralelizáveis agora: **B12** (viewport do mapa; B7 já entregue), fill-ins (O0 - **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) - **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~~ (entregue 2026-07-21); F2 prep catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md) -- **B12** Aproximar o Mapa das Praças ao footprint filtrado (`fitToKeys` no `BahiaMap`; sem re-zoom em Ano/Escala) · Janela 1 · cortável · gatilho do B7 · [plano](plans/aproximar-mapa-pracas.md) - **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais - **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md) - **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa) diff --git a/src/components/campaign/BahiaMap.tsx b/src/components/campaign/BahiaMap.tsx index 3026b482..dbe94779 100644 --- a/src/components/campaign/BahiaMap.tsx +++ b/src/components/campaign/BahiaMap.tsx @@ -15,6 +15,7 @@ import { import { buildHighlightSet, buildLayerStyleContext, + canonicalMapKeysKey, featureKeyFromProperties, keyPropertyForMode, resolvePathStyle, @@ -46,6 +47,10 @@ type BahiaMapProps = { /** When set, overrides auto max from `values` (e.g. fixed 1 for 0–100% shares). */ scaleMax?: number highlightKeys?: string[] + /** Fit viewport to these keys without highlighting them. */ + fitToKeys?: string[] + /** When non-empty, only these keys receive hover/click. */ + interactiveKeys?: string[] selectedKey?: string | null onFeatureSelect?: (info: BahiaMapFeatureInfo | null) => void onFeatureActivate?: (key: string) => void @@ -96,6 +101,8 @@ export const BahiaMap = ({ fillMode = 'sequential', scaleMax, highlightKeys = [], + fitToKeys = [], + interactiveKeys = [], selectedKey = null, onFeatureSelect, onFeatureActivate, @@ -113,6 +120,8 @@ export const BahiaMap = ({ const touchPinnedRef = useRef(false) const onFeatureSelectRef = useRef(onFeatureSelect) const onFeatureActivateRef = useRef(onFeatureActivate) + const interactiveSetRef = useRef | null>(null) + const lastFittedViewportKeyRef = useRef(null) const stylePropsRef = useRef({ values, fillMode, @@ -121,7 +130,16 @@ export const BahiaMap = ({ selectedKey, }) const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') - const highlightKey = useMemo(() => highlightKeys.slice().sort().join(','), [highlightKeys]) + const highlightKey = useMemo(() => canonicalMapKeysKey(highlightKeys), [highlightKeys]) + const fitKey = useMemo(() => canonicalMapKeysKey(fitToKeys), [fitToKeys]) + const interactiveKey = useMemo(() => canonicalMapKeysKey(interactiveKeys), [interactiveKeys]) + const viewportKey = useMemo(() => fitKey || highlightKey, [fitKey, highlightKey]) + const interactiveSet = useMemo( + () => (interactiveKey ? buildHighlightSet(interactiveKey) : null), + [interactiveKey], + ) + + interactiveSetRef.current = interactiveSet stylePropsRef.current = { values, @@ -136,11 +154,21 @@ export const BahiaMap = ({ onFeatureActivateRef.current = onFeatureActivate }, [onFeatureActivate, onFeatureSelect]) + const isKeyInteractive = (key: string) => { + const set = interactiveSetRef.current + return set === null || set.has(key) + } + + const resolvePathStyleForFeature = (context: LayerStyleContext, key: string) => ({ + ...resolvePathStyle(context, key), + interactive: isKeyInteractive(key), + }) + const restyleFeature = (key: string) => { const context = styleContextRef.current const path = pathByKeyRef.current.get(key) if (!context || !path) return - path.setStyle(resolvePathStyle(context, key)) + path.setStyle(resolvePathStyleForFeature(context, key)) } const restyleAllPaths = () => { @@ -148,23 +176,31 @@ export const BahiaMap = ({ if (!context) return for (const [key, path] of pathByKeyRef.current) { - path.setStyle(resolvePathStyle(context, key)) + path.setStyle(resolvePathStyleForFeature(context, key)) } } - const setHoveredKey = (nextKey: string | null, bringToFrontLayer?: L.Path) => { + const setHoveredKey = ( + nextKey: string | null, + bringToFrontLayer?: L.Path, + clearSelection = false, + ) => { const context = styleContextRef.current if (!context) return const previousKey = context.hoveredKey if (previousKey === nextKey) return + context.hoveredKey = nextKey + + if (clearSelection && previousKey && context.selectedKey === previousKey) { + context.selectedKey = null + } + if (previousKey) { restyleFeature(previousKey) } - context.hoveredKey = nextKey - if (nextKey) { restyleFeature(nextKey) ;(bringToFrontLayer ?? pathByKeyRef.current.get(nextKey))?.bringToFront() @@ -221,6 +257,7 @@ export const BahiaMap = ({ let cancelled = false setStatus('loading') geometryReadyRef.current = false + lastFittedViewportKeyRef.current = null const renderLayer = async () => { try { @@ -252,7 +289,7 @@ export const BahiaMap = ({ const properties = feature?.properties as Record | undefined const key = featureKeyFromProperties(properties, keyProperty) const context = styleContextRef.current ?? mountContext - return resolvePathStyle(context, key ?? '') + return resolvePathStyleForFeature(context, key ?? '') }, onEachFeature: (feature, featureLayer) => { if (!(featureLayer instanceof L.Path)) return @@ -265,6 +302,8 @@ export const BahiaMap = ({ pathByKeyRef.current.set(key, featureLayer) featureLayer.on('mouseover', (event) => { + if (!isKeyInteractive(key)) return + const pointerType = getPointerType(event) if (pointerType === 'mouse') { touchPinnedRef.current = false @@ -280,11 +319,15 @@ export const BahiaMap = ({ featureLayer.on('mouseout', () => { if (touchPinnedRef.current) return - setHoveredKey(null) + if (!isKeyInteractive(key)) return + + setHoveredKey(null, undefined, true) onFeatureSelectRef.current?.(null) }) featureLayer.on('click', (event) => { + if (!isKeyInteractive(key)) return + touchPinnedRef.current = getPointerType(event, 'mouse') === 'touch' setHoveredKey(key, featureLayer) @@ -301,12 +344,9 @@ export const BahiaMap = ({ syncMountStyleContext() restyleAllPaths() - fitMapToHighlights( - map, - mode, - styleContextRef.current!.highlightSet, - geometryModule, - ) + const fitIdentity = `${mode}:${viewportKey}` + fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule) + lastFittedViewportKeyRef.current = fitIdentity geometryReadyRef.current = true setStatus('ready') @@ -332,12 +372,21 @@ export const BahiaMap = ({ useEffect(() => { if (!geometryReadyRef.current) return + const fitIdentity = `${mode}:${viewportKey}` + if (lastFittedViewportKeyRef.current === fitIdentity) return + const map = mapRef.current const geometryModule = geometryModuleRef.current if (!map || !geometryModule || !layerRef.current) return - fitMapToHighlights(map, mode, buildHighlightSet(highlightKey), geometryModule) - }, [highlightKey, mode]) + fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule) + lastFittedViewportKeyRef.current = fitIdentity + }, [viewportKey, mode]) + + useEffect(() => { + if (!interactiveKey || pathByKeyRef.current.size === 0) return + restyleAllPaths() + }, [interactiveKey]) useEffect(() => { const previousContext = styleContextRef.current diff --git a/src/components/campaign/PlazaMapPanel.tsx b/src/components/campaign/PlazaMapPanel.tsx index 348da011..b47f5648 100644 --- a/src/components/campaign/PlazaMapPanel.tsx +++ b/src/components/campaign/PlazaMapPanel.tsx @@ -75,6 +75,11 @@ export const PlazaMapPanel = ({ ) }, [comparisonActive, displayValues, percentScaleActive]) + const scopedKeys = useMemo( + () => Object.keys(bundle.plazasByIbgeCode), + [bundle.plazasByIbgeCode], + ) + const metricLabel = useMemo(() => { if (comparisonActive) { return `diferença de votos em ${year}` @@ -252,6 +257,8 @@ export const PlazaMapPanel = ({ values={displayValues} scaleMax={displayMax > 0 ? displayMax : undefined} fillMode={comparisonActive ? 'diverging' : 'sequential'} + fitToKeys={scopedKeys} + interactiveKeys={scopedKeys} selectedKey={selectedFeature?.key ?? null} onFeatureSelect={handleFeatureSelect} onFeatureActivate={handleFeatureActivate} diff --git a/src/lib/bahiaMapStyle.ts b/src/lib/bahiaMapStyle.ts index b81a0ed2..23f88a89 100644 --- a/src/lib/bahiaMapStyle.ts +++ b/src/lib/bahiaMapStyle.ts @@ -35,13 +35,13 @@ export const getFeatureStyle = ({ fillMode, max, }: FeatureStyleInput): PathOptions => ({ - weight: highlighted ? 2.5 : 1, + weight: highlighted ? 2 : 1, color: highlighted ? '#c51414' : '#a8a29e', fillColor: fillMode === 'diverging' ? divergingFillColor(metric, max) : choroplethFillColor(metric, max), - fillOpacity: highlighted ? 0.92 : metric !== 0 ? 0.78 : 0.35, + fillOpacity: metric !== 0 ? 0.78 : 0.35, }) export const computeChoroplethMax = ( @@ -55,6 +55,9 @@ export const computeChoroplethMax = ( export const buildHighlightSet = (highlightKey: string): Set => new Set(highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : []) +export const canonicalMapKeysKey = (keys: string[]): string => + keys.length > 0 ? [...keys].sort().join(',') : '' + export const keyPropertyForMode = (mode: 'municipality' | 'territory'): FeatureKeyProperty => mode === 'municipality' ? 'codarea' : 'code' diff --git a/tests/unit/bahiaMapStyle.unit.spec.ts b/tests/unit/bahiaMapStyle.unit.spec.ts index ffd022c4..0eaa2433 100644 --- a/tests/unit/bahiaMapStyle.unit.spec.ts +++ b/tests/unit/bahiaMapStyle.unit.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { buildLayerStyleContext, + canonicalMapKeysKey, computeChoroplethMax, resolvePathStyle, } from '@/lib/bahiaMapStyle' @@ -28,6 +29,17 @@ describe('bahiaMapStyle', () => { }) }) + describe('canonicalMapKeysKey', () => { + it('sorts keys for stable identity', () => { + expect(canonicalMapKeysKey(['2927408', '2905701'])).toBe('2905701,2927408') + expect(canonicalMapKeysKey(['2905701', '2927408'])).toBe('2905701,2927408') + }) + + it('returns empty string for empty input', () => { + expect(canonicalMapKeysKey([])).toBe('') + }) + }) + describe('resolvePathStyle', () => { it('highlights hovered feature over selected', () => { const context = { @@ -36,7 +48,7 @@ describe('bahiaMapStyle', () => { hoveredKey: '2927408', } - expect(resolvePathStyle(context, '2927408').weight).toBe(2.5) + expect(resolvePathStyle(context, '2927408').weight).toBe(2) expect(resolvePathStyle(context, '2905701').weight).toBe(1) }) @@ -47,7 +59,7 @@ describe('bahiaMapStyle', () => { hoveredKey: null, } - expect(resolvePathStyle(context, '2905701').weight).toBe(2.5) + expect(resolvePathStyle(context, '2905701').weight).toBe(2) }) it('reveals selected highlight after hover clears on a different feature', () => { @@ -58,7 +70,7 @@ describe('bahiaMapStyle', () => { } expect(resolvePathStyle(context, '2927408').weight).toBe(1) - expect(resolvePathStyle(context, '2905701').weight).toBe(2.5) + expect(resolvePathStyle(context, '2905701').weight).toBe(2) }) it('highlights keys in highlightSet', () => { @@ -67,7 +79,18 @@ describe('bahiaMapStyle', () => { highlightSet: new Set(['2927408']), } - expect(resolvePathStyle(context, '2927408').weight).toBe(2.5) + expect(resolvePathStyle(context, '2927408').weight).toBe(2) + }) + + it('keeps fillOpacity when highlighted', () => { + const base = resolvePathStyle(baseContext, '2927408') + const highlighted = resolvePathStyle( + { ...baseContext, hoveredKey: '2927408' }, + '2927408', + ) + + expect(highlighted.fillOpacity).toBe(base.fillOpacity) + expect(highlighted.color).toBe('#c51414') }) it('uses diverging fill for negative metrics', () => {