simplify(B14): corrige o mecanismo da fronteira, estado unsupported e duplicatas

Terceira rodada de /simplify sobre o B14, com dois enunciados errados corrigidos:

- A propriedade da fronteira compartilhada NAO vem da ordem do `.find()`, e sim
  da convencao meio-aberta do ray casting (`point.lng < crossingLng`): a aresta
  pertence ao poligono cujo interior fica a leste (ao norte, se horizontal). Tres
  comentarios diziam o contrario, e o do unit test dizia "oeste" enquanto
  afirmava `east`.
- O plano dizia que o `Promise.all` esconde o decode da malha atras do fix de
  GPS. Nao esconde: o decode roda no topo do modulo, quando o chunk executa. O
  paralelo continua certo por outro motivo (decode ~2 ms; sequencial jogaria os
  42 KB no caminho critico quando o fix vem do cache).

Simplificacoes: `unsupported` virou estado proprio, entao `failureCopy` deixa de
ser anulavel e caem duas leituras defensivas inalcancaveis; "Voce esta em " era
escrito duas vezes (frase sr-only + JSX), o que anulava justamente a invariante
que o comentario alegava; `zoneCityHrefs` virou `Partial` porque a busca falha
para os 416 municipios sem zona e o guard existia sem o tipo exigir;
`MunicipalityForIbgeIndex` era duplicata campo-a-campo de `AccessibleMunicipality`;
`kmFormatter` duplicava `oneDecimalFormatter`; `RecentlyVisitedCard` carregava
tres props que nenhum caller passa (dez condicionais mortas); e o bbox de feature
estava escrito em dois specs (agora `tests/helpers/featureBounds.ts`, compartilhado
por vitest e Playwright).

Copy: fora da Bahia sem nada por perto dizia a mesma coisa em duas frases e nunca
dizia que voce estava fora do estado. Agora o headline afirma o fato e o detalhe
reusa "Nenhum municipio da sua carteira por perto.".

Tambem limpa um achado de knip herdado do main (`MunicipalityPoliticalTrendViewModel`
exportado sem uso) e registra no plano o caminho afiado do debito de payload RSC
mais dois adiados com gatilho.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 18:03:44 -03:00
parent e482005ce0
commit 5ace1a04b2
10 changed files with 130 additions and 133 deletions

View file

@ -22,9 +22,9 @@ Entregue:
**Medições (as-built).** Payload RSC de `/campanha` com 435 entradas: ~6,6 KB gzip — abaixo do teto de ~10 KB do plano, então o campo ficou como objetos legíveis em vez de tuplas. First Load JS de `/campanha`: **265 kB → 268 kB**. A primeira versão chegou a 286 kB porque o card chamava `buildMunicipalityListHref` para o link das zonas de Salvador, e o serializador canônico arrasta `bahiaTerritories` + `municipalityCatalog` (que puxa códigos IBGE e zonas TSE) para o bundle do cliente: **21 kB por um único link**. Corrigido serializando o href no servidor (`zoneCityHrefs`, chaveado por IBGE, uma entrada só quando o ator enxerga mais de uma zona da cidade) — a regra "client components não importam módulos de dado do servidor para obter valores" também vale para serializadores de URL. No `/simplify` esse cálculo saiu do loader `server-only` para `CampaignDashboard`, reusando `buildMunicipalitiesByIbgeCode` (o índice por código IBGE que o painel do mapa já usa) em vez de contar linhas à mão.
**Medições de CPU (feitas no `/simplify`, sobre o artefato real — não estimadas).** A malha decodifica em **417 features, todas `Polygon` simples**: 417 anéis, **zero buracos, zero MultiPolygon**, 15.111 pares de coordenadas, anel mais longo com 147 pontos. Varredura point-in-polygon completa (o caminho "fora da Bahia", sem acerto): **0,106 ms**; até acertar Salvador: 0,143 ms; `resolveNearbyMunicipality` inteiro com carteira de 435: **0,32 ms**, dos quais 0,26 ms são os 417 centroides. Com penalidade de 6× para celular médio, ~2,6 ms — **um oitavo de um frame**, então não há caminho quente aqui e o artefato de centroides continuaria sem se pagar. Construir um `Map<codarea, feature>` novo por chamada mediu **0,337 ms, mais lento** que o `find`; o índice `getMunicipalityFeature` é usado porque o módulo **já o construiu** para o mapa, não como otimização. Chunk de geometria no build: 134.488 B crus / **42.158 B gzip**, referenciado por um único arquivo (o chunk da página), confirmando um download e um decode (1,69 ms) para os dois consumidores. O que muda com o card é o **agendamento**: a geometria passa a ser pedida na hidratação da página, competindo com Leaflet, em vez de só depois do chunk do painel — aceito porque o `Promise.all` esconde isso atrás do fix de GPS (100 ms a 10 s).
**Medições de CPU (feitas no `/simplify`, sobre o artefato real — não estimadas).** A malha decodifica em **417 features, todas `Polygon` simples**: 417 anéis, **zero buracos, zero MultiPolygon**, 15.111 pares de coordenadas, anel mais longo com 147 pontos. Varredura point-in-polygon completa (o caminho "fora da Bahia", sem acerto): **0,106 ms**; até acertar Salvador: 0,143 ms; `resolveNearbyMunicipality` inteiro com carteira de 435: **0,32 ms**, dos quais 0,26 ms são os 417 centroides. Com penalidade de 6× para celular médio, ~2,6 ms — **um oitavo de um frame**, então não há caminho quente aqui e o artefato de centroides continuaria sem se pagar. Construir um `Map<codarea, feature>` novo por chamada mediu **0,337 ms, mais lento** que o `find`; o índice `getMunicipalityFeature` é usado porque o módulo **já o construiu** para o mapa, não como otimização. Chunk de geometria no build: 134.488 B crus / **42.158 B gzip**, referenciado por um único arquivo (o chunk da página), confirmando um download e um decode (1,69 ms) para os dois consumidores. O que muda com o card é o **agendamento**: a geometria passa a ser pedida na hidratação da página, competindo com Leaflet (42.675 B gzip), em vez de só depois do chunk do painel. Aceito — mas **não** porque o `Promise.all` esconderia o decode atrás do fix de GPS: o decode roda no topo do módulo, ou seja, quando o chunk baixa e executa, independente do fix. Ele é aceito porque é **barato** (`JSON.parse` 1,62 ms + `topojson.feature()` 0,41 ms ≈ 2 ms no desktop, ~10 ms num celular 5× mais lento — abaixo da barra de 50 ms de long task) e porque a alternativa sequencial (fix primeiro, geometria depois) é **pior** no caso comum: com `maximumAge` de 5 min um fix em cache volta em milissegundos e aí os 42 KB cairiam inteiros no caminho crítico do card. O download também nunca é especulativo — `MunicipalityMapPanel` monta sempre em `mode="municipality"`, então todo staff em `/campanha` precisa desse chunk de qualquer forma.
**Débito registrado (não corrigido aqui).** As 435 linhas de `accessibleMunicipalities` (~6,6 KB gzip) duplicam o `municipalitiesByIbgeCode` que `MunicipalityMapBundle` já serializa na mesma página, a partir do mesmo `loadMunicipalityScope`. Unificar exige mexer na fronteira de Suspense do mapa (o painel só monta atrás dela, e pode ser `null`), o que é reestruturação, não limpeza. Tuplas em vez de objetos economizariam apenas 398 B gzip — medido, e não vale o tipo auto-descritivo.
**Débito registrado (não corrigido aqui).** As 435 linhas de `accessibleMunicipalities` (~6,6 KB gzip) duplicam o `municipalitiesByIbgeCode` que `MunicipalityMapBundle` já serializa na mesma página, a partir do mesmo `loadMunicipalityScope`. Unificar exige mexer na fronteira de Suspense do mapa (o painel só monta atrás dela, e pode ser `null`), o que é reestruturação, não limpeza. Tuplas em vez de objetos economizariam apenas 398 B gzip — medido, e não vale o tipo auto-descritivo. A 3ª rodada de `/simplify` afiou o caminho da correção: publicar `MunicipalitiesByIbgeCode` no lugar do array plano faria `resolveNearbyMunicipality` trocar o `filter` sobre 435 linhas por uma busca por chave, e a página pararia de montar o mesmo agrupamento duas vezes (`buildZoneCityHrefs` reconstrói o índice a partir do array). Só que exige mover `municipalityMapNavigation.ts` para `src/lib/` — hoje em `utilities/` **sem nenhum import**, portanto já puro e mal-alojado — e tocar os importadores do domínio do mapa. Continua reestruturação, não limpeza.
## Design (Impeccable)
@ -138,6 +138,8 @@ Componentes:
- **Atalho também na lista `/campanha/municipios`.** Revisitar quando: evidência de uso no Início (campo pede o mesmo atalho na lista).
- **Lembrar última sugestão em `sessionStorage` para paint imediato.** Revisitar quando: critique acusar flash/vazio perceptível no card. _(Não acusou: o chunk de geometria costuma estar resolvido antes do fix chegar.)_
- **Atalho geo também para `leader`.** Permanece fora: liderança não sai do `LeaderContactsPanel`.
- **Reusar `resolveMunicipalityMapNavigation` no branch `none|navigate|zones`.** Revisitar quando: um 3º chamador precisar do `zoneCount`/da entrada casada. Hoje a união existente descarta justamente o que o card precisa, então reusar significa alargar um contrato de que duas superfícies do mapa dependem para servir um caller — mais caro que as ~18 linhas duplicadas.
- **Helper compartilhado para flags de sessão (`sessionStorage`).** Revisitar quando: nascer a 3ª flag. Hoje são duas (`campaignGeolocation` e `InstallPwaToast`) e elas **discordam de propósito** no fallback de leitura — em modo privado o geo suprime o prompt automático e o toast continua aparecendo —, o que é a maior parte do que o helper faria.
## Referências

View file

@ -23,6 +23,9 @@ import {
type GeolocationFailureReason,
} from '@/utilities/campaignGeolocation'
/** Everything except `unsupported`, which is a state of its own — the card hides. */
type ActionableFailureReason = Exclude<GeolocationFailureReason, 'unsupported'>
type CardState =
/**
* Mounted, permission not read yet. Renders the explanation without the CTA:
@ -34,9 +37,14 @@ type CardState =
| { status: 'ready' }
| { status: 'locating' }
| { status: 'resolved'; resolution: NearbyMunicipalityResolution; coarse: boolean }
| { status: 'failed'; reason: GeolocationFailureReason }
| { status: 'failed'; reason: ActionableFailureReason }
/** No geolocation in this browser: nothing to act on, so the card takes no space. */
| { status: 'unsupported' }
const failureCopy: Record<GeolocationFailureReason, { title: string; hint: string } | null> = {
/** The states that render — `unsupported` never reaches the body. */
type VisibleCardState = Exclude<CardState, { status: 'unsupported' }>
const failureCopy: Record<ActionableFailureReason, { title: string; hint: string }> = {
denied: {
title: 'Localização bloqueada neste navegador',
hint: 'Libere o acesso à localização nas permissões do site e tente de novo.',
@ -51,8 +59,6 @@ const failureCopy: Record<GeolocationFailureReason, { title: string; hint: strin
title: 'A localização demorou para responder',
hint: 'Sinal fraco costuma ser a causa. Tente de novo.',
},
// Nothing the user can do here, so the card does not take space (see render).
unsupported: null,
}
/**
@ -65,17 +71,21 @@ const failureCopy: Record<GeolocationFailureReason, { title: string; hint: strin
* `leader` nunca chega aqui: a página `/campanha` roteia liderança para o
* `LeaderContactsPanel` antes do dashboard.
*/
/**
* Filtered-list href per multi-zone city, keyed by IBGE code — only the cities
* that have several zone municipalities, hence `Partial`: most codes are absent
* and the lookup must be guarded. Ready-made on the server on purpose: importing
* the canonical list-URL builder here costs 21 KB of the dashboard's First Load
* JS (see `buildZoneCityHrefs` in `CampaignDashboard`).
*/
type ZoneCityHrefs = Readonly<Partial<Record<string, string>>>
export const NearestMunicipalityCard = ({
accessible,
zoneCityHrefs,
}: {
accessible: readonly AccessibleMunicipality[]
/**
* Filtered-list href per multi-zone city, keyed by IBGE code. Ready-made on
* purpose: importing the canonical list-URL builder here costs 21 KB of the
* dashboard's First Load JS (see `buildZoneCityHrefs` in `CampaignDashboard`).
*/
zoneCityHrefs: Readonly<Record<string, string>>
zoneCityHrefs: ZoneCityHrefs
}) => {
const [state, setState] = useState<CardState>({ status: 'starting' })
const autoStartedRef = useRef(false)
@ -104,7 +114,11 @@ export const NearestMunicipalityCard = ({
const [result, geometry] = loaded
if (!result.ok) {
setState({ status: 'failed', reason: result.reason })
setState(
result.reason === 'unsupported'
? { status: 'unsupported' }
: { status: 'failed', reason: result.reason },
)
return
}
@ -150,7 +164,7 @@ export const NearestMunicipalityCard = ({
void start()
}, [accessible.length, locate])
if (accessible.length === 0 || !isCardWorthShowing(state)) return null
if (accessible.length === 0 || state.status === 'unsupported') return null
return (
<Card className="flex h-full flex-col">
@ -174,59 +188,50 @@ export const NearestMunicipalityCard = ({
)
}
/** A browser without geolocation takes no space: there is nothing to act on. */
const isCardWorthShowing = (state: CardState): boolean =>
state.status !== 'failed' || state.reason !== 'unsupported'
/**
* The multi-second wait and its outcome must reach a screen reader, and the
* visible copy is spread over paragraph and button — hence one sentence here.
*/
const statusAnnouncement = (state: CardState): string => {
const statusAnnouncement = (state: VisibleCardState): string => {
if (state.status === 'locating') return 'Localizando…'
if (state.status === 'failed') return failureCopy[state.reason]?.title ?? ''
if (state.status === 'failed') return failureCopy[state.reason].title
if (state.status !== 'resolved') return ''
return headlineSentence(locationHeadline(state.resolution))
}
/**
* Where the actor is, written once: the announcement above and the visible
* headline below both read it, so they cannot drift apart.
* Where the actor is, in parts: the announcement joins them into a sentence and
* the visible headline emphasizes the place, so the copy exists in one place and
* the two cannot drift apart.
*/
type LocationHeadline = { place: string; suffix: string } | { place: null; sentence: string }
type LocationHeadline = { lead: string; place: string | null; tail: string }
const PLACE_LEAD = 'Você está em '
const locationHeadline = (resolution: NearbyMunicipalityResolution): LocationHeadline => {
switch (resolution.kind) {
case 'inScope':
return { place: resolution.municipality.name, suffix: '.' }
return { lead: PLACE_LEAD, place: resolution.municipality.name, tail: '.' }
case 'zoneCity':
return { place: resolution.city, suffix: ', dividida por zona eleitoral.' }
return { lead: PLACE_LEAD, place: resolution.city, tail: ', dividida por zona eleitoral.' }
case 'outOfScope':
return { place: resolution.city, suffix: ', fora da sua carteira.' }
return { lead: PLACE_LEAD, place: resolution.city, tail: ', fora da sua carteira.' }
case 'outsideBahia':
return {
place: null,
sentence: resolution.nearestInScope
? 'Você está fora da Bahia.'
: 'Nenhum município da campanha perto de você.',
}
// What to do about it is the detail's job, so the headline states only the
// fact — the same sentence whether or not there is a município to offer.
return { lead: 'Você está fora da Bahia.', place: null, tail: '' }
}
}
const headlineSentence = (headline: LocationHeadline): string =>
headline.place === null ? headline.sentence : `Você está em ${headline.place}${headline.suffix}`
const headlineSentence = ({ lead, place, tail }: LocationHeadline): string =>
`${lead}${place ?? ''}${tail}`
const Headline = ({ headline }: { headline: LocationHeadline }) => (
const Headline = ({ headline: { lead, place, tail } }: { headline: LocationHeadline }) => (
<p className="text-sm">
{headline.place === null ? (
headline.sentence
) : (
<>
Você está em <strong className="font-medium">{headline.place}</strong>
{headline.suffix}
</>
)}
{lead}
{place ? <strong className="font-medium">{place}</strong> : null}
{tail}
</p>
)
@ -256,10 +261,12 @@ const CoarseFixCaveat = () => (
</Explanation>
)
const NothingNearby = () => <Explanation>Nenhum município da sua carteira por perto.</Explanation>
const renderBody = (
state: CardState,
state: VisibleCardState,
locate: () => void,
zoneCityHrefs: Readonly<Record<string, string>>,
zoneCityHrefs: ZoneCityHrefs,
): ReactNode => {
if (state.status === 'starting' || state.status === 'ready') {
return (
@ -285,7 +292,6 @@ const renderBody = (
if (state.status === 'failed') {
const copy = failureCopy[state.reason]
if (!copy) return null
return (
<>
@ -310,7 +316,7 @@ const renderBody = (
const resolutionDetail = (
resolution: NearbyMunicipalityResolution,
locate: () => void,
zoneCityHrefs: Readonly<Record<string, string>>,
zoneCityHrefs: ZoneCityHrefs,
): ReactNode => {
const nearest = 'nearestInScope' in resolution ? resolution.nearestInScope : null
const nearestDetail = nearest ? (
@ -356,13 +362,14 @@ const resolutionDetail = (
}
case 'outOfScope':
return nearestDetail ?? <Explanation>Nenhum município da sua carteira por perto.</Explanation>
return nearestDetail ?? <NothingNearby />
case 'outsideBahia':
return (
nearestDetail ?? (
<>
<Explanation>Sua posição está longe do território da campanha.</Explanation>
<NothingNearby />
{/* Out of state with nothing to offer is also what a bad fix looks like. */}
<LocateButton label="Tentar de novo" onClick={locate} />
</>
)

View file

@ -6,7 +6,6 @@ import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { formatRelativeAge } from '@/utilities/formatRelativeAge'
import {
clearRecentVisits,
@ -14,19 +13,7 @@ import {
type RecentVisitEntry,
} from '@/utilities/recentVisits'
type RecentlyVisitedCardProps = {
now: Date
compact?: boolean
clearControl?: 'icon' | 'labeled'
className?: string
}
export const RecentlyVisitedCard = ({
now,
compact = false,
clearControl = 'icon',
className,
}: RecentlyVisitedCardProps) => {
export const RecentlyVisitedCard = ({ now }: { now: Date }) => {
const [entries, setEntries] = useState<RecentVisitEntry[]>([])
useEffect(() => {
@ -43,29 +30,23 @@ export const RecentlyVisitedCard = ({
const nowMs = now.getTime()
return (
<Card className={cn('flex h-full flex-col', className)}>
<CardHeader className={compact ? 'pb-2' : undefined}>
<CardTitle className={compact ? 'text-sm' : undefined}>
{compact ? 'Recentes' : 'Visitados recentemente'}
</CardTitle>
<Card className="flex h-full flex-col">
<CardHeader>
<CardTitle>Visitados recentemente</CardTitle>
<CardAction>
<Button
type="button"
variant={clearControl === 'labeled' ? 'outline' : 'ghost'}
size={clearControl === 'labeled' ? 'sm' : 'icon-sm'}
className={clearControl === 'labeled' ? 'min-h-11 shrink-0' : 'size-8 shrink-0'}
variant="ghost"
size="icon-sm"
className="size-8 shrink-0"
onClick={handleClear}
aria-label="Limpar histórico de páginas visitadas recentemente"
>
<Trash2Icon
data-icon={clearControl === 'labeled' ? 'inline-start' : undefined}
aria-hidden="true"
/>
{clearControl === 'labeled' ? 'Limpar histórico' : null}
<Trash2Icon aria-hidden="true" />
</Button>
</CardAction>
</CardHeader>
<CardContent className={cn('flex-1', compact ? 'px-4 pt-0' : undefined)}>
<CardContent className="flex-1">
<ul className="m-0 flex list-none flex-col gap-0.5 p-0">
{entries.map((entry) => {
const Icon = entry.kind === 'municipality' ? Building2Icon : FilterIcon
@ -74,22 +55,15 @@ export const RecentlyVisitedCard = ({
<Button
asChild
variant="ghost"
className={cn(
'h-auto w-full justify-start gap-2 px-2',
compact ? 'min-h-10 py-1.5' : 'min-h-11',
)}
className="h-auto min-h-11 w-full justify-start gap-2 px-2"
>
<Link href={entry.href} title={entry.label}>
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-left">
<strong className="block truncate text-sm font-medium">{entry.label}</strong>
{!compact ? (
<span className="block truncate text-xs text-muted-foreground">
{formatRelativeAge(entry.visitedAt, nowMs)}
</span>
) : (
<span className="sr-only">{formatRelativeAge(entry.visitedAt, nowMs)}</span>
)}
<span className="block truncate text-xs text-muted-foreground">
{formatRelativeAge(entry.visitedAt, nowMs)}
</span>
</span>
</Link>
</Button>

View file

@ -4,6 +4,7 @@ import type {
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
import { oneDecimalFormatter } from '@/lib/electionFormat'
/**
* B14 — "onde estou" resolvido contra a malha municipal da Bahia.
@ -79,9 +80,10 @@ export const haversineKm = (from: GeoPoint, to: GeoPoint): number => {
}
/**
* Even-odd ray casting. A point exactly on a ring is undefined by construction,
* which is why `findContainingMunicipality` documents a deterministic tie-break
* instead of pretending shared borders belong to nobody.
* Even-odd ray casting, half-open on purpose: only crossings strictly east of
* the point count (`point.lng < crossingLng`). That is what makes a point on a
* shared border belong to exactly one município — the one whose interior lies
* east of the edge (north, for a horizontal one) — instead of to both or neither.
*/
const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => {
let inside = false
@ -168,9 +170,11 @@ export const featureCentroid = (feature: BahiaMunicipalityFeature): GeoPoint =>
}
/**
* First feature whose polygons contain the point. Shared borders are resolved
* by feature order, so a point on a boundary belongs to exactly one município —
* never to both, never to none.
* First feature whose polygons contain the point. A point on a shared border
* lands in exactly one município by the half-open convention in `isPointInRing`,
* not by this scan's order: the mesh is TopoJSON-derived, so neighbours share
* numerically identical arcs and never overlap. Feature order only decides
* pathological overlaps the committed mesh does not contain.
*/
export const findContainingMunicipality = (
features: readonly BahiaMunicipalityFeature[],
@ -252,10 +256,8 @@ export const resolveNearbyMunicipality = ({
}
}
const kmFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 })
/** Distance as field copy: precise where it is felt, rounded where it is not. */
export const formatDistanceKm = (distanceKm: number): string => {
if (distanceKm < 1) return 'menos de 1 km'
return `${kmFormatter.format(distanceKm < 10 ? distanceKm : Math.round(distanceKm))} km`
return `${oneDecimalFormatter.format(distanceKm < 10 ? distanceKm : Math.round(distanceKm))} km`
}

View file

@ -1,3 +1,5 @@
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
type MunicipalityMapSlugEntry = {
slug: string
name: string
@ -10,14 +12,8 @@ export type MunicipalityMapNavigation =
| { kind: 'navigate'; slug: string }
| { kind: 'zones' }
type MunicipalityForIbgeIndex = {
slug: string
name: string
ibgeCode: string
}
export const buildMunicipalitiesByIbgeCode = (
municipalities: readonly MunicipalityForIbgeIndex[],
municipalities: readonly AccessibleMunicipality[],
): MunicipalitiesByIbgeCode => {
const byIbge: MunicipalitiesByIbgeCode = {}

View file

@ -44,7 +44,7 @@ export const municipalityListSelect = {
},
} as const
export type MunicipalityPoliticalTrendViewModel = {
type MunicipalityPoliticalTrendViewModel = {
status: PoliticalTrendStatus | null
note: string | null
recordedByName: string | null

View file

@ -8,6 +8,7 @@ import {
haversineKm,
type GeoPoint,
} from '../../src/lib/municipalityProximity.js'
import { featureBounds } from '../helpers/featureBounds.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
@ -36,17 +37,7 @@ const geoCard = (page: Page) => page.locator('[data-slot="card"]').filter({ hasT
* fall outside a concave município, and the fixture hands out an arbitrary one.
*/
const interiorPointOf = (feature: BahiaMunicipalityFeature): GeoPoint => {
const rings =
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
const positions = rings.flat(2)
const longitudes = positions.map(([lng]) => lng)
const latitudes = positions.map(([, lat]) => lat)
const west = Math.min(...longitudes)
const east = Math.max(...longitudes)
const south = Math.min(...latitudes)
const north = Math.max(...latitudes)
const { west, east, south, north } = featureBounds(feature)
const steps = 24
for (let row = 1; row < steps; row += 1) {

View file

@ -0,0 +1,30 @@
import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js'
export type FeatureBounds = {
west: number
east: number
south: number
north: number
}
/**
* Bounding box of every ring of a municipality feature. Type-only import on
* purpose: this helper is shared by the vitest int suite and the Playwright e2e
* suite, which resolve runtime modules differently.
*/
export const featureBounds = (feature: BahiaMunicipalityFeature): FeatureBounds => {
const rings =
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
const positions = rings.flat(2)
const longitudes = positions.map(([lng]) => lng)
const latitudes = positions.map(([, lat]) => lat)
return {
west: Math.min(...longitudes),
east: Math.max(...longitudes),
south: Math.min(...latitudes),
north: Math.max(...latitudes),
}
}

View file

@ -16,6 +16,8 @@ import {
resolveNearbyMunicipality,
} from '@/lib/municipalityProximity'
import { featureBounds } from '../helpers/featureBounds'
/** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */
const MAX_TOPO_BYTES = 600 * 1024
@ -120,26 +122,18 @@ describe('Bahia static geometries', () => {
for (const entry of features) {
const centroid = featureCentroid(entry)
const rings =
entry.geometry.type === 'Polygon'
? [entry.geometry.coordinates]
: entry.geometry.coordinates
const positions = rings.flat(2)
const longitudes = positions.map(([lng]) => lng)
const latitudes = positions.map(([, lat]) => lat)
const bounds = featureBounds(entry)
expect(centroid.lng, entry.properties.name).toBeGreaterThanOrEqual(Math.min(...longitudes))
expect(centroid.lng, entry.properties.name).toBeLessThanOrEqual(Math.max(...longitudes))
expect(centroid.lat, entry.properties.name).toBeGreaterThanOrEqual(Math.min(...latitudes))
expect(centroid.lat, entry.properties.name).toBeLessThanOrEqual(Math.max(...latitudes))
expect(centroid.lng, entry.properties.name).toBeGreaterThanOrEqual(bounds.west)
expect(centroid.lng, entry.properties.name).toBeLessThanOrEqual(bounds.east)
expect(centroid.lat, entry.properties.name).toBeGreaterThanOrEqual(bounds.south)
expect(centroid.lat, entry.properties.name).toBeLessThanOrEqual(bounds.north)
}
})
it('sends a Salvador position to the zone list and an out-of-portfolio one to the nearest', async () => {
const geometry = await loadMunicipalityGeometryModule()
const salvadorZones = municipalityCatalog
.filter((entry) => entry.city === 'Salvador')
.map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode }))
const salvadorZones = municipalityCatalog.filter((entry) => entry.city === 'Salvador')
const seabra = municipalityCatalog.find((entry) => entry.slug === 'seabra')!
const salvadorPoint = { lat: -12.973, lng: -38.5121 }
@ -160,7 +154,7 @@ describe('Bahia static geometries', () => {
resolveNearbyMunicipality({
point: salvadorPoint,
geometry,
accessible: [{ slug: seabra.slug, name: seabra.name, ibgeCode: seabra.ibgeCode }],
accessible: [seabra],
}),
).toEqual({ kind: 'outOfScope', city: 'Salvador', nearestInScope: null })
})

View file

@ -137,9 +137,10 @@ describe('municipalityProximity', () => {
const onBorder = { lat: -12.5, lng: -39 }
const matches = features.filter((feature) => featureContainsPoint(feature, onBorder))
// Exactly one, and it is the eastern feature: the half-open ray test counts
// only crossings strictly east of the point, so a shared edge belongs to the
// polygon whose interior lies east of it. Scan order never gets a say.
expect(matches).toHaveLength(1)
// The border belongs to the feature whose interior is west of it, and the
// tie-break is feature order — not "whichever the scan happened to hit".
expect(findContainingMunicipality(features, onBorder)).toBe(east)
})