Merge branch 'cursor/716686b9'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 18:47:45 -03:00
commit e5dd0c3b3f
5 changed files with 66 additions and 11 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **Fill-in "Limitar mapa à Bahia" entregue em código (2026-07-26):** `BahiaMap` trava pan com `maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` + `maxBoundsViscosity: 1` e zoom-out com `applyBahiaMinZoom` (`getBoundsZoom` + padding 16px alinhado ao `fitBounds`, guarda `Number.isFinite`, recalcula no `ResizeObserver` existente). Único arquivo; props/`MunicipalityMapPanel` intactos. Impeccable B: critique 0 P0/P1; harden/optimize out. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/test (2 timeouts flaky re-rodados verdes)/build; Aikido 0 findings. Plano: [`limitar-mapa-a-bahia.md`](../../../docs/plans/limitar-mapa-a-bahia.md).
- **B38 entregue em código (2026-07-26):** sidebar recolhido em tablet no shell de `/campanha`. `CampaignSidebar` deixou `collapsible="none"` e passou a `collapsible="offcanvas"` (gap `w-0` — listas densas ganham largura no iPad). Layout `(app)` lê cookie `sidebar_state` no SSR para `defaultOpen`; `CampaignSidebarViewportDefault` colapsa **uma vez** em viewport 768–1023 px quando não há cookie (`matchMedia` `min-width: 1024px`); após o primeiro toggle o cookie vence. `SidebarTrigger` no header vermelho mobile (`md:hidden`) inalterado; nova fileira slim `hidden md:flex` no inset com o mesmo label pt-BR. Mobile continua Sheet. Sem migration. Gate: tsc/lint/format/knip/check:cycles/522 unit verdes; int 399/400 (1 timeout pré-existente em `municipalityPageData`); build não rodou neste worktree sem `.env.local`. Plano: [`sidebar-recolhido-tablet.md`](../../../docs/plans/sidebar-recolhido-tablet.md). Soft **B18:** com rail collapsed o submenu de filtros salvos exigirá expandir antes do 2º nível.
- **B24 entregue em código (2026-07-26):** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios`. Tirou o botão "Salvar" (anti-goal do princípio 4) e o `trendNote` hidden; `MunicipalityListTrendControl` agora espelha a máquina de `MunicipalityListExpectedVotesControl` — draft/display/committed + AbortController + geração, status ~150 ms / nota 600 ms (timer cancelável), flush no blur e no fechamento do Popover, Badge otimista, `Textarea` "Justificativa" (`maxLength` 2000), `Alert` + live region `sr-only`, tooltip B23 lendo a nota exibida. Endpoint `POST /campanha/municipios/political-trend` (mesmo shell CSRF/JSON do B27: `isSameOriginRequest` + `campaignJsonMutationRoute`) chama `setMunicipalityPoliticalTrend` **sem** `revalidatePath` — faceta/`?trend=` reconciliam na próxima navegação. Lista/page deixam de passar `trendFormAction`; `/editar` mantém o form action multi-campo. Sem migration. Gate verde (tsc/lint/format/knip/check:cycles/479 unit/393 int/build); e2e B24 + prewarm da rota; Aikido 0 findings. Plano: [`autosave-tendencia-lista-municipios.md`](../../../docs/plans/autosave-tendencia-lista-municipios.md).
- **B14 entregue em código (2026-07-26):** card "Onde estou" no Início staff. **A auditoria do plano derrubou sua decisão central antes de codar:** o plano de 24/07 tratava point-in-polygon como rabbit hole ("explode bundle") e escolhia um artefato commitado de centroides — mas `MunicipalityMapPanel` monta sempre em `mode="municipality"` e `BahiaMap` chama `loadMunicipalityGeometryModule()` no mount, então os 417 polígonos (132 KB crus, **41 KB gzip**, chunk próprio memoizado) já estão no browser de quem abre `/campanha`. Decidido com o usuário: usar polígonos. Saíram de escopo `pnpm build:municipality-centroids`, `src/lib/municipalityCentroids/` e o snapshot test — e o card passou a responder "o município **onde estou**" em vez de "o mais próximo". Centroide sobrevive só para ordenar o fallback.

View file

@ -0,0 +1,24 @@
# Critique — BahiaMap pan/zoom lock (Limitar mapa à Bahia)
Target: `src/components/campaign/map/BahiaMap.tsx`
Date: 2026-07-26
Provenance: Assessment A via sub-agent; Assessment B via `detect.mjs` (empty findings). Interaction-only Class B fill-in — no browser overlay (no visual chrome change).
## Summary
Constraint-only Leaflet lock (`maxBounds` pad 0.1 + viscosity 1 + runtime `minZoom`). No AI slop. No P0/P1. Polish beyond shipped: none.
## Heuristics (relevant)
- User Control and Freedom: 3/4 (freedom scoped to Bahia — intentional)
- Error Prevention: 4/4
## Detector
`[]`
## Strengths
1. Error prevention without UI tax
2. minZoom ↔ fitBounds padding parity (16px)
3. Soft pad under rigid viscosity + ResizeObserver recompute

View file

@ -1,12 +1,14 @@
# Limitar o mapa da Bahia a pan/zoom dentro do estado
Status: rascunho
Status: entregue (2026-07-26)
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos)
Item do roadmap: [docs/roadmap.md](../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).
@ -61,12 +63,12 @@ map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
- **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)`**, o mesmo cálculo que o `fitBounds` já faz internamente, 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.
- **`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` deve ser igual ao padding do `fitBounds` inicial ou maior?** Opções: (a) usar `BAHIA_BOUNDS` sem folga extra para `maxBounds`; (b) aplicar `L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` (10%) só para o `maxBounds`, mantendo `BAHIA_BOUNDS` original para o `fitBounds` e o cálculo de `minZoom`. **Recomendação:** (b) — sem folga, a trava "gruda" exatamente na fronteira do retângulo ao arrastar perto da borda, cortando ao meio o último município visível; uma folga pequena (10%) é suficiente para dar conforto tátil ao arrastar sem deixar o usuário perceber que "saiu" da Bahia _(assumido — validar visualmente no craft; se 10% parecer pouco/demais, ajustar o número, não a decisão)_.
- ~~**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
@ -81,8 +83,8 @@ flowchart LR
Componentes:
- **`BahiaMap.tsx`** (`src/components/campaign/map/BahiaMap.tsx`, único arquivo tocado):
- No `useEffect` de criação do mapa (linhas ~247-281), 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)` (Questão em aberto acima).
- Extrair um pequeno helper local `applyBahiaMinZoom(map: L.Map)` que chama `map.setMinZoom(map.getBoundsZoom(BAHIA_BOUNDS))`, 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.
- 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.**

File diff suppressed because one or more lines are too long

View file

@ -34,6 +34,9 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
[-8.5, -37.0],
]
/** Shared by state-level `fitBounds` and `getBoundsZoom` so minZoom matches the initial fit. */
const BAHIA_FIT_PADDING = L.point(16, 16)
/**
* Bubbles get their own pane above the polygons: hovering a município calls
* `bringToFront()` on its path, which in a shared pane would raise the polygon
@ -41,6 +44,24 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
*/
const BUBBLE_PANE = 'municipality-bubbles'
/**
* Clamp zoom-out to the zoom where the whole state still fills the container.
* Temporarily drops the floor before measuring: `getBoundsZoom` clamps to the
* current `minZoom`, so without that the floor can only rise on shrink and never
* fall on enlarge. Restores the previous floor if the measure is non-finite
* (0×0 container before layout / hidden tab).
*/
const applyBahiaMinZoom = (map: L.Map) => {
const previousMinZoom = map.getMinZoom()
map.setMinZoom(0)
const zoom = map.getBoundsZoom(BAHIA_BOUNDS, false, BAHIA_FIT_PADDING)
if (!Number.isFinite(zoom)) {
map.setMinZoom(previousMinZoom)
return
}
map.setMinZoom(zoom)
}
export type BahiaMapMode = 'municipality' | 'territory'
export type BahiaMapFeatureInfo = {
@ -92,7 +113,7 @@ const fitMapToHighlights = (
geometryModule: MunicipalityGeometryModule | TerritoryGeometryModule,
) => {
if (highlightSet.size === 0) {
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
return
}
@ -108,7 +129,7 @@ const fitMapToHighlights = (
})
if (highlightedFeatures.length === 0) {
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
return
}
@ -250,8 +271,12 @@ export const BahiaMap = ({
zoomControl: true,
attributionControl: true,
scrollWheelZoom: false,
// Soft pad so viscosity:1 does not clip the edge município on drag.
maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1),
maxBoundsViscosity: 1,
})
map.fitBounds(BAHIA_BOUNDS, { padding: [16, 16] })
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
applyBahiaMinZoom(map)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
@ -264,6 +289,7 @@ export const BahiaMap = ({
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => {
map.invalidateSize()
applyBahiaMinZoom(map)
})
: null
resizeObserver?.observe(container)
@ -370,6 +396,8 @@ export const BahiaMap = ({
layer.addTo(map)
layerRef.current = layer
map.invalidateSize()
// Layout may have been 0×0 on mount — re-apply after size is known.
applyBahiaMinZoom(map)
syncMountStyleContext()
restyleAllPaths()