mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 14:55:16 +00:00
Merge branch 'cursor/58bad531'
This commit is contained in:
commit
4bbd526dfd
27 changed files with 418 additions and 213 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-B2 (geometrias + scripts CLI)
|
||||
|
||||
Status: Fase 1 entregue com B3 (2026-07-19); Fases 2–3 pendentes
|
||||
Atualizado em: 2026-07-20 (`capture-review-debts` pós-A8: `build-bahia-demographics.mjs` reforça gatilho F2 como 4º call site)
|
||||
Atualizado em: 2026-07-27 (`capture-review-debts` pós-B8+: `scripts/lib/topology.mjs` resolve por precedente onde o helper da F2 vive — `scripts/lib/`, não `src/lib/`; 2026-07-26 pós-B8 F2: 5º call site; 2026-07-20 pós-A8: `build-bahia-demographics.mjs` reforça o gatilho da F2 como 4º call site)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B5)
|
||||
Responsável: —
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ Este plano é o registro canônico desses follow-ups. Sem ele, o B3 pode embarca
|
|||
## Questões em aberto
|
||||
|
||||
- **API pública pós-lazy: manter `getMunicipalityFeature` sync após warm, ou só async loaders?** **Recomendação:** loaders async (`loadMunicipalityGeometryModule()` / `loadTerritoryGeometryModule()`) que resolvem o módulo já indexado; os getters sync atuais viram métodos do módulo carregado (ou ficam em submodule lazy). Evitar Promise em cada hover do mapa.
|
||||
- **Onde vive o helper de cache CLI?** **Recomendação:** `src/lib/cliCachedDownload.ts` (ou `scripts/lib/cached-download.mjs` se quiser zero risco de import no app). Preferir `src/lib/` só se tipado e importado só pelos scripts via `--import=tsx/esm` — não expor no client bundle.
|
||||
- ~~**Onde vive o helper de cache CLI?**~~ **RESOLVIDO POR PRECEDENTE (`capture-review-debts` pós-B8+, 2026-07-27):** `scripts/lib/`. O B8+ precisou compartilhar a política de escala de chão entre os dois scripts de geometria e criou **`scripts/lib/topology.mjs`** — o primeiro módulo da pasta. A F2 **pousa nele** (`scripts/lib/cached-download.mjs`) em vez de abrir um segundo endereço: dois lares para código de CLI é exatamente a duplicação que esta fase existe para remover. A recomendação anterior (`src/lib/cliCachedDownload.ts`) fica **rejeitada** — `src/lib/` é client-safe por contrato (`.cursor/rules/codebase-map.mdc`) e um helper que faz `mkdir`/`fetch`/`writeFile` não pertence a uma pasta que o bundle do browser pode alcançar, mesmo que hoje ninguém o importe de lá.
|
||||
- **Unificar env `TSE_CACHE_DIR` / `GEOMETRIES_CACHE_DIR`?** **Recomendação:** não — um parâmetro `cacheDir` por chamada; cada script mantém sua env e pasta sob `data/`.
|
||||
|
||||
## Abordagem proposta
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -93,6 +135,7 @@ Componentes:
|
|||
- **Helper `createCampaignUser` para os e2e** — a criação inline de `campaignUser` está na 19ª cópia (`campaignZoneMap.e2e.spec.ts:1618-1629` é a mais nova), e o int já tem o helper (`tests/helpers/campaignFixtures.ts:542-559`). Pré-existente em `main` e inerte, portanto fora deste lote. Revisitar quando: o próximo spec e2e nascer — ele paga o helper em `campaignE2EFixtures.ts` e migra os vizinhos que tocar.
|
||||
- **Genérico para `MunicipalityZoneGeometryModule`/`MunicipalityGeometryModule`/`TerritoryGeometryModule`** com `getFeature(key)` uniforme. Revisitar quando: **a 4ª malha** entrar — a três, o rename atravessa `municipalityProximity.ts`, dois int specs e um e2e por ganho estético.
|
||||
- **Um único laço sobre `municipalities` em `municipalityMapData.ts`** (hoje quatro, cada um chamando `mapKeyForMunicipality`). Revisitar quando: o bundle ganhar um 5º campo por município — abaixo disso, juntar o laço histórico com o de cenários custa legibilidade e economiza microssegundos.
|
||||
- **A grade de quantização é exata em um eixo só** (achado do revisor de perf no `/simplify` desta entrega, 2026-07-27). `quantize` aceita **um** número de dígitos e o aplica aos dois eixos, então `GROUND_GRID_METRES` é honrado em longitude e a latitude segue o aspect ratio do bbox: medido, **102,8×109,2 m** nas duas malhas estaduais e **102,9×80,1 m** na de Salvador. Ancorar em X não é acidente — é o que mantém `bahia-municipalities.topo.json` byte-idêntico ao build pré-política, que é a prova de que F1 não mexeu na malha que todo mundo baixa. Tornar os dois eixos exatos exige passar um `{scale, translate}` explícito e **reescrever os três artefatos**, perdendo essa prova por um erro de ±9% num eixo, num mapa que para em `maxZoom: 10`. Registrado, não corrigido: o comentário de `quantizeDigitsFor` carrega o número e `describeGroundScale` imprime os dois passos em todo build. Revisitar quando: uma malha precisar de precisão métrica isotrópica (medida de área/distância **a partir do artefato**, não da fonte) — ou de graça, se algum outro motivo já obrigar a reescrever os três artefatos.
|
||||
|
||||
## Explicitamente fora (descartes deste triage)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
123
scripts/lib/topology.mjs
Normal 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`
|
||||
)
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue