teqo-backup/docs/plans/limitar-mapa-a-bahia.md
Francisco Ignácio Fontoura Solla d38997686d fix(mapa): limita pan/zoom do BahiaMap ao estado
Evita que arrastar ou dar zoom-out no mapa do Início tire o usuário da Bahia por engano em campo; maxBounds + minZoom runtime alinhados ao fitBounds.
2026-07-26 18:47:06 -03:00

12 KiB
Raw Permalink Blame History

Limitar o mapa da Bahia a pan/zoom dentro do estado

Status: entregue (2026-07-26) Atualizado em: 2026-07-26 Item do roadmap: docs/roadmap.md (Fill-ins — ✓) Impeccable: B — encaixe em BahiaMap/MunicipalityMapPanel (mapa do Início staff); muda só comportamento de interação, sem layout novo Appetite: ~0,25–0,5 dia eng; sem migration, sem UI nova — só opções de L.map Responsável: —

Revisão na entrega (2026-07-26): fechada a questão da folga — pad(0.1) só em maxBounds; minZoom via getBoundsZoom(BAHIA_BOUNDS, false, L.point(16, 16)) alinhado ao padding do fitBounds inicial (defasagem leve do rascunho, que omitia o padding). Helper local applyBahiaMinZoom no mount + no ResizeObserver existente. Critique: 0 P0/P1; harden/optimize out.

Design (Impeccable)

Âncoras: PRODUCT.md § Design Principles, princípio 2 "Clarity under pressure" (toda tela ganha o próprio lugar; evitar que um gesto acidental — arrastar ou dar zoom-out demais — jogue o usuário para fora da geografia que importa, no meio da pressão de campo). Tema data-theme='campaign' (irrelevante aqui — não há CSS/token novo).

Na implementação (implement-roadmap-item): craft compacto → critique → polish. Não há shape a fazer (não é UI nova) nem harden/optimize previsto (mudança pequena e isolada).

Brief compacto (B ambíguo — aqui pouco ambíguo, registrado por completude):

  • Persona / contexto: Alex (coordenador) ou um assessor, olhando o mapa do Início ou o painel de comparação de candidatos, muitas vezes no celular, em campo.
  • Job principal: ler a distribuição de um valor pela Bahia sem se perder da geografia que importa.
  • Estratégia de cor: N/A — item não toca cor/paleta, só bounds/zoom.
  • Edit where you see: N/A — não há dado a editar aqui, é uma restrição de navegação num mapa somente-leitura.
  • Anti-goals: não adicionar texto instrutivo ("você não pode sair da Bahia") por cima do mapa — o próprio comportamento (bounce-back ao arrastar, botão de zoom-out desabilitado no limite) já comunica a trava; não redesenhar controles do Leaflet.

Dados → decisão → apresentação

Dados: N/A — o item não adiciona, agrega nem reformata nenhuma métrica exibida no mapa; restringe apenas a navegação (pan/zoom) da câmera Leaflet sobre os mesmos dados que já são pintados hoje.

Contexto

BahiaMap (src/components/campaign/map/BahiaMap.tsx:247-259) cria o mapa Leaflet com:

const map = L.map(container, {
  zoomControl: true,
  attributionControl: true,
  scrollWheelZoom: false,
})
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })

BAHIA_BOUNDS (linhas 32-35) já existe como o retângulo [[-18.5, -46.8], [-8.5, -37.0]] usado para o enquadramento inicial e para o fallback de fitMapToHighlights quando não há chaves destacadas. O tile layer é o OpenStreetMap global (linha 256). Hoje nada impede o usuário de:

  • Arrastar (pan) o mapa para fora da Bahia — dragging está no valor padrão do Leaflet (true) e não há maxBounds.
  • Dar zoom-out além do nível em que o estado cabe inteiro na tela — não há minZoom; o botão "−" do zoomControl e o pinch-zoom em touch continuam ativos até o mínimo absoluto do Leaflet.

scrollWheelZoom já está desabilitado (provavelmente para não capturar o scroll da página ao passar o mouse por cima do mapa) — isso é comportamento existente e não faz parte deste pedido.

Único consumidor hoje: MunicipalityMapPanel.tsx (src/components/campaign/map/MunicipalityMapPanel.tsx:713-727), que renderiza <BahiaMap mode="municipality" ... /> no Início staff (/campanha) tanto no modo normal quanto no modo de comparação de candidatos (escala divergente). Um arrasto ou zoom-out acidental — fácil de fazer com pinch/scroll no celular em campo — leva a tiles vazios (oceano, outros estados) sem nenhum dado pintado, sem indicar como voltar além de dar zoom-in de novo. Pedido de produto (2026-07-26): "fixar o mapa que exibimos apenas a Bahia... para que o usuário não saia da zona de interesse por engano. Ele pode fazer zoom e pan, mas apenas dentro da região da Bahia" — ou seja, não é para desabilitar zoom/pan (já parcialmente restritos hoje via scrollWheelZoom: false), é para limitar seu alcance ao estado.

Objetivos

  • Arrastar o mapa não consegue sair dos limites geográficos da Bahia (com uma pequena folga de conforto, não a fronteira exata).
  • Dar zoom-out não passa do nível em que o estado inteiro cabe na viewport — sem mostrar tiles fora da Bahia.
  • Zoom-in continua livre até o maxZoom: 18 do tile layer, como hoje.
  • Dentro dos limites, pan/zoom continuam tão fluidos quanto hoje — a trava só age na borda.
  • Comportamento idêntico nos dois mode (municipality e territory) e em qualquer consumidor futuro de BahiaMap — a mudança fica dentro do componente compartilhado, não em MunicipalityMapPanel.
  • O limite de zoom se recalcula quando o contêiner muda de tamanho (mobile vs. desktop, painel normal vs. futuro dialog), usando o mesmo ResizeObserver que já existe para invalidateSize() — não duplicar observer.
  • Sem migration, sem collection, sem server action, sem mudança nas props públicas de BahiaMap.

Decisões travadas

  • Bounds da trava = a mesma constante BAHIA_BOUNDS já usada no fitBounds inicial, sem segunda fonte de verdade geográfica. Rejeitado: calcular a bounding box a partir do TopoJSON dos 417 municípios (bahiaGeometries.ts) — mais "preciso" ao contorno real, mas exigiria esperar o módulo de geometria carregar (é import() dinâmico, ~132 KB) antes de a trava existir, e o retângulo já sobra confortavelmente para o único objetivo aqui (não deixar sair do estado por engano, não desenhar uma fronteira exata).
  • maxBoundsViscosity: 1 (trava rígida), não elástica. O pedido é explícito — "não sair... por engano" — e uma viscosidade parcial (ex. Leaflet default incremental) ainda deixa arrastar um pouco para fora antes de voltar, que é precisamente o engano que o pedido quer eliminar. Rejeitado: viscosidade padrão (0, sem trava nenhuma) e viscosidade intermediária (ex. 0.6) — ambas permitem sair visivelmente da Bahia antes de qualquer resistência.
  • minZoom calculado em runtime via map.getBoundsZoom(BAHIA_BOUNDS, false, L.point(16, 16)), o mesmo padding que o fitBounds inicial usa, em vez de uma constante fixa. O zoom em que o estado cabe inteiro depende do tamanho do contêiner (heightClassName varia por consumidor — hoje só MunicipalityMapPanel, mas o componente é compartilhado) e da largura da viewport (mobile vs. desktop). Rejeitado: constante única (ex. minZoom: 6) — erra para qualquer contêiner com aspect ratio diferente do testado; também rejeitado na entrega: getBoundsZoom sem padding (deixaria uma faixa de zoom-out entre o fit inicial e o piso).
  • i18n e naming: sem strings novas (nenhuma UI textual nova); identificadores propostos em inglês (applyBahiaMinZoom, se extraído como função local) seguem o padrão já usado no arquivo (fitMapToHighlights, resolvePathStyleForFeature).

Questões em aberto

  • A folga (pad) ao redor de BAHIA_BOUNDS para o maxBounds… Resolvida na entrega (2026-07-26): opção (b) — L.latLngBounds(BAHIA_BOUNDS).pad(0.1) só no maxBounds; fitBounds / minZoom usam BAHIA_BOUNDS com padding L.point(16, 16).

Abordagem proposta

flowchart LR
  mount["useEffect de mount<br/>(container pronto)"] --> create["L.map(container, {<br/>...,<br/>maxBounds: BAHIA_BOUNDS.pad(0.1),<br/>maxBoundsViscosity: 1 })"]
  create --> fit["map.fitBounds(BAHIA_BOUNDS)<br/>(já existe)"]
  fit --> minzoom["applyBahiaMinZoom(map):<br/>map.setMinZoom(map.getBoundsZoom(BAHIA_BOUNDS))"]
  resize["ResizeObserver<br/>(já existe p/ invalidateSize)"] --> minzoom

Componentes:

  • BahiaMap.tsx (src/components/campaign/map/BahiaMap.tsx, único arquivo tocado):
    • No useEffect de criação do mapa, acrescentar maxBounds e maxBoundsViscosity: 1 às opções de L.map(...), calculando o bounds com folga a partir de L.latLngBounds(BAHIA_BOUNDS).pad(0.1).
    • Extrair um pequeno helper local applyBahiaMinZoom(map: L.Map) que chama map.setMinZoom(map.getBoundsZoom(BAHIA_BOUNDS, false, L.point(16, 16))) (mesmo padding do fitBounds), com guarda para o caso do contêiner ainda ter tamanho zero (getBoundsZoom pode retornar Infinity/NaN num container 0×0 — ex. aba oculta) — só aplicar quando Number.isFinite(zoom). Chamar uma vez logo após o fitBounds inicial.
    • Reusar o resizeObserver já existente (linhas ~262-269, hoje só chama map.invalidateSize()) para chamar applyBahiaMinZoom(map) de novo a cada resize — não criar um segundo observer.
    • Nenhuma mudança nas props públicas do componente (BahiaMapProps inalterado) — MunicipalityMapPanel.tsx e qualquer futuro consumidor não precisam de alteração.
  • Sem migration, sem collection, sem server action.

Dependências

Nenhuma de outro plano. Reusa só a constante BAHIA_BOUNDS e o ResizeObserver já existentes em BahiaMap.tsx.

Não escopo

  • Restringir o viewport ao escopo do assessor (ex.: só ao portfólio de municípios administrados) em vez de à Bahia inteira — o pedido é sobre não sair do estado, não sobre reforçar RBAC territorial no mapa. fitToKeys/interactiveKeys (já existentes) fazem um recorte diferente — foco visual e hover, não bloqueio de pan. Um item futuro pode pedir isso explicitamente; não é este.
  • Mudar scrollWheelZoom: false — comportamento pré-existente e não relacionado a bounds; fora deste pedido.
  • Overlay/instrução textual explicando a trava — o comportamento nativo do Leaflet (bounce-back ao arrastar, botão "−" desabilitado no minZoom) já comunica o limite; revisitar só com evidência de confusão real de usuário.
  • Aplicar a mesma trava a outro mapa do produto — hoje BahiaMap é o único componente de mapa Leaflet (usado só por MunicipalityMapPanel); a trava, por viver dentro do componente compartilhado, já vale de graça para qualquer consumidor futuro.

Rabbit holes

  • Calcular bounds "reais" a partir do TopoJSON dos 417 municípios em vez do retângulo BAHIA_BOUNDS. Se alguém "só for mais preciso": passa a depender do módulo de geometria assíncrono (loadMunicipalityGeometryModule, ~132 KB) só para travar o pan, atrasando quando a trava passa a existir e complicando o cálculo (bounds de um MultiPolygon não é um simples LatLngBounds de retângulo). Mitigação: manter BAHIA_BOUNDS retangular — já é o que o fitBounds/fallback existentes usam, e precisão de contorno não é o objetivo (é não deixar sair do estado por engano).
  • "Já que estou mexendo, também limito por escopo do assessor." Explode em decidir se um assessor deveria sequer ver municípios fora do portfólio dele no mapa (hoje pode, só não interage) e se a trava deveria variar por role — mudança de política de acesso, não de UX de mapa. Mitigação: este item trava só no nível estadual, igual para todos os papéis; ver "Não escopo".

Adiado com gatilho

Nenhum neste item.

Referências

  • docs/roadmap.md (Fill-ins abertos)
  • src/components/campaign/map/BahiaMap.tsx — único arquivo a alterar; BAHIA_BOUNDS (linha 32), criação do mapa (linhas 247-259), resizeObserver existente (linhas 262-269)
  • src/components/campaign/map/MunicipalityMapPanel.tsx — único consumidor hoje; confirma que a mudança é transparente às props (BahiaMapProps inalterado)
  • Leaflet API — opções maxBounds/maxBoundsViscosity/minZoom de L.Map, método getBoundsZoom(bounds) e LatLngBounds.pad(bufferRatio) (@types/leaflet)
  • PRODUCT.md § Design Principles, princípio 2 — "Clarity under pressure"