mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
e482005ce0
commit
5ace1a04b2
10 changed files with 130 additions and 133 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const municipalityListSelect = {
|
|||
},
|
||||
} as const
|
||||
|
||||
export type MunicipalityPoliticalTrendViewModel = {
|
||||
type MunicipalityPoliticalTrendViewModel = {
|
||||
status: PoliticalTrendStatus | null
|
||||
note: string | null
|
||||
recordedByName: string | null
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
30
tests/helpers/featureBounds.ts
Normal file
30
tests/helpers/featureBounds.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue