B8+: escala das malhas em unidades reais, contrato do map key e degradacao

F1 — a tolerancia de simplificacao e os digitos de quantizacao viraram
politica em unidades de solo em scripts/lib/topology.mjs, consumida pelos
dois scripts de geometria. Os quantis 0.35 copiados cortavam as 19 ZE de
Salvador a ~0.6 m2 e os 417 municipios a ~1.78 km2; com tolerancia
absoluta e a grade de ~103 m do estado, a malha das zonas caiu de 480 para
30.2 pontos/feature. bahia-municipalities segue byte-identico e o tripwire
de bytes deu lugar a pontos/feature.

F2 — featureMapKey passou a ser asserido sobre features reais das tres
malhas comprometidas (417 codarea, 27 code, 19 slug), e o comentario de
MAP_KEY_PROPERTIES agora diz o que o codigo faz: as propriedades sao
mutuamente exclusivas, nao ordenadas por especificidade.

F3 — a malha das zonas e um detalhe sobre o mapa, nao o mapa: se o chunk
falha, Salvador cai no poligono municipal que ja esta desenhado embaixo e
o resto da Bahia continua funcionando, com o motivo logado. A ressalva de
aproximacao saiu do gate de comparacao e passou a depender de
bundle.hasZoneMunicipalities, derivado no servidor de municipality.kind.

F4 — orfaos deletados: os dois acessores sem chamador, isZoneMapKey (o
servidor ja sabe a regra), o wrapper simplifyTopology e o helper
polygonCount com um branch inalcancavel. MunicipalityMapNavigation
colapsou em string | undefined e MunicipalitiesByMapKey em
Record<string, string>.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 01:56:24 -03:00
parent dd8c4d1d27
commit 2c30944593
26 changed files with 415 additions and 211 deletions

File diff suppressed because one or more lines are too long

View file

@ -74,6 +74,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md).
**Recently resolved (2026-07-27):** **B8+** Escala e DRY pós-B8 F2 — the four `/simplify` debts of the ZE polygons, and the headline finding changed owner. The plan called the target "ground resolution implemented via quantization digits"; measuring the committed artifact re-snapped to coarser grids showed quantization alone barely cuts (~25 m → 90% of the original vertices, and **even on the state mesh's ~103 m grid the zones stayed at 203 points/feature**, ~9× the sibling). The lever is the **absolute Visvalingam threshold**, so the policy was inverted: absolute weight in **m² of ground** is the policy, digits are derived per topology. See the ground-scale policy bullet under "Campaign map geometries" for the numbers that matter; the outcome is the zone mesh at **574 arc points / 30.2 per feature / 7,687 bytes** (from 9,120 / 480 / 72,310), `bahia-municipalities` **byte-identical**, and `bahia-identity-territories` off by one byte because its grid is now derived from its bbox (102.730 → 102.822 m). `zoneNumber` left the artifact and the type. The other three phases: `featureMapKey` is now exercised on real features of all three committed meshes, each inside the loop that already sweeps it whole (417 `codarea` + 27 `code` + 19 `slug`) — the `/simplify` caught the first version asserting the city inside the zone loop, which covered **one** municipal feature nineteen times because the 19 ZE share Salvador's code — and the synthetic precedence test died (the invariant is real — see the Map bullet); a zone-chunk failure degrades instead of killing the map (with the reason logged, since a ChunkLoadError and a corrupt artifact are not the same event), and the approximation caveat left the `!comparisonActive` gate for a paragraph conditioned on the new bundle field `hasZoneMunicipalities`, published by the loader from `kind === 'zona'` — the `/simplify` rejected deriving it on the client from the key's shape (a second encoding of `mapKeyForMunicipality`, in negative space) and rejected gating on `zoneBreakdown`, which the zone-list fill-in deletes; and the delivery's orphans are gone — two accessors deleted, `MunicipalityMapNavigation` collapsed to `string | undefined`, `MunicipalitiesByMapKey` to `Record<string, string>` (435 dead strings out of every Início RSC payload), `polygonRingsOf` exported, geometry helpers retyped on `PolygonalFeature`. **One planned deletion was rejected by measurement:** `city`/`zoneNumber` on `MunicipalityZoneNeighborhoodEntry` **stay** — no runtime reads them, but both feed the rows hashed by `canonicalEvidenceRows` and are columns of the RA 02/2017 provenance fixture (an independent transcription), so deleting them would weaken the verification for nothing. No migration, collection, action or `Consent`; e2e still 436 paths / 435 interactive. Plano: [`docs/plans/escala-dry-pos-b8f2.md`](docs/plans/escala-dry-pos-b8f2.md).
**Recently resolved (2026-07-26):** **E12** Camada TI — extends the E17/B21 rollup (`territoryOverview.ts` + `loadTerritoryOverview.ts`) with E8 goal coverage (`central`), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via `computeAggregateTerritorialClass` (same rule as B13 for TI groups). Staff surface: three new columns on `/campanha/territorios` (Cobertura, Captura, Classe) with B22 header descriptions and sorts `cobertura`/`captura`/`classe`; municipality detail gets intra-TI benchmark T4 on `MunicipalityGoalAccountCard` (`territoryIntraCaptureBenchmark.ts` — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (`captura-regional`, `benchmark-intra-ti`). v1 cuts kept: no `?group=territorio` on the municipality list, no TI map mode. No migration. Plano: [`docs/plans/camada-territorios-identidade.md`](docs/plans/camada-territorios-identidade.md).
**Recently resolved (2026-07-26):** **B24** Auto-save + justificativa no popover de Tendência — `MunicipalityListTrendControl` grava sozinho via `POST /campanha/municipios/political-trend` (status ~150 ms, nota 600 ms, flush blur/fechamento), `Textarea` de justificativa no Popover, sem `revalidatePath` da lista; `/editar` mantém submit explícito. Plano: [`docs/plans/autosave-tendencia-lista-municipios.md`](docs/plans/autosave-tendencia-lista-municipios.md).
@ -159,7 +161,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
- **Dobradinhas:** `stateDeputy` entity + `municipality.stateDeputies` / `leadership.stateDeputies` hasMany; UI at `/campanha/dobradinhas`.
- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. **Keyed by "map key", not `ibgeCode`** (B8 F2, 2026-07-26): `mapKeyForMunicipality` returns the `municipalitySlug` for `kind === 'zona'` and the `ibgeCode` otherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own from `bahia-municipality-zones.topo.json`. Every bundle field follows that key (`municipalitiesByMapKey`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `valuesByYear`, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's **municipal** polygon is still drawn, underneath, as a non-interactive base with no data (`baseKeys` in `BahiaMap`) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution is `featureMapKey` (`municipalitySlug ?? codarea ?? code`). **`competitiveRank` is still per city:** the TSE artifact only carries `federalRankByIbgeCode`, so all 19 zones share the city's placement — which is what the legend already claims, and an int test pins it. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason.
- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. **Keyed by "map key", not `ibgeCode`** (B8 F2, 2026-07-26): `mapKeyForMunicipality` returns the `municipalitySlug` for `kind === 'zona'` and the `ibgeCode` otherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own from `bahia-municipality-zones.topo.json`. Every bundle field follows that key (`municipalitiesByMapKey`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `valuesByYear`, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's **municipal** polygon is still drawn, underneath, as a non-interactive base with no data (`baseKeys` in `BahiaMap`) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution is `featureMapKey` (`municipalitySlug ?? codarea ?? code`) — the three are **mutually exclusive** on the committed meshes, so it is a lookup over distinct names and not a precedence rule; the zone mesh also carries the city's `ibgeCode`, which is deliberately **not** a key property because all 19 zones share it. Since B8+ that contract is exercised on **real features of all three meshes** in `tests/int/bahiaGeometries.int.spec.ts` — over the full 417 / 27 / 19, not on hand-written literals and not on one city feature repeated per zone — and a failure of the zone chunk **degrades instead of killing the map**: `loadLayerFeatures` catches it and paints the state without the ZE (Salvador falls back to its non-interactive base polygon, not to an aggregated one — the bundle is keyed by slug), while `status === 'error'` stays reserved for the municipal mesh. **`competitiveRank` is still per city:** the TSE artifact only carries `federalRankByIbgeCode`, so all 19 zones share the city's placement — which is what the legend already claims, and an int test pins it. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason.
- **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`.
## Campaign supporters (C2)
@ -186,9 +188,10 @@ Mobilization events / agenda backing the campaign's field operations (roadmap it
Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). No migration, collection, Consent, or server action — same static-data pattern as `bahiaTerritories` / `bahiaTseZones`.
- **Artifacts (committed):** `src/lib/geometries/bahia-municipalities.topo.json` (~132 KB; Topology object `municipalities`, 417 features, `properties: { codarea, name }`), `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`) and — since B8 F2 (2026-07-26) — `src/lib/geometries/bahia-municipality-zones.topo.json` (~72 KB; object `municipalityZones`, **19** features, `properties: { municipalitySlug, name, zoneNumber, ibgeCode }`), the Salvador ZE mesh. Soft size budget in tests: ≤ ~600 KB each.
- **Artifacts (committed):** `src/lib/geometries/bahia-municipalities.topo.json` (~132 KB; Topology object `municipalities`, 417 features, `properties: { codarea, name }`), `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`) and — since B8 F2 (2026-07-26) — `src/lib/geometries/bahia-municipality-zones.topo.json` (**~7.7 KB** since B8+ recalibrated it; object `municipalityZones`, **19** features, `properties: { municipalitySlug, name, ibgeCode }`), the Salvador ZE mesh.
- **Ground-scale policy (B8+, 2026-07-27):** how much detail a committed mesh keeps is declared **in ground units** in `scripts/lib/topology.mjs` (the first module in `scripts/lib/` — **B5 F2** lands there rather than opening a second one), and both build scripts consume it. Two knobs, both meaningless as raw numbers: `topojson-simplify` can pick its Visvalingam threshold as a **quantile of the mesh's own triangles**, and `quantize(topo, n)` lays an `n`-step grid across **the topology's own bbox** — so the `0.35` and `1e4` copied verbatim between the two scripts cut Salvador at ~0.6 m² and ~3.9 m while cutting Bahia at ~1.78 km² and ~103 m. The zone mesh shipped at **480 points/feature against the state mesh's 21.9** because of it. Now `SIMPLIFY_TOLERANCE_M2` states a tolerance per mesh in **square metres of ground** (with the density it buys measured in the comment) and quantization digits are derived per topology from `GROUND_GRID_METRES`. Two things not to re-derive: quantization alone barely helps (even on the state grid the zones stayed at 203 points/feature — the absolute weight is the lever), and **simplify before quantize**, because quantizing a dense mesh to a ~100 m grid collapses vertices into duplicates and a later `merge` produces broken rings (measured: Salvador dissolving to 24 km² instead of 307). Tolerances differ per mesh on purpose: the municipal tolerance reaches 17.9 points/feature on the zones but sheds 5% of Salvador's land area (306 → 291 km²), which is the quantity the int spec uses to prove no neighbourhood was dropped or double-counted. Guard: `MAX_POINTS_PER_FEATURE = 60` in `tests/int/bahiaGeometries.int.spec.ts`, next to the byte budget (soft, ≤ ~600 KB each) — bytes only catch a mesh nobody simplified at all.
- **Code table:** `src/lib/bahiaMunicipalityCodes.ts` — canonical municipality name → IBGE 7-digit `codarea`, plus `codeForMunicipality` / `municipalityForCode`. Fixture `tests/fixtures/bahia-municipality-codes.official.json` + int tests for bijection/coverage.
- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — `getMunicipalityFeature` / `getTerritoryFeature` / `getMunicipalityZoneFeature`, typed topologies, feature arrays. TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule` / `loadMunicipalityZoneGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it. Shared feature types live in `bahiaGeometriesTypes.ts`; geometry math that must accept any of the three meshes (point-in-polygon, centroid) takes `BahiaGeometryFeature`.
- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — typed topologies, feature arrays and `getMunicipalityFeature` (the territory and zone accessors were deleted by B8+: only the int specs used them, and a `Map` built in one line is what a spec wants). TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule` / `loadMunicipalityZoneGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it. Shared feature types live in `bahiaGeometriesTypes.ts`: `BahiaFeature<P>` is the one spelling of `Feature<Polygon | MultiPolygon, P>`, `BahiaMeshFeature` is what the map layer receives from any of the three meshes, and geometry math (point-in-polygon, centroid, area, bounds — all built on the exported `polygonRingsOf`) takes **`PolygonalFeature`**, which declares the actual requirement instead of dragging `GeoJsonProperties` (i.e. `any`) through the signature.
- **Territory polygons:** dissolved from IBGE municipality meshes via `bahiaIdentityTerritoryRecords` (`topojson` `merge`), not the IDE Bahia shapefile (IDE remains provenance/validation reference only).
- **Rebuild (Salvador zones):** `pnpm build:municipality-zone-geometries` (`scripts/build-municipality-zone-geometries.mjs`; `--report` reconciles without writing) downloads IBGE `BA_bairros_CD2022.zip` from geoftp, parses the shapefile with **`shpjs`** (devDependency — that cut is SHP-only, SIRGAS 2000), keeps `CD_MUN === '2927408'` (170 polygons) and dissolves neighbourhood→zone against **TRE-BA RA 02/2017** (`municipalityZoneNeighborhoods.ts`). Reconciliation is **curated data in the script header**, not cleverer matching: an alias table, a table for TRE neighbourhoods with no polygon of their own (the build refuses a merge that would cross zones), and a table for the one neighbourhood the resolution splits mid-street (it goes whole to one ZE — the neighbourhood is the atom). The 5 polygons the resolution never names are assigned by **longest shared boundary**, a rule validated against the 4 it names under another label. The build fails on an orphan polygon or a TRE neighbourhood with no polygon, so 170/170 → 19/19 is an invariant, not a hope. Same cache dir and the same "**does not touch the database**" guarantee as the sibling below.
- **Rebuild:** `pnpm build:geometries` (`scripts/build-bahia-geometries.mjs`) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names with `canonicalizeMunicipalityName`, simplifies/quantizes, emits the artifacts above. Cache under `data/geometries/` (gitignored; override with `GEOMETRIES_CACHE_DIR`). **Does not touch the database** — no `assertLocalDatabase` / no Neon risk. Not part of `pnpm build` or `pnpm dev`. Details: `docs/plans/mapa-bahia-geometrias.md`. Scale/DRY follow-ups from `/simplify`: `docs/plans/escala-dry-pos-b2.md` (B5).

View file

@ -1,12 +1,54 @@
# Escala e DRY pós-B8 F2 (polígonos das ZE de Salvador)
Status: rascunho
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B8+**, fill-in de engenharia)
Status: **entregue 2026-07-27** (as quatro fases; ver "Como foi entregue")
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (**B8+ ✓**, fill-in de engenharia)
Impeccable: A — N/A (nenhuma superfície nova; F3 muda um parágrafo de copy e um estado de falha, o resto preserva o comportamento visível do B8 F2)
Appetite: ~0,75–1 dia eng; quatro fases independentes, nenhuma com migration, collection ou `Consent`; só F1 reescreve o artefato commitado
Responsável: —
## Como foi entregue (as-built, 2026-07-27)
As quatro fases entraram numa sessão, F1 primeiro. Duas correções ao plano vieram de medição, não de preferência, e estão registradas abaixo porque mudam o que a próxima pessoa deve acreditar.
### F1 — a alavanca era o peso absoluto, não os dígitos
O plano tratava o alvo como resolução de chão **implementada via dígitos de quantização**. Medindo o re-snap do artefato commitado para grades mais grossas, quantização sozinha quase não corta: ~12 m → 8.990 pontos (99% do original), ~25 m → 8.164 (90%), ~50 m → 6.219 (68%), e mesmo **na grade da malha estadual (~103 m) → 3.849 pontos, ainda 203/feature** — ~9× a densidade do irmão. A alavanca dominante é o limiar absoluto de Visvalingam, que o plano mencionava entre parênteses. A política inverteu a ordem: **o peso absoluto (em m² de chão) é a política; os dígitos são derivados do bbox de cada topologia por consistência.**
`scripts/lib/topology.mjs` (primeiro módulo de `scripts/lib/` — **B5 F2** deve pousar nele, não abrir um segundo) declara `SIMPLIFY_TOLERANCE_M2` por malha, `GROUND_GRID_METRES = 102.82`, `simplifyToGroundScale`, `quantizeToGroundGrid` e `describeGroundScale`. `SIMPLIFY_QUANTILE` e `QUANTIZE_DIGITS` saíram dos dois scripts.
| artefato | antes | depois |
| ------------------------------ | --------------------------------- | -------------------------------------------- |
| `bahia-municipalities` | 132.162 B · 9.139 pts · 21,9/ft | **byte-idêntico** (política semeada nele) |
| `bahia-identity-territories` | 14.862 B · 1.210 pts · 44,8/ft | 14.861 B · 1.210 pts · 44,8/ft (−1 B) |
| **`bahia-municipality-zones`** | **72.310 B · 9.120 pts · 480/ft** | **7.687 B · 574 pts · 30,2/ft** (−89% bytes) |
Três coisas que a implementação aprendeu e deixou no comentário do módulo, para ninguém re-derivar:
- **Tolerâncias são por malha de propósito**, agora lado a lado em unidades de chão. A tolerância municipal (1,78 km²) até chega a 17,9 pontos/feature nas zonas, mas come **5% da área de Salvador (306 → 291 km²)** — e a soma de áreas é justamente a asserção que prova que nenhum bairro foi descartado nem contado duas vezes. As zonas ficaram em 0,06 km²; os territórios em 13,6 km² (são dissolve da malha municipal, então já vêm generalizados).
- **A ordem importa:** quantizar antes de simplificar colapsa vértices em duplicatas e o `merge` posterior produz anéis quebrados — medido: Salvador dissolveu para 24 km² em vez de 307.
- O 1 byte dos territórios é a grade indo de 102,730 m para 102,822 m: os dígitos passaram a ser derivados do bbox para a grade pedida em vez de um `1e4` fixo.
A área das zonas saiu em **306,3 km²** (era ~324), dentro da janela de 280–360 km² que o int já asseverava — medida, não presumida, como o plano exigia. O tripwire de bytes ficou e ganhou ao lado `MAX_POINTS_PER_FEATURE = 60`, a grandeza que a política controla. `zoneNumber` saiu do artefato e do tipo.
### F2 — o invariante valia; caiu o teste sintético
`featureMapKey` agora roda sobre features **reais** das três malhas commitadas, cada uma no laço que já as varre por inteiro: 417 municípios respondem `codarea`, 27 TIs respondem `code`, 19 zonas respondem `slug` **mesmo carregando o `ibgeCode` da cidade**. A primeira escrita asseria a cidade dentro do laço das zonas, e o `/simplify` pegou o furo: as 19 entradas compartilham o código de Salvador, então aquilo era uma feature municipal asserida 19 vezes enquanto 416 municípios e os 27 territórios nunca passavam por `featureMapKey`. O caso de precedência pinado no unit usava a forma `{ municipalitySlug, codarea }`, que nenhuma malha produz: caiu, e o comentário passou a declarar o invariante real (as três propriedades de chave são mutuamente exclusivas; `ibgeCode` na malha de zonas deliberadamente não é chave, porque as 19 o compartilham). Sem resolvedor único, como travado.
### F3 — degrada e admite a aproximação em todo modo
`loadLayerFeatures` faz `.catch()` na metade das zonas e segue com a malha municipal. A precisão que o plano deixava otimista: como o bundle é keyed por slug, **Salvador não volta a pintar agregada** — volta como polígono sem dado e sem interação, porque não está em `scopedKeys`. A memoização da rejeição fica (é o que dá ao B14 um estado de falha acionável) e `status === 'error'` segue reservado à falha da malha municipal. A ressalva de aproximação saiu do gate `!comparisonActive` e passou a ser um `<p>` condicionado a **haver ZE no escopo**. A primeira escrita derivava isso no cliente com um `isZoneMapKey` (um município é keyed por 7 dígitos; uma zona, pelo slug) e o `/simplify` a **rejeitou**: era uma segunda codificação da regra de `mapKeyForMunicipality`, agora em espaço negativo (qualquer chave sem 7 dígitos responde "zona"), e o servidor já conhece a resposta por `kind`. O gate é `bundle.hasZoneMunicipalities`, campo próprio do contrato preenchido no mesmo passe que monta `zoneBreakdown` — e **não** `zoneBreakdown.length > 0`, que recriaria justamente o acoplamento que o fill-in [remover-lista-zonas-mapa-inicio.md](remover-lista-zonas-mapa-inicio.md) vai deletar; assim ele só apaga o `<ul>` + o campo do bundle.
### F4 — deletado, colapsado e **um item rejeitado**
Saíram `getTerritoryFeature` e `getMunicipalityZoneFeature` (os int specs montam o índice em uma linha). `MunicipalityMapNavigation` virou `string | undefined` e `MunicipalitiesByMapKey` virou `Record<string, string>` — com o tipo, morreram o helper, o `useMemo` que só existia porque o helper alocava por chamada, o `| null` do `MapFeatureReadout` e 435 strings no payload RSC de toda visita ao Início. `polygonRingsOf` foi exportado (3º call site) e os helpers de geometria passaram a declarar o requisito real com `PolygonalFeature`, mais `BahiaFeature<P>` para as quatro grafias de `Feature<Polygon | MultiPolygon, P>` e `BahiaMeshFeature` para o que a camada do mapa recebe. As limpezas do script entraram (chave por alias num `Map` acima do passe 1, `polygonCount` sem branches inalcançáveis, `[await shp(buffer)].flat()`, o `url` que ninguém desestrutura, `push` em vez de copiar o array).
**Rejeitado por medição, contra o plano:** deletar `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry`. É verdade que nenhum runtime os lê, mas os dois entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017 — uma transcrição **independente** da resolução. Deletar enfraqueceria a verificação ou forçaria regerar um fixture de proveniência por ganho zero. **Ficaram**, e com eles o filtro `entry.city === 'Salvador'` no int. Só o `zoneNumber` do **artefato TopoJSON** saiu (F1 reescreveu o arquivo).
### O que continua verdade
Sem migration, collection, server action ou `Consent`. Contrato de URL, escalas e catálogo TRE inalterados. O e2e segue em 436 paths / 435 interativos (o não-interativo é a base municipal de Salvador). Os dois débitos de produto do B8 F2 (rank por ZE, ZE exata no "Onde estou") continuam abertos com gatilho no [plano do B8](poligonos-pracas-zona.md), e a decisão sobre o modo `territory` morto do `loadLayerFeatures` continua pertencendo ao **E12**.
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A) — o lote é custo, coerência e honestidade de dados já entregues pelo B8 F2. F3 muda **um parágrafo** (a ressalva de aproximação, que hoje desaparece no modo comparação enquanto a malha aproximada continua pintada), não a métrica nem a forma.
@ -46,10 +88,10 @@ A comparação que fecha o caso: **Salvador é um `Polygon` de 35 pontos na malh
- **A ressalva de aproximação pertence à malha, não à lista.** O parágrafo "é aproximado, não é o limite oficial do TSE" estava dentro do gate `bundle.zoneBreakdown.length > 0 && !comparisonActive`, mas o mapa divergente da comparação pinta Salvador zona por zona a partir da **mesma** malha aproximada. A ressalva foi pendurada na condição de render da lista em vez de na presença da malha na tela — e o rabbit hole que o próprio plano do B8 nomeia é "tratar polígono derivado como limite oficial". **Rejeitado:** duplicar a frase nos dois braços (duas cópias da mesma afirmação jurídica divergem na primeira revisão de copy). **Atualização 2026-07-26:** o fill-in [remover-lista-zonas-mapa-inicio.md](remover-lista-zonas-mapa-inicio.md) remove a lista `zoneBreakdown` inteira (pedido de produto pós-B8 F2). F3 **não** presupõe o `<ul>`: quem landar primeiro coloca a ressalva atrelada ao escopo com ZE / presença da malha; o outro só ajusta.
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
## Questões em aberto
## Questões em aberto (resolvidas na entrega)
- **Qual resolução de chão para as duas malhas?** **Opções:** (a) ~100 m nas duas, igualando a malha municipal de hoje (`QUANTIZE_DIGITS ≈ 380` nas zonas); (b) ~25 m nas zonas, aceitando nitidez até ~zoom 14 (`≈ 1500`), e 100 m no estado; (c) ~25 m nas duas (encarece a malha estadual, que é a que todo mundo baixa). **Recomendação:** (b) — o zoom útil de uma ZE é maior que o de um município do interior, e mesmo assim corta a densidade atual várias vezes; (a) é o corte máximo e vale se a medição pós-mudança mostrar que 1500 ainda dobra a camada.
- **Deletar os dois acessores órfãos ou declará-los contrato do módulo?** **Opções:** (a) deletar `getMunicipalityZoneFeature` e `getTerritoryFeature`, com os specs montando o índice em uma linha; (b) manter e documentar como contrato uniforme dos três módulos de malha. **Recomendação:** (a) — o `getTerritoryFeature` perdeu o último consumidor de produção **neste commit** (quando `fitMapToHighlights` parou de consultar os módulos de geometria) e o knip não vê nenhum dos dois porque specs são entry points; a regra do repo é deletar o que a mudança orfanou. Se o produto quiser o contrato uniforme, ele volta de graça no 4º mesh.
- **Qual resolução de chão para as duas malhas?** ~~Opções (a) ~100 m nas duas · (b) ~25 m nas zonas · (c) ~25 m nas duas.~~ **Resolvido: (a), ~100 m nas duas** (decisão do usuário na sessão de implementação), e a pergunta estava mal-posta: a **grade** é a mesma nas três malhas, mas o que controla densidade é a **tolerância de simplificação**, que é por malha. Ver "Como foi entregue → F1" — a tolerância municipal aplicada às zonas custaria 5% da área de Salvador, então elas têm a própria, declarada em m² ao lado das irmãs.
- **Deletar os dois acessores órfãos ou declará-los contrato do módulo?** **Resolvido: (a), deletados.** Os int specs montam o índice em uma linha. Se o produto quiser o contrato uniforme, ele volta de graça no 4º mesh.
## Abordagem proposta

View file

@ -138,7 +138,7 @@ 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.
- **Reusar a navegação do mapa no branch `inScope|zoneCity|outOfScope`.** Revisitar quando: um 3º chamador precisar do `zoneCount`/da entrada casada. Encerrado como reuso possível pelo **B8+** F4: `resolveMunicipalityMapNavigation` e sua união de estados morreram — o mapa lê `municipalitiesByMapKey[key]` direto, um `Record<string, string>` que não carrega nada do que este card precisa.
- **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

File diff suppressed because one or more lines are too long

View file

@ -26,9 +26,14 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { merge, quantize } from 'topojson-client'
import { merge } from 'topojson-client'
import { topology } from 'topojson-server'
import { presimplify, quantile, simplify } from 'topojson-simplify'
import {
describeGroundScale,
SIMPLIFY_TOLERANCE_M2,
simplifyToGroundScale,
} from './lib/topology.mjs'
const { canonicalizeMunicipalityName, UnknownMunicipalityError } =
await import('../src/lib/electionResults.ts')
@ -51,11 +56,6 @@ const SOURCES = {
},
}
/** Quantile of triangle area kept after presimplify (higher = more detail). */
const SIMPLIFY_QUANTILE = 0.35
/** Quantization precision for the final topology. */
const QUANTIZE_DIGITS = 1e4
const die = (message) => {
console.error(`\n[build:geometries] ${message}\n`)
process.exit(1)
@ -93,13 +93,6 @@ const writeJson = async (relativePath, value) => {
console.log(`[build:geometries] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
}
const simplifyTopology = (topo) => {
let next = presimplify(topo)
const minWeight = quantile(next, SIMPLIFY_QUANTILE)
next = simplify(next, minWeight)
return quantize(next, QUANTIZE_DIGITS)
}
const buildMunicipalityCodesModule = (entries, provenance) => {
const lines = entries
.map(([name, code]) => ` ${JSON.stringify(name)}: ${JSON.stringify(code)},`)
@ -209,7 +202,11 @@ const main = async () => {
let municipalityTopo = topology({
municipalities: { type: 'FeatureCollection', features: municipalityFeatures },
})
municipalityTopo = simplifyTopology(municipalityTopo)
municipalityTopo = simplifyToGroundScale(
municipalityTopo,
SIMPLIFY_TOLERANCE_M2.bahiaMunicipalities,
)
console.log(`[build:geometries] ${describeGroundScale(municipalityTopo)}`)
const geometriesByCodarea = new Map(
municipalityTopo.objects.municipalities.geometries.map((geometry) => [
@ -236,7 +233,11 @@ const main = async () => {
let territoryTopo = topology({
territories: { type: 'FeatureCollection', features: territoryFeatures },
})
territoryTopo = simplifyTopology(territoryTopo)
territoryTopo = simplifyToGroundScale(
territoryTopo,
SIMPLIFY_TOLERANCE_M2.bahiaIdentityTerritories,
)
console.log(`[build:geometries] ${describeGroundScale(territoryTopo)}`)
const accessed = new Date().toISOString().slice(0, 10)
const provenance = {

View file

@ -30,9 +30,15 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import shp from 'shpjs'
import { merge, quantize } from 'topojson-client'
import { merge } from 'topojson-client'
import { topology } from 'topojson-server'
import { presimplify, quantile, simplify } from 'topojson-simplify'
import {
describeGroundScale,
quantizeToGroundGrid,
SIMPLIFY_TOLERANCE_M2,
simplifyToGroundScale,
} from './lib/topology.mjs'
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityZoneNeighborhoods } =
@ -49,11 +55,6 @@ const SOURCE = {
const OUTPUT_PATH = 'src/lib/geometries/bahia-municipality-zones.topo.json'
/** Quantile of triangle area kept after presimplify (higher = more detail). */
const SIMPLIFY_QUANTILE = 0.35
/** Quantization precision for the final topology. */
const QUANTIZE_DIGITS = 1e4
/**
* Same neighborhood, different name in the two sources — IBGE composites
* ("Beiru / Tancredo Neves") and spellings the accent folding cannot reach.
@ -116,7 +117,7 @@ const ensureCachedBinary = async ({ key, url, ext }) => {
await access(path)
log(`cache hit ${path}`)
const buffer = await readFile(path)
return { url, hash: sha256(buffer), buffer }
return { hash: sha256(buffer), buffer }
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
@ -125,7 +126,7 @@ const ensureCachedBinary = async ({ key, url, ext }) => {
const hash = sha256(buffer)
await writeFile(path, buffer)
log(`saved ${path} (${buffer.length} bytes, sha256=${hash})`)
return { url, hash, buffer }
return { hash, buffer }
}
const writeJson = async (relativePath, value) => {
@ -136,13 +137,6 @@ const writeJson = async (relativePath, value) => {
log(`wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
}
const simplifyTopology = (topo) => {
let next = presimplify(topo)
const minWeight = quantile(next, SIMPLIFY_QUANTILE)
next = simplify(next, minWeight)
return quantize(next, QUANTIZE_DIGITS)
}
/** Accent-folded, punctuation-free key; parentheses (the TRE's split notes) drop. */
const normalizeNeighborhood = (name) =>
name
@ -153,9 +147,6 @@ const normalizeNeighborhood = (name) =>
.replace(/[^A-Z0-9]+/g, ' ')
.trim()
const polygonCount = (geometry) =>
geometry?.type === 'MultiPolygon' ? geometry.coordinates.length : geometry ? 1 : 0
const arcIndexes = (arcs, into = new Set()) => {
for (const item of arcs) {
if (Array.isArray(item)) arcIndexes(item, into)
@ -270,12 +261,22 @@ const main = async () => {
})
const geometries = neighborhoodTopology.objects.neighborhoods.geometries
// The TRE key each polygon resolves to once aliases are applied. Resolved once
// because the assignment pass and the coverage check below used to derive it
// separately with the same fallback: the `die` guard is built on the second, so
// a divergence between the two would let it pass over a wrong assignment.
const treKeyByGeometry = new Map(
geometries.map((geometry) => {
const key = normalizeNeighborhood(geometry.properties.name)
return [geometry, aliasTargets.get(key) ?? key]
}),
)
// Pass 1 — the resolution's own names.
const zoneSlugByGeometry = new Map()
const unnamed = []
for (const geometry of geometries) {
const key = normalizeNeighborhood(geometry.properties.name)
const slug = zoneSlugByNeighborhood.get(aliasTargets.get(key) ?? key)
const slug = zoneSlugByNeighborhood.get(treKeyByGeometry.get(geometry))
if (slug) zoneSlugByGeometry.set(geometry, slug)
else unnamed.push(geometry)
}
@ -300,12 +301,7 @@ const main = async () => {
)
}
const coveredKeys = new Set(
geometries.map((geometry) => {
const key = normalizeNeighborhood(geometry.properties.name)
return aliasTargets.get(key) ?? key
}),
)
const coveredKeys = new Set(treKeyByGeometry.values())
for (const hostName of Object.values(TRE_NEIGHBORHOODS_MERGED_INTO)) {
if (!coveredKeys.has(normalizeNeighborhood(hostName))) continue
for (const [treName, host] of Object.entries(TRE_NEIGHBORHOODS_MERGED_INTO)) {
@ -320,7 +316,9 @@ const main = async () => {
const geometriesByZone = new Map()
for (const [geometry, slug] of zoneSlugByGeometry) {
geometriesByZone.set(slug, [...(geometriesByZone.get(slug) ?? []), geometry])
const bucket = geometriesByZone.get(slug)
if (bucket) bucket.push(geometry)
else geometriesByZone.set(slug, [geometry])
}
log(
`assigned ${zoneSlugByGeometry.size}/${geometries.length} polygons to ${geometriesByZone.size}/${zoneEntries.length} zones`,
@ -353,7 +351,10 @@ const main = async () => {
// Simplification keeps geometry order and properties, so the zone buckets
// above still address the right shapes.
const simplified = simplifyTopology(neighborhoodTopology)
const simplified = simplifyToGroundScale(
neighborhoodTopology,
SIMPLIFY_TOLERANCE_M2.bahiaMunicipalityZones,
)
const simplifiedById = new Map(
simplified.objects.neighborhoods.geometries.map((geometry) => [
geometry.properties.id,
@ -379,7 +380,6 @@ const main = async () => {
properties: {
municipalitySlug: entry.slug,
name: entry.name,
zoneNumber: entry.zoneNumber,
ibgeCode: entry.ibgeCode,
},
geometry,
@ -388,16 +388,15 @@ const main = async () => {
// Already simplified above — the final pass only quantizes, so the artifact
// stores short integers instead of the floats `merge` hands back.
await writeJson(
OUTPUT_PATH,
quantize(
topology({ municipalityZones: { type: 'FeatureCollection', features: zoneFeatures } }),
QUANTIZE_DIGITS,
),
const zoneTopology = quantizeToGroundGrid(
topology({ municipalityZones: { type: 'FeatureCollection', features: zoneFeatures } }),
)
log(describeGroundScale(zoneTopology))
await writeJson(OUTPUT_PATH, zoneTopology)
for (const feature of zoneFeatures) {
const pieces = polygonCount(feature.geometry)
// `merge` always hands back a MultiPolygon, so this is the piece count.
const pieces = feature.geometry.coordinates.length
log(
`${feature.properties.municipalitySlug}: ${
geometriesByZone.get(feature.properties.municipalitySlug).length

123
scripts/lib/topology.mjs Normal file
View file

@ -0,0 +1,123 @@
/**
* Ground-scale policy shared by the committed Bahia TopoJSON meshes.
*
* Every mesh in `src/lib/geometries/` is simplified and quantized before it is
* committed, and the two knobs that control that only mean something in ground
* units:
*
* - **Simplification tolerance** — Visvalingam drops a vertex whose effective
* triangle is smaller than a threshold in square degrees. `topojson-simplify`
* can pick that threshold as a *quantile* of the topology's own triangles,
* which is what both build scripts used to do with a copied `0.35`. A quantile
* is relative to the mesh: the same `0.35` cut Salvador's 19 neighbourhood-
* derived zones at ~0.6 m² while it cut the 417 municipalities at ~1.78 km².
* This is the dominant lever, and it is why the zone mesh shipped at 480 points
* per feature against the state mesh's 21.9.
* - **Quantization digits** — `topojson-client`'s `quantize(topo, n)` lays an
* `n`-step grid across *the topology's own bbox*, so a copied `1e4` means
* ~103 m across Bahia and ~3.9 m across Salvador: a 26× finer grid step nobody
* asked for. Measured, it is the minor knob — putting the zones on the state's
* ~103 m grid alone took them from 480 points per feature to 203, and only the
* absolute tolerance below reached 30.2.
*
* So the policy is declared here in ground units and the bbox-relative numbers
* are derived per topology. Tolerances are per mesh *on purpose* — a tolerance
* is a statement about the size of the shapes being drawn, and a Salvador zone
* (~16 km²) is three orders of magnitude smaller than a Bahia municipality
* (~1,340 km²) — but they now sit side by side in ground units, so divergence
* is a visible decision instead of an accident of a copied constant.
*/
import { quantize } from 'topojson-client'
import { presimplify, simplify } from 'topojson-simplify'
/**
* Metres per degree at the equator. Bahia spans ~9°–18° S, where a degree of
* longitude is 5–8% shorter, so every metre here is a nominal metre — good
* enough to size a tolerance, not a distance measurement.
*/
const METRES_PER_DEGREE = 111_320
const SQUARE_METRES_PER_SQUARE_DEGREE = METRES_PER_DEGREE * METRES_PER_DEGREE
/**
* Ground resolution of the committed meshes. Chosen as the step the state mesh
* has always had (`1e4` digits over its 9.2357° bbox), so honoring the policy
* leaves `bahia-municipalities.topo.json` byte-identical.
*/
const GROUND_GRID_METRES = 102.82
/**
* Simplification tolerance per mesh, as the ground area of the smallest
* triangle kept. Each value is followed by the density it buys, measured — the
* tripwire in `tests/int/bahiaGeometries.int.spec.ts` asserts points per
* feature, so a mesh that silently goes back to a relative cut fails there.
*/
export const SIMPLIFY_TOLERANCE_M2 = {
/** 1.78 km² → 21.9 points/feature. The state mesh's historical cut. */
bahiaMunicipalities: 1_776_383,
/** 13.6 km² → 44.8 points/feature. Dissolved from the mesh above, so these
* outlines already carry the municipal cut and anything at or below it is
* close to a no-op; 13.6 km² is a deliberate step coarser, sized to shapes
* ~15× larger. */
bahiaIdentityTerritories: 13_591_527,
/** 0.06 km² → 30.2 points/feature. Sized to Salvador's zones rather than to
* Bahia's municipalities: the municipal tolerance does reach 17.9
* points/feature here, but it also sheds 5% of the city's land area
* (306 → 291 km²), which is the quantity the int spec uses to prove no
* neighbourhood was dropped or double-counted. */
bahiaMunicipalityZones: 60_000,
}
/**
* Quantization digits that lay `GROUND_GRID_METRES` across this topology's own
* bbox. `quantize` derives its scale from `n - 1` steps, so the requested step
* is honored to within one grid cell.
*
* `quantize` applies that single digit count to BOTH axes, so the step is only
* exact on the axis it is derived from — longitude here — and latitude follows
* the bbox aspect ratio: measured, 102.8×109.2 m for the two statewide meshes
* and 102.9×80.1 m for Salvador's. Anchoring on X is what keeps
* `bahia-municipalities.topo.json` byte-identical to its pre-policy build;
* making both axes exact would mean passing an explicit `{scale, translate}`
* transform and rewriting all three artifacts. `describeGroundScale` prints
* both steps, so the asymmetry is visible in every build log.
*/
const quantizeDigitsFor = (topo, gridMetres) => {
const [minX, , maxX] = topo.bbox
return Math.round((maxX - minX) / (gridMetres / METRES_PER_DEGREE)) + 1
}
/**
* Simplify to a ground tolerance and quantize to the shared ground grid.
*
* Order matters: quantizing a dense mesh to a ~100 m grid before simplifying it
* collapses vertices into duplicates, and a later `merge` of such a topology
* produces broken rings (measured: Salvador's 307 km² dissolved to 24 km²).
*/
export const simplifyToGroundScale = (topo, toleranceM2) => {
const minWeight = toleranceM2 / SQUARE_METRES_PER_SQUARE_DEGREE
return quantizeToGroundGrid(simplify(presimplify(topo), minWeight))
}
/** Quantize an already-simplified topology to the shared ground grid. */
export const quantizeToGroundGrid = (topo) =>
quantize(topo, quantizeDigitsFor(topo, GROUND_GRID_METRES))
/**
* One line describing what a topology actually landed on, so a rebuild reports
* the ground scale instead of leaving it to be re-derived from the artifact.
* Every topology here holds exactly one object (the int spec pins that), so the
* name is read off it rather than passed in twice.
*/
export const describeGroundScale = (topo) => {
const [name, object] = Object.entries(topo.objects)[0]
const arcPoints = topo.arcs.reduce((total, arc) => total + arc.length, 0)
const features = object.geometries.length
const stepMetres = topo.transform.scale.map((scale) => scale * METRES_PER_DEGREE)
return (
`${name}: ${features} features, ${arcPoints} arc points ` +
`(${(arcPoints / features).toFixed(1)}/feature), ` +
`grid ${stepMetres.map((step) => step.toFixed(1)).join('×')} m`
)
}

View file

@ -12,7 +12,7 @@ import {
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes'
import type { BahiaMeshFeature } from '@/lib/bahiaGeometriesTypes'
import {
bubbleRadius,
buildHighlightSet,
@ -61,6 +61,13 @@ const applyBahiaMinZoom = (map: L.Map) => {
map.setMinZoom(zoom)
}
/**
* `territory` has no caller today — B21 moved the Identity Territories to their
* own page and `MunicipalityMapPanel` hardcodes `municipality`. It is kept
* because E12 registered the TI map mode as a trigger, not because something
* still reads it; `bahiaTerritoryGeometries` and its ~15 KB artifact are alive
* only through that decision and the int spec.
*/
export type BahiaMapMode = 'municipality' | 'territory'
/**
@ -72,7 +79,7 @@ export type BahiaMapMode = 'municipality' | 'territory'
*/
const loadLayerFeatures = async (
mode: BahiaMapMode,
): Promise<{ features: BahiaGeometryFeature[]; baseKeys: Set<string> }> => {
): Promise<{ features: BahiaMeshFeature[]; baseKeys: Set<string> }> => {
if (mode === 'territory') {
const { features } = await loadTerritoryGeometryModule()
return { features: [...features], baseKeys: new Set() }
@ -80,12 +87,24 @@ const loadLayerFeatures = async (
const [municipalities, zones] = await Promise.all([
loadMunicipalityGeometryModule(),
loadMunicipalityZoneGeometryModule(),
// B8+ F3 — the state mesh is the map; the zone mesh is a detail on top of it.
// If it fails to arrive, Salvador falls back to the base polygon it already
// has underneath: drawn, but with no value and no pointer, since the bundle
// is keyed by zone slug and the city code is not a scoped key. Every other
// município keeps working instead of the whole panel erroring out. A network
// ChunkLoadError is the expected failure and needs no report; a decode error
// or a renamed topology object is a bug, so the reason is logged either way.
loadMunicipalityZoneGeometryModule().catch((error: unknown) => {
console.error('[map] malha das zonas indisponível; Bahia sem as ZE de Salvador', error)
return null
}),
])
const zoneFeatures = zones?.features ?? []
return {
features: [...municipalities.features, ...zones.features],
baseKeys: new Set(zones.features.map((zone) => zone.properties.ibgeCode)),
features: [...municipalities.features, ...zoneFeatures],
baseKeys: new Set(zoneFeatures.map((zone) => zone.properties.ibgeCode)),
}
}

View file

@ -4,7 +4,6 @@ import type { BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionFormat'
import type { MunicipalityMapScaleMode } from '@/utilities/municipalityMapContract'
import type { MunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
type MapFeatureReadoutProps = {
feature: BahiaMapFeatureInfo | null
@ -21,7 +20,8 @@ type MapFeatureReadoutProps = {
/** What the bubble encodes for THIS município — only while the layer is on. */
bubbleReading: string | null
comparisonActive: boolean
navigation: MunicipalityMapNavigation | null
/** The município the painted feature opens, when it is one the actor can read. */
municipalitySlug: string | undefined
}
const percentFormatter = new Intl.NumberFormat('pt-BR', {
@ -52,7 +52,7 @@ export const MapFeatureReadout = ({
relativeReading,
bubbleReading,
comparisonActive,
navigation,
municipalitySlug,
}: MapFeatureReadoutProps) => {
if (!feature) {
return (
@ -105,9 +105,9 @@ export const MapFeatureReadout = ({
{bubbleReading ? <p className="text-xs text-muted-foreground">{bubbleReading}</p> : null}
</div>
{navigation?.kind === 'navigate' ? (
{municipalitySlug ? (
<Button asChild className="min-h-11 shrink-0">
<Link href={`/campanha/municipios/${navigation.slug}`}>Abrir município</Link>
<Link href={`/campanha/municipios/${municipalitySlug}`}>Abrir município</Link>
</Button>
) : null}
</div>

View file

@ -60,7 +60,6 @@ import {
type MunicipalityMapScaleMode,
type MunicipalityMapYear,
} from '@/utilities/municipalityMapContract'
import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/** The legend's note doubles as the scale selector's description. */
@ -590,15 +589,20 @@ export const MunicipalityMapPanel = ({
}
/>
{/* zone breakdown below */}
{/* B8+ F3 — tied to the MESH, not to the list below: the comparison mode
paints Salvador from the same approximate polygons, so the caveat has
to survive with or without comparison. */}
{bundle.hasZoneMunicipalities ? (
<p className="text-sm text-muted-foreground">
Salvador é pintada zona por zona. O desenho vem dos bairros de cada circunscrição
(TRE-BA, RA nº 2/2017) sobre a malha do IBGE — é aproximado, não é o limite oficial do
TSE.
</p>
) : null}
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Municípios por zona eleitoral</h3>
<p className="text-sm text-muted-foreground">
Salvador é pintada zona por zona. O desenho vem dos bairros de cada circunscrição
(TRE-BA, RA nº 2/2017) sobre a malha do IBGE — é aproximado, não é o limite oficial do
TSE.
</p>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
<li
@ -677,10 +681,10 @@ const MunicipalityMapSelection = ({
(key: string) => {
if (selectedKeyRef.current !== key) return
const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByMapKey)
if (navigation.kind === 'navigate') {
router.push(`/campanha/municipios/${navigation.slug}`)
}
// Annotated because `noUncheckedIndexedAccess` is off: the index is
// genuinely partial — a painted key outside the actor's scope has no slug.
const slug: string | undefined = municipalitiesByMapKey[key]
if (slug) router.push(`/campanha/municipios/${slug}`)
},
[municipalitiesByMapKey, router],
)
@ -693,13 +697,9 @@ const MunicipalityMapSelection = ({
const selectedRawMetricValue =
selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined
const selectedNavigation = useMemo(
() =>
selectedFeature
? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByMapKey)
: null,
[municipalitiesByMapKey, selectedFeature],
)
const selectedSlug: string | undefined = selectedFeature
? municipalitiesByMapKey[selectedFeature.key]
: undefined
return (
<>
@ -728,7 +728,7 @@ const MunicipalityMapSelection = ({
relativeReading={selectedFeature ? relativeReadingFor(selectedFeature.key) : null}
bubbleReading={selectedFeature ? bubbleReadingFor(selectedFeature.key) : null}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
municipalitySlug={selectedSlug}
/>
</>
)

View file

@ -1,6 +1,9 @@
import type { Feature, GeoJsonProperties, MultiPolygon, Polygon } from 'geojson'
import type { Feature, MultiPolygon, Polygon } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
/** Every committed mesh is polygonal; only the properties differ. */
type BahiaFeature<Properties> = Feature<Polygon | MultiPolygon, Properties>
type BahiaMunicipalityProperties = {
codarea: string
name: string
@ -18,18 +21,27 @@ type BahiaTerritoryProperties = {
type MunicipalityZoneProperties = {
municipalitySlug: string
name: string
zoneNumber: number
ibgeCode: string
}
export type BahiaMunicipalityFeature = Feature<Polygon | MultiPolygon, BahiaMunicipalityProperties>
export type BahiaMunicipalityFeature = BahiaFeature<BahiaMunicipalityProperties>
export type BahiaTerritoryFeature = Feature<Polygon | MultiPolygon, BahiaTerritoryProperties>
export type BahiaTerritoryFeature = BahiaFeature<BahiaTerritoryProperties>
export type MunicipalityZoneFeature = Feature<Polygon | MultiPolygon, MunicipalityZoneProperties>
export type MunicipalityZoneFeature = BahiaFeature<MunicipalityZoneProperties>
/** Any feature of the committed meshes — for helpers that only read coordinates. */
export type BahiaGeometryFeature = Feature<Polygon | MultiPolygon, GeoJsonProperties>
/** Any feature of the committed meshes, as the map layer receives them. */
export type BahiaMeshFeature =
| BahiaMunicipalityFeature
| BahiaTerritoryFeature
| MunicipalityZoneFeature
/**
* What the geometry helpers actually need — a polygonal geometry, nothing else.
* Declaring the requirement instead of a feature union keeps `GeoJsonProperties`
* (which is `any`) out of the signature of a helper that only reads coordinates.
*/
export type PolygonalFeature = { geometry: Polygon | MultiPolygon }
export type MunicipalityTopology = Topology<{
municipalities: GeometryCollection<BahiaMunicipalityProperties>
@ -52,11 +64,9 @@ export type MunicipalityGeometryModule = {
export type TerritoryGeometryModule = {
topology: TerritoryTopology
features: readonly BahiaTerritoryFeature[]
getTerritoryFeature: (code: string) => BahiaTerritoryFeature | undefined
}
export type MunicipalityZoneGeometryModule = {
topology: MunicipalityZoneTopology
features: readonly MunicipalityZoneFeature[]
getMunicipalityZoneFeature: (municipalitySlug: string) => MunicipalityZoneFeature | undefined
}

View file

@ -12,7 +12,8 @@ export type ChoroplethValues = Record<string, number>
export type BahiaMapFillMode = 'sequential' | 'diverging'
/** Properties that carry a map key, most specific first (see `featureMapKey`). */
/** The three names a map key can arrive under — mutually exclusive on the
* committed meshes, so the order is incidental (see `featureMapKey`). */
const MAP_KEY_PROPERTIES = ['municipalitySlug', 'codarea', 'code'] as const
/**
@ -96,8 +97,12 @@ export const canonicalMapKeysKey = (keys: string[]): string =>
* The key a feature is painted and addressed by, whichever mesh it came from:
* a zone municipality carries `municipalitySlug` (B8 F2 — the whole city shares
* one codarea, so the code cannot identify it), a município `codarea`, an
* identity territory `code`. The three never coexist on one feature, so no mode
* argument is needed.
* identity territory `code`. Exactly one of the three is present on any feature
* of the committed meshes, so this is a lookup over mutually exclusive names and
* not a precedence rule — no mode argument is needed. The zone mesh does also
* carry the city's `ibgeCode`, which is deliberately not a key property: all 19
* zones share it. Pinned against the real meshes in
* `tests/int/bahiaGeometries.int.spec.ts`.
*/
export const featureMapKey = (
properties: Record<string, unknown> | undefined | null,

View file

@ -12,12 +12,7 @@ const topology = municipalityZoneTopologyJson as unknown as MunicipalityZoneTopo
const municipalityZoneFeatures = feature(topology, topology.objects.municipalityZones)
.features as MunicipalityZoneFeature[]
const zoneBySlug = new Map(
municipalityZoneFeatures.map((entry) => [entry.properties.municipalitySlug, entry]),
)
export const municipalityZoneGeometryModule: MunicipalityZoneGeometryModule = {
topology,
features: municipalityZoneFeatures,
getMunicipalityZoneFeature: (municipalitySlug: string) => zoneBySlug.get(municipalitySlug),
}

View file

@ -12,10 +12,7 @@ const topology = territoryTopologyJson as unknown as TerritoryTopology
const territoryFeatures = feature(topology, topology.objects.territories)
.features as BahiaTerritoryFeature[]
const territoryByCode = new Map(territoryFeatures.map((entry) => [entry.properties.code, entry]))
export const territoryGeometryModule: TerritoryGeometryModule = {
topology,
features: territoryFeatures,
getTerritoryFeature: (code: string) => territoryByCode.get(code),
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,9 @@
import type { Position } from 'geojson'
import type {
BahiaGeometryFeature,
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
PolygonalFeature,
} from '@/lib/bahiaGeometriesTypes'
import { oneDecimalFormatter } from '@/lib/electionFormat'
@ -109,7 +109,8 @@ const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => {
return inside
}
const polygonRingsOf = (feature: BahiaGeometryFeature): readonly Position[][][] =>
/** Outer ring + holes per polygon, so callers stop re-writing the Polygon/MultiPolygon branch. */
export const polygonRingsOf = (feature: PolygonalFeature): readonly Position[][][] =>
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
@ -118,7 +119,7 @@ const polygonRingsOf = (feature: BahiaGeometryFeature): readonly Position[][][]
* Holes and MultiPolygons do not occur in the committed mesh (417 plain
* Polygons), but a rebuild at finer quality would introduce them.
*/
export const featureContainsPoint = (feature: BahiaGeometryFeature, point: GeoPoint): boolean =>
export const featureContainsPoint = (feature: PolygonalFeature, point: GeoPoint): boolean =>
polygonRingsOf(feature).some(([outerRing, ...holes]) => {
if (!outerRing || !isPointInRing(point, outerRing)) return false
return !holes.some((hole) => isPointInRing(point, hole))
@ -129,7 +130,7 @@ export const featureContainsPoint = (feature: BahiaGeometryFeature, point: GeoPo
* at Bahia's latitudes is far below the precision this feeds). Holes are
* ignored: they move the centre by less than the rounding of the label.
*/
export const featureCentroid = (feature: BahiaGeometryFeature): GeoPoint => {
export const featureCentroid = (feature: PolygonalFeature): GeoPoint => {
let weightedLng = 0
let weightedLat = 0
let totalArea = 0

View file

@ -125,6 +125,16 @@ export type MunicipalityMapBundle = {
projectedValidVotesByMapKey: Record<string, number>
/** map key → the municipality it opens on click. */
municipalitiesByMapKey: MunicipalitiesByMapKey
/**
* Whether the actor's scope includes a zone municipality, i.e. whether the
* approximate ZE mesh is painted at all — which is what the map's provenance
* caveat is about. Published rather than re-derived on the client so `kind`
* stays the single answer to "is this a zona": reading it off the key's shape
* would be a second encoding of the rule in `mapKeyForMunicipality`, and
* `zoneBreakdown` is scheduled for deletion by the fill-in that removes the
* textual zone list.
*/
hasZoneMunicipalities: boolean
/** Zone municipalities in scope (Salvador) with per-year values. */
zoneBreakdown: MunicipalityZoneBreakdownRow[]
candidateName: string

View file

@ -155,11 +155,9 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const territorialClassByMapKey: Record<string, MunicipalityTerritorialClass> = {}
const projectedValidVotesByMapKey: Record<string, number> = {}
for (const [mapKey, entry] of Object.entries(municipalitiesByMapKey)) {
territorialClassByMapKey[mapKey] = computeMunicipalityTerritorialClass(entry.slug).class
projectedValidVotesByMapKey[mapKey] = projectedValidVotes(
getMunicipalityFederalBaseline(entry.slug),
)
for (const [mapKey, slug] of Object.entries(municipalitiesByMapKey)) {
territorialClassByMapKey[mapKey] = computeMunicipalityTerritorialClass(slug).class
projectedValidVotesByMapKey[mapKey] = projectedValidVotes(getMunicipalityFederalBaseline(slug))
}
const pledgeValuesByScenario = Object.fromEntries(
@ -264,6 +262,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
competitiveRankByYear,
projectedValidVotesByMapKey,
municipalitiesByMapKey,
hasZoneMunicipalities: municipalities.some((municipality) => municipality.kind === 'zona'),
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,

View file

@ -9,13 +9,13 @@ type MunicipalityMapSlugEntry = {
export type MunicipalitiesByIbgeCode = Record<string, MunicipalityMapSlugEntry[]>
/**
* Map key → the single unit it paints. Since B8 F2 the map draws one polygon per
* catalog unit — zone municipalities by slug, everything else by IBGE codarea —
* so this index is 1:1 where `MunicipalitiesByIbgeCode` is 1:N.
* Map key → the slug of the single unit it paints. Since B8 F2 the map draws one
* polygon per catalog unit — zone municipalities by slug, everything else by IBGE
* codarea — so this index is 1:1 where `MunicipalitiesByIbgeCode` is 1:N, and the
* name it used to carry alongside was 435 dead strings in the RSC payload of
* every visit to Início (the readout takes the name from the feature it painted).
*/
export type MunicipalitiesByMapKey = Record<string, MunicipalityMapSlugEntry>
export type MunicipalityMapNavigation = { kind: 'none' } | { kind: 'navigate'; slug: string }
export type MunicipalitiesByMapKey = Record<string, string>
/**
* What the choropleth paints a unit by: a zone municipality has no code of its
@ -52,19 +52,8 @@ export const buildMunicipalitiesByMapKey = (
const byMapKey: MunicipalitiesByMapKey = {}
for (const municipality of municipalities) {
byMapKey[mapKeyForMunicipality(municipality)] = {
slug: municipality.slug,
name: municipality.name,
}
byMapKey[mapKeyForMunicipality(municipality)] = municipality.slug
}
return byMapKey
}
export const resolveMunicipalityMapNavigation = (
mapKey: string,
municipalitiesByMapKey: MunicipalitiesByMapKey,
): MunicipalityMapNavigation => {
const entry = municipalitiesByMapKey[mapKey]
return entry ? { kind: 'navigate', slug: entry.slug } : { kind: 'none' }
}

View file

@ -1,4 +1,5 @@
import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js'
import type { PolygonalFeature } from '../../src/lib/bahiaGeometriesTypes.js'
import { polygonRingsOf } from '../../src/lib/municipalityProximity.js'
export type FeatureBounds = {
west: number
@ -7,17 +8,9 @@ export type FeatureBounds = {
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)
/** Bounding box of every ring of a mesh feature. Shared by the int and e2e suites. */
export const featureBounds = (feature: PolygonalFeature): FeatureBounds => {
const positions = polygonRingsOf(feature).flat(2)
const longitudes = positions.map(([lng]) => lng)
const latitudes = positions.map(([, lat]) => lat)

View file

@ -5,15 +5,17 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Position } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
import { describe, expect, it } from 'vitest'
import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes'
import type { PolygonalFeature } from '@/lib/bahiaGeometriesTypes'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { featureMapKey } from '@/lib/bahiaMapStyle'
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
@ -21,6 +23,7 @@ import {
featureCentroid,
featureContainsPoint,
findContainingMunicipality,
polygonRingsOf,
resolveNearbyMunicipality,
} from '@/lib/municipalityProximity'
@ -29,6 +32,22 @@ import { featureBounds } from '../helpers/featureBounds'
/** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */
const MAX_TOPO_BYTES = 600 * 1024
/**
* B8+ — the grandeza the ground-scale policy in `scripts/lib/topology.mjs`
* actually controls. Bytes only catch a mesh nobody simplified at all: the zone
* mesh shipped at 480 points/feature, 22× the state mesh, while sitting at 10% of
* the byte budget, because a simplification tolerance copied as a *quantile* of
* each mesh's own triangles cut Salvador's zones at ~0.6 m² and Bahia's
* municipalities at ~1.78 km². A mesh that goes back to a relative cut fails here.
*/
const MAX_POINTS_PER_FEATURE = 60
/** Arc points over features — sound because each mesh holds exactly one object. */
const pointsPerFeature = (topology: Topology<{ [key: string]: GeometryCollection }>): number => {
const arcPoints = topology.arcs.reduce((total, arc) => total + arc.length, 0)
return arcPoints / Object.values(topology.objects)[0].geometries.length
}
/** Square degrees → km² at Salvador's latitude — good to a few percent, which is all this needs. */
const SQUARE_DEGREE_KM2 = 110.57 * 108.5
@ -43,13 +62,8 @@ const hasNonEmptyGeometry = (
}
/** Planar shoelace in square degrees — only ever compared against itself. */
const featureArea = (feature: BahiaGeometryFeature): number => {
const polygons =
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
return polygons.reduce((total, [outerRing, ...holes]) => {
const featureArea = (feature: PolygonalFeature): number =>
polygonRingsOf(feature).reduce((total, [outerRing, ...holes]) => {
const ringArea = (ring: readonly Position[]): number => {
let sum = 0
for (
@ -66,7 +80,6 @@ const featureArea = (feature: BahiaGeometryFeature): number => {
total + ringArea(outerRing) - holes.reduce((holeTotal, hole) => holeTotal + ringArea(hole), 0)
)
}, 0)
}
describe('Bahia static geometries', () => {
it('keeps committed TopoJSON under the size budget', () => {
@ -82,12 +95,25 @@ describe('Bahia static geometries', () => {
expect(municipalityBytes).toBeGreaterThan(10_000)
expect(territoryBytes).toBeGreaterThan(1_000)
expect(zoneBytes).toBeGreaterThan(10_000)
expect(zoneBytes).toBeGreaterThan(1_000)
expect(municipalityBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(territoryBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(zoneBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
})
it('cuts every mesh at the shared ground scale, not at its own bbox', async () => {
const [municipalityModule, territoryModule, zoneModule] = await Promise.all([
loadMunicipalityGeometryModule(),
loadTerritoryGeometryModule(),
loadMunicipalityZoneGeometryModule(),
])
for (const { topology } of [municipalityModule, territoryModule, zoneModule]) {
const [objectName] = Object.keys(topology.objects)
expect(pointsPerFeature(topology), objectName).toBeLessThanOrEqual(MAX_POINTS_PER_FEATURE)
}
})
it('exposes 417 municipality features whose codarea set matches the code table', async () => {
const { topology, features, getMunicipalityFeature } = await loadMunicipalityGeometryModule()
@ -102,6 +128,9 @@ describe('Bahia static geometries', () => {
expect(entry.properties.codarea).toMatch(/^29\d{5}$/)
expect(bahiaMunicipalityCodes[entry.properties.name]).toBe(entry.properties.codarea)
expect(hasNonEmptyGeometry(entry.geometry)).toBe(true)
// B8+ F2 — the map key every one of these is painted and hovered by,
// resolved from the real properties instead of a synthetic literal.
expect(featureMapKey(entry.properties), entry.properties.name).toBe(entry.properties.codarea)
}
expect(getMunicipalityFeature('2927408')?.properties.name).toBe('Salvador')
@ -109,7 +138,7 @@ describe('Bahia static geometries', () => {
})
it('exposes 27 territory features whose codes match bahiaIdentityTerritoryRecords', async () => {
const { topology, features, getTerritoryFeature } = await loadTerritoryGeometryModule()
const { topology, features } = await loadTerritoryGeometryModule()
expect(topology.objects.territories.geometries).toHaveLength(27)
expect(features).toHaveLength(27)
@ -118,15 +147,19 @@ describe('Bahia static geometries', () => {
const recordCodes = bahiaIdentityTerritoryRecords.map((entry) => entry.code).sort()
expect(featureCodes).toEqual(recordCodes)
const territoryByCode = new Map(
features.map((entry) => [entry.properties.code, entry] as const),
)
for (const record of bahiaIdentityTerritoryRecords) {
const featureEntry = getTerritoryFeature(record.code)
const featureEntry = territoryByCode.get(record.code)
expect(featureEntry, record.code).toBeDefined()
expect(featureEntry?.properties.name).toBe(record.name)
expect(hasNonEmptyGeometry(featureEntry?.geometry)).toBe(true)
expect(featureMapKey(featureEntry?.properties), record.code).toBe(record.code)
}
expect(getTerritoryFeature('01')?.properties.name).toBe('Irecê')
expect(getTerritoryFeature('99')).toBeUndefined()
expect(territoryByCode.get('01')?.properties.name).toBe('Irecê')
})
/**
@ -139,23 +172,31 @@ describe('Bahia static geometries', () => {
const zoneCatalog = municipalityCatalog.filter((entry) => entry.kind === 'zona')
it('exposes one feature per zona municipality, keyed by catalog slug', async () => {
const { topology, features, getMunicipalityZoneFeature } =
await loadMunicipalityZoneGeometryModule()
const { topology, features } = await loadMunicipalityZoneGeometryModule()
expect(Object.keys(topology.objects)).toEqual(['municipalityZones'])
expect(topology.objects.municipalityZones.geometries).toHaveLength(zoneCatalog.length)
expect(features).toHaveLength(zoneCatalog.length)
const zoneBySlug = new Map(
features.map((zone) => [zone.properties.municipalitySlug, zone] as const),
)
for (const entry of zoneCatalog) {
const zone = getMunicipalityZoneFeature(entry.slug)
const zone = zoneBySlug.get(entry.slug)
expect(zone, entry.slug).toBeDefined()
expect(zone?.properties.name).toBe(entry.name)
expect(zone?.properties.zoneNumber).toBe(entry.zoneNumber)
expect(zone?.properties.ibgeCode).toBe(entry.ibgeCode)
expect(hasNonEmptyGeometry(zone?.geometry)).toBe(true)
// B8+ F2 — a zone answers its own slug even though it also carries the
// city's `ibgeCode`, which all 19 share and which is therefore not a key
// property. The municipal side of the contract is pinned over all 417
// features in the codarea test above.
expect(featureMapKey(zone?.properties), entry.slug).toBe(entry.slug)
}
expect(getMunicipalityZoneFeature('feira-de-santana')).toBeUndefined()
expect(zoneBySlug.get('feira-de-santana')).toBeUndefined()
})
it('keeps every zone inside the municipality it decomposes', async () => {

View file

@ -35,6 +35,7 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
expect(bundle).not.toBeNull()
expect(bundle!.zoneBreakdown).toHaveLength(ZONE_MUNICIPALITY_COUNT)
expect(bundle!.hasZoneMunicipalities).toBe(true)
const mapKeys = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(mapKeys.length).toBeGreaterThan(0)
@ -69,6 +70,8 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
expect(included!.zoneBreakdown.some((row) => row.slug === administered.slug)).toBe(
administered.kind === 'zona',
)
// The map's provenance caveat follows the actor's scope, not the list below it.
expect(included!.hasZoneMunicipalities).toBe(administered.kind === 'zona')
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([
administered.kind === 'zona' ? administered.slug : administered.ibgeCode,
])

View file

@ -31,10 +31,10 @@ describe('bahiaMapStyle', () => {
})
describe('featureMapKey (B8 F2)', () => {
it('keys a zone municipality by its catalog slug', () => {
expect(
featureMapKey({ municipalitySlug: 'salvador-ze-7', ibgeCode: '2927408', zoneNumber: 7 }),
).toBe('salvador-ze-7')
it('keys a zone municipality by its slug, never by the city code it also carries', () => {
expect(featureMapKey({ municipalitySlug: 'salvador-ze-7', ibgeCode: '2927408' })).toBe(
'salvador-ze-7',
)
})
it('keys a município by codarea and an identity territory by code', () => {
@ -44,12 +44,6 @@ describe('bahiaMapStyle', () => {
)
})
it('prefers the slug when the zone mesh also carries the city code', () => {
expect(featureMapKey({ municipalitySlug: 'salvador-ze-1', codarea: '2927408' })).toBe(
'salvador-ze-1',
)
})
it('returns undefined when no key property is usable', () => {
expect(featureMapKey(undefined)).toBeUndefined()
expect(featureMapKey({ name: 'Sem chave' })).toBeUndefined()

View file

@ -4,7 +4,6 @@ import {
buildMunicipalitiesByIbgeCode,
buildMunicipalitiesByMapKey,
mapKeyForMunicipality,
resolveMunicipalityMapNavigation,
} from '@/utilities/municipalityMapNavigation'
const abaira = { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108', kind: 'municipio' } as const
@ -40,31 +39,11 @@ describe('municipalityMapNavigation', () => {
it('gives every zone its own map key instead of pooling them under the city', () => {
const byMapKey = buildMunicipalitiesByMapKey([salvadorZe1, salvadorZe2, abaira])
expect(Object.keys(byMapKey).sort()).toEqual(['2900108', 'salvador-ze-1', 'salvador-ze-2'])
expect(byMapKey).toEqual({
'2900108': 'abaira',
'salvador-ze-1': 'salvador-ze-1',
'salvador-ze-2': 'salvador-ze-2',
})
expect(byMapKey['2927408']).toBeUndefined()
expect(byMapKey['salvador-ze-2']).toEqual({
slug: 'salvador-ze-2',
name: 'Salvador — ZE 2',
})
})
it('resolveMunicipalityMapNavigation returns none for an unknown or empty key', () => {
const byMapKey = buildMunicipalitiesByMapKey([abaira])
expect(resolveMunicipalityMapNavigation('9999999', byMapKey)).toEqual({ kind: 'none' })
expect(resolveMunicipalityMapNavigation('2900108', {})).toEqual({ kind: 'none' })
})
it('resolveMunicipalityMapNavigation opens the unit behind the key, zone included', () => {
const byMapKey = buildMunicipalitiesByMapKey([abaira, salvadorZe1])
expect(resolveMunicipalityMapNavigation('2900108', byMapKey)).toEqual({
kind: 'navigate',
slug: 'abaira',
})
expect(resolveMunicipalityMapNavigation('salvador-ze-1', byMapKey)).toEqual({
kind: 'navigate',
slug: 'salvador-ze-1',
})
})
})