Merge branch 'cursor/a2a2fd95'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 23:26:15 -03:00
commit 1d3ec3c772
27 changed files with 1130 additions and 277 deletions

File diff suppressed because one or more lines are too long

View file

@ -70,12 +70,14 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**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-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).
**Recently resolved (2026-07-26):** **B14** Município mais próximo (atalho geo no Início staff) — a new client island `NearestMunicipalityCard` ("Onde estou") answers "open the município I am standing in", and the answer is **point-in-polygon**, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because `MunicipalityMapPanel` always mounts in `mode="municipality"` and `BahiaMap` calls `loadMunicipalityGeometryModule()` on mount, so the 417 polygons (132 KB raw, **41 KB gzip**, own memoized chunk) are already in the browser of anyone on `/campanha` — the card reuses that promise and adds no geometry. So **no centroid artifact, no build script, no migration, no collection, no server action and no `Consent` key**: the position is read by `navigator.geolocation` and matched in the browser, and never leaves the device. Pure math in `src/lib/municipalityProximity.ts` (haversine, ray casting with holes, area-weighted centroid, discriminated union `inScope | zoneCity | outOfScope | outsideBahia`; **measured, so nobody re-derives it:** the mesh decodes to 417 plain `Polygon`s with zero holes and zero MultiPolygons, a full containment scan costs **0.106 ms** and the whole fallback **0.32 ms** — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh `Map` per call measured _slower_, and the module's existing `getMunicipalityFeature` index is passed in only because the map already built it); browser plumbing in `src/utilities/campaignGeolocation.ts` (sibling of `recentVisits.ts`: session flag `teqo:campaign:geo-prompted-session` so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers `unknown`, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is **and** offered the closest município he can open, up to `NEAREST_IN_SCOPE_MAX_KM` = 150 km. Salvador still gets no guessed ZE (trigger stays **B8 F2**) — it links to the filtered list, with the zone count limited to what the actor can read. `StaffDashboardView` gained `accessibleMunicipalities` (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a `zoneCityHrefs` prop serialized by `CampaignDashboard` (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported `buildMunicipalityListHref` into the island and cost **21 kB** of First Load JS for a single link, because the canonical serializer drags `bahiaTerritories` + `municipalityCatalog` in — the "client components must not import server data modules for values" rule applies to **URL serializers** too, so the href is serialized on the server. `/campanha` First Load JS: 265 → **268 kB** (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result **with** an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in **CSS** (`hidden … has-[>*]:grid`, written inline in `CampaignDashboard`): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the `DashboardShortcuts` wrapper was a named `div`, so it is gone too. `loadMunicipalityGeometryModule` memoizes the dynamic import's **rejection**, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: [`docs/plans/municipio-mais-proximo.md`](docs/plans/municipio-mais-proximo.md).
**Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md).
**Recently resolved (2026-07-26):** **B14** Município mais próximo (atalho geo no Início staff) — a new client island `NearestMunicipalityCard` ("Onde estou") answers "open the município I am standing in", and the answer is **point-in-polygon**, not a nearest-centroid guess. The source plan had rejected polygons as a bundle rabbit hole; the premise was false by the time it shipped, because `MunicipalityMapPanel` always mounts in `mode="municipality"` and `BahiaMap` calls `loadMunicipalityGeometryModule()` on mount, so the 417 polygons (132 KB raw, **41 KB gzip**, own memoized chunk) are already in the browser of anyone on `/campanha` — the card reuses that promise and adds no geometry. So **no centroid artifact, no build script, no migration, no collection, no server action and no `Consent` key**: the position is read by `navigator.geolocation` and matched in the browser, and never leaves the device. Pure math in `src/lib/municipalityProximity.ts` (haversine, ray casting with holes, area-weighted centroid, discriminated union `inScope | zoneCity | outOfScope | outsideBahia`; **measured, so nobody re-derives it:** the mesh decodes to 417 plain `Polygon`s with zero holes and zero MultiPolygons, a full containment scan costs **0.106 ms** and the whole fallback **0.32 ms** — 81% of it centroids — so there is no hot path here and a centroid artifact would still not pay for itself; building a fresh `Map` per call measured _slower_, and the module's existing `getMunicipalityFeature` index is passed in only because the map already built it); browser plumbing in `src/utilities/campaignGeolocation.ts` (sibling of `recentVisits.ts`: session flag `teqo:campaign:geo-prompted-session` so the automatic prompt fires at most once per tab, guarded permission read where Safari's missing descriptor legitimately answers `unknown`, typed failures). Centroid distance survives only to rank the fallback: an advisor standing outside his portfolio is told where he is **and** offered the closest município he can open, up to `NEAREST_IN_SCOPE_MAX_KM` = 150 km. Salvador still gets no guessed ZE — it links to the filtered list, with the zone count limited to what the actor can read. `StaffDashboardView` gained `accessibleMunicipalities` (~6.6 KB gzip of RSC payload for all 435), and the zone-list href reaches the island as a `zoneCityHrefs` prop serialized by `CampaignDashboard` (the RSC that already builds list hrefs) rather than by the Payload loader: the first version imported `buildMunicipalityListHref` into the island and cost **21 kB** of First Load JS for a single link, because the canonical serializer drags `bahiaTerritories` + `municipalityCatalog` in — the "client components must not import server data modules for values" rule applies to **URL serializers** too, so the href is serialized on the server. `/campanha` First Load JS: 265 → **268 kB** (measured against a stashed baseline, not against the number in a previous note). A coarse fix (accuracy > 10 km, typical of a desk) renders the result **with** an explicit "localização aproximada" caveat rather than silently naming the wrong município. The shortcuts row hides itself in **CSS** (`hidden … has-[>*]:grid`, written inline in `CampaignDashboard`): having the children report visibility upward cost two state slots, a callback prop, an effect and a layout shift on every load, because the server rendered the row hidden and the effect only unhid it after first paint — and with that state gone, the `DashboardShortcuts` wrapper was a named `div`, so it is gone too. `loadMunicipalityGeometryModule` memoizes the dynamic import's **rejection**, so the card catches it and falls into an actionable failure state — otherwise a ChunkLoadError left a spinner with a retry button that could never recover. Plano: [`docs/plans/municipio-mais-proximo.md`](docs/plans/municipio-mais-proximo.md).
**Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. _(B8 F2 later re-keyed the last two to `territorialClassByMapKey` / `projectedValidVotesByMapKey`.)_ UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md).
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).
@ -155,7 +157,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. 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`). **`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)
@ -182,10 +184,11 @@ 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 }`) and `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`). 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` (~72 KB; object `municipalityZones`, **19** features, `properties: { municipalitySlug, name, zoneNumber, ibgeCode }`), the Salvador ZE mesh. Soft size budget in tests: ≤ ~600 KB each.
- **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`, typed topologies, feature arrays. TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it.
- **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`.
- **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).
## Election baseline data (TSE 2022)

View file

@ -8,6 +8,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | open 2026-07-26 (found during B8 F2) | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |

View file

@ -1,7 +1,8 @@
# B8 — Polígonos dos Municípios-zona de Salvador (ZE 1–19)
Status: F1 entregue em código (2026-07-21; em produção desde 2026-07-23); F2 pendente
Atualizado em: 2026-07-24
Status: F1 entregue em código (2026-07-21; em produção desde 2026-07-23); **F2 entregue em código (2026-07-26)** — deploy pendente
Atualizado em: 2026-07-26
Revisão 2026-07-26 (F2 entregue): as duas questões em aberto foram fechadas na implementação (malha **IBGE 2022**, sem pivô para GeoSalvador; chave = **híbrido A+B** — `municipalitySlug` nas zonas, `ibgeCode` no resto, com o polígono municipal de Salvador **mantido como base sem interação** em vez de removido). As-built completo na seção "F2 as-built" abaixo; débitos novos em "Adiado com gatilho".
Revisão 2026-07-24 (remodelagem Municípios M1): **Camaçari saiu do escopo** — virou município inteiro (ZE 170/171 agregadas), e as entradas de Camaçari foram removidas do catálogo (`MunicipalityZoneNeighborhoodCity = 'Salvador'`; fonte única `tre-ra-02-2017`). B8 agora cobre **só as 19 zonas de Salvador**. Identificadores renomeados `plaza*` → `municipality*` na M1. As menções a Camaçari abaixo foram mantidas apenas como histórico de pesquisa.
Revisão 2026-07-21: F1 implementada — `municipalityZoneNeighborhoods.ts`, fixture+int, `MunicipalityZoneNeighborhoodsCard` no overview do Município-zona; mapa inalterado (F2).
Revisão 2026-07-21 (pós-`/simplify` + capture-review-debts): débitos S1–S3 absorvidos como **F2 prep** (~½ dia, dentro do appetite F2); S4–S10 em Explicitamente fora / Adiado; JR1–JR7 em Já resolvido (não reabrir no simplify).
@ -58,10 +59,10 @@ _Histórico Camaçari (fora de escopo desde M1):_ não havia lista oficial zona
- **Polígonos = dissolve de bairros (IBGE e/ou malha municipal), não geocodificar seções.** Mesmo padrão de dissolução já usado em TIs (B2). Gaps: polígono manual leve ou omitir pedaço com nota. **Rejeitado:** geocodificar milhares de seções → casco; pedir shapefile ao TRE neste ciclo; PostGIS.
- **i18n e naming** (AGENTS.md): identificadores `municipalityZoneNeighborhoods` (existente), `build-municipality-zone-geometries.mjs`, `bahia-municipality-zones.topo.json`, `getMunicipalityZoneFeature(slug)`; strings UI em pt-BR.
## Questões em aberto
## Questões em aberto — fechadas na F2 (2026-07-26)
- **Malha de bairros: IBGE 2022 vs GeoSalvador?** **Opções:** A) só IBGE BA | B) GeoSalvador. **Recomendação:** A na F2 com reconciliação de nomes TRE↔`NM_BAIRRO`; se cobertura de Salvador for insuficiente no spike de ½ dia, pivotar B. Validar no spike antes de lock de artefato.
- **Chave do coroplético no mapa: `municipalitySlug` ou continuar `ibgeCode` + overlay?** **Opções:** A) mode/`values` por `municipalitySlug` para Municípios-zona + município para o resto | B) split Salvador em features de zona substituindo o polígono municipal. **Recomendação:** B no footprint de Salvador (um polígono por Município-zona; o polígono municipal some lá) e A/`ibgeCode` no restante da Bahia — evita dupla contagem visual. _(assumido — validar no craft)_
- **Malha de bairros: IBGE 2022 vs GeoSalvador?** → **A (IBGE `BA_bairros_CD2022`)**, sem pivô. O spike deu **170/170** polígonos de Salvador atribuídos e **19/19** zonas cobertas; os unmatched críticos que disparariam GeoSalvador (>10% dos bairros TRE sem polígono) não apareceram. Custo: `shpjs` como devDependency, porque o IBGE só publica SHP (SIRGAS 2000) nesse recorte.
- **Chave do coroplético: `municipalitySlug` ou `ibgeCode` + overlay?** → **híbrido A+B**, com um ajuste sobre a recomendação: o polígono municipal de Salvador **não sumiu**, ficou desenhado por baixo como **base sem dado e sem pointer**. Duas malhas simplificadas de forma independente não fecham na mesma linha de costa, e apagar o polígono municipal abria sliver de fundo no contorno do estado. A base não entra no índice de chaves, então não há dupla contagem visual nem alvo duplo de hover — o risco que a recomendação original queria evitar.
## Abordagem proposta
@ -88,11 +89,11 @@ Componentes:
### Fases
| Fase | Entrega | Appetite |
| ------- | ------------------------------------------------------------------------------ | ------------- |
| F1 ✓ | Catálogo + testes + bloco no município (entregue; Salvador-only desde M1) | ~1 dia |
| F2 prep | Hidratação via `municipalityCatalog` + canônico JSON→TS + teste ordem de slugs | ~½ dia |
| F2 | Spike cobertura malha → script → TopoJSON → mapa | ~1,5–2,5 dias |
| Fase | Entrega | Appetite |
| --------- | ------------------------------------------------------------------------------------------- | ------------- |
| F1 ✓ | Catálogo + testes + bloco no município (entregue; Salvador-only desde M1) | ~1 dia |
| F2 prep ✓ | Hidratação via `municipalityCatalog` + teste ordem de slugs (S1+S3; **S2 cortado**, abaixo) | ~½ dia |
| F2 ✓ | Spike cobertura malha → script → TopoJSON → mapa (entregue 2026-07-26) | ~1,5–2,5 dias |
**F2 prep** (débitos pós-`/simplify`, capture-review-debts 2026-07-21 — não item paralelo no roadmap):
@ -122,6 +123,30 @@ Componentes:
- **Desenhar 19 polígonos à mão no QGIS sem catálogo.** Irrepetível e sem teste. **Mitigação:** F1 obrigatória (feita); manual só para unmatched.
- **Tratar polígono derivado como limite oficial TSE.** Risco jurídico/comunicação. **Mitigação:** copy “aproximação a partir de bairros; não é limite oficial”.
## F2 as-built (2026-07-26)
**Artefato.** `src/lib/geometries/bahia-municipality-zones.topo.json` — **72.310 bytes** (~20 KB gzip; budget de 600 KB do padrão B2 sobrou inteiro), objeto `municipalityZones`, 19 features com `properties: { municipalitySlug, name, zoneNumber, ibgeCode }`. Gerado por `pnpm build:municipality-zone-geometries` (`scripts/build-municipality-zone-geometries.mjs`, flag `--report` para só reconciliar sem escrever), que baixa/cacheia `BA_bairros_CD2022.zip` em `data/geometries/` (gitignored), parseia com `shpjs`, filtra `CD_MUN === '2927408'`, simplifica no quantil 0.35, dissolve por zona com `topojson.merge` e quantiza em 1e4 — mesma ordem do irmão `build-bahia-geometries.mjs`, e como ele **não toca o banco**. Consumo lazy por `loadMunicipalityZoneGeometryModule()` (`bahiaMunicipalityZoneGeometries.ts`), no padrão memoizado de B5 F1: o grafo default do `/campanha` não paga o decode.
**Reconciliação (a parte que não é código).** Três tabelas curadas no cabeçalho do script, cada linha com a evidência:
- `IBGE_TO_TRE_NEIGHBORHOOD` — 7 aliases de grafia/composição (`Beiru/Tancredo Neves`, CAB, ilhas da baía).
- `TRE_NEIGHBORHOODS_MERGED_INTO` — bairro da resolução sem polígono próprio no IBGE (Jardim Cruzeiro dentro de Vila Ruy Barbosa). O build **recusa** um merge cujos dois lados caiam em zonas diferentes.
- `SPLIT_NEIGHBORHOOD_ZONES` — Periperi, que a RA 02/2017 corta na Rua das Pedrinhas entre duas zonas, vai **inteiro** para a ZE 17: o bairro é o átomo geométrico disponível, e partir polígono por descrição de rua era o rabbit hole que este plano já recusava.
Atribuição em dois passes: (1) nome citado pela resolução; (2) **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi). O passe 2 foi **validado**, não assumido: rodado sobre os 4 bairros que a resolução nomeia sob outro rótulo, ele reproduz a zona oficial nos 4. O build falha se sobrar polígono órfão ou bairro TRE sem polígono — hoje 170/170 → 19/19.
**Re-keying (o que mudou fora do mapa).** Map key = `kind === 'zona' ? municipalitySlug : ibgeCode`, via `mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` (`municipalityMapNavigation.ts`). O bundle inteiro passou a ser keyed por ela (`valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`); os rollups pararam de acumular com `+=` porque cada chave é agora 1:1, e por isso `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass`. `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com a copy "toque de novo para ver as zonas" e o scroll-anchor do painel. `buildMunicipalitiesByIbgeCode` sobrevive intacto: o href de lista do **B14 ✓** é por cidade, de propósito.
**Rank competitivo continua por cidade.** O artefato TSE (v3) só tem `federalRankByIbgeCode`, então as 19 zonas herdam a posição da cidade inteira — exatamente o que a legenda da escala "posição no município" já afirma. Int novo pina que as 19 compartilham **uma** posição, para que a limitação seja um invariante testado e não uma surpresa. Rank por ZE exigiria uma dimensão nova no artefato (gatilho abaixo).
**Mapa.** `BahiaMap` monta a camada de município a partir de duas malhas (`loadLayerFeatures`), com o polígono municipal de Salvador desenhado por baixo como base sem dado e sem pointer (`baseKeys`). `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo era supérfluo); `fitMapToHighlights` passou a medir os paths desenhados (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado.
**Dois fatos medidos (não re-derivar).** (1) O polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²**: a diferença é a água da baía que o limite municipal encerra e a malha de bairros não desenha. Por isso o int de área compara contra a área de **terra** (280–360 km²) e não contra o município — a primeira versão do teste, que exigia ≥95% de razão, estava medindo terra contra terra+água. (2) Centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui: a ZE 16 é concava e envolve a ZE 14, então o centróide da 16 cai legitimamente na 14. A não-sobreposição é garantida estruturalmente (cada bairro entra em exatamente uma zona, verificado no build) mais a soma de áreas.
**Testes.** Int de contrato do artefato (19 features, slugs == catálogo `kind === 'zona'`, budget, centróide de cada zona dentro do polígono municipal de Salvador, área somada); int de rank compartilhado; unit de `featureMapKey` e de `mapKeyForMunicipality`/`buildMunicipalitiesByMapKey`; e2e `campaignZoneMap.e2e.spec.ts` — 436 paths / 435 interativos (o único não-interativo é a base de Salvador), readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`.
**Gate.** tsc, lint, format, knip (P3 pré-existente em `payload.config.ts`), `check:cycles`, unit+int, e2e e build rodados. **Aikido não fechou:** o scanner responde Opengrep exit code 2 em qualquer entrada, inclusive num arquivo-probe trivial de uma linha, então os achados que ele reportou são artefato da execução quebrada e não são atribuíveis; o feed do Aikido não lista nada deste repo. SCA do `shpjs` (devDependency nova) também pendente — ambos registrados como débito abaixo.
## Já resolvido no simplify/critique (não reabrir)
- **JR1** — `MunicipalityZoneNeighborhoodsCard`: um lookup `municipalityZoneNeighborhoodEntryForSlug` (sem duplo hit no `entryBySlug`).
@ -132,6 +157,9 @@ Componentes:
- **JR6** — cobertura usa `zoneMunicipalities.length` em vez de constante fixa.
- **JR7** — _(histórico)_ exclusividade de Camaçari sem teste — obsoleto: Camaçari saiu do catálogo na M1.
- Impeccable compacto na F1: sem snapshot `.impeccable/critique/` nem P0–P3 abertos nesta superfície; polish visual amplo → **R6**.
- **JR8** _(F2)_ — comentário de `NearbyMunicipalityResolution.zoneCity` que afirmava não existirem polígonos de zona: corrigido para dizer que existem e que o card **deliberadamente** não os usa.
- **JR9** _(F2)_ — duas asserções do int de bairros que a hidratação da Fase 0 tornou tautológicas (comparavam `city`/`zoneNumber` com o catálogo de onde acabaram de ser copiados) removidas; a verificação real segue no `deepEqual` contra o fixture transcrito à mão.
- **JR10** _(F2)_ — o script re-indexava geometrias simplificadas por **nome** de bairro; passou a usar `id` próprio (o irmão usa `codarea`, único por construção). Hoje são 170 nomes distintos, mas um nome repetido em malha futura colapsaria dois polígonos em silêncio dentro de um artefato commitado. Rebuild saiu byte-idêntico.
## Explicitamente fora (skips `/simplify` + descartes do triage)
@ -144,7 +172,10 @@ Componentes:
## Adiado com gatilho
- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Gatilho:** F2 alterar `MunicipalityMapPanel`/`BahiaMap` ou R6 citar duplicação visual em ≥2 superfícies de zona.
- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Gatilho disparou** na F2 (o painel foi alterado) e foi reavaliado: as duas listas continuam pequenas e com estrutura diferente, então extrair peça compartilhada agora seria abstração por coincidência visual. Segue adiado, agora com gatilho único: **R6** citar a duplicação em ≥2 superfícies de zona.
- **F2-D1 — card "Onde estou" (B14 ✓) resolver a ZE exata de Salvador.** Os polígonos passaram a existir nesta entrega, e `featureContainsPoint` já é genérico o suficiente para recebê-los; o card continua levando à lista filtrada por decisão. **Gatilho:** pedido de campo ("estou em Salvador e quero abrir minha zona"). Custo real é uma segunda malha no chunk do card, não matemática nova. Aresta já registrada no grafo do roadmap.
- **F2-D2 — rank competitivo por ZE.** Exige dimensão nova no artefato TSE (hoje `federalRankByIbgeCode`), o que mexe no budget de 700 KB. **Gatilho:** E11 pedir o eixo de competição dentro de Salvador, ou o dossiê de um Município-zona exibir posição própria. Hoje as 19 zonas herdam a posição da cidade, com int pinando o fato.
- **S2 — fonte única TS ↔ fixture do catálogo de bairros** (gerar `municipalityZoneNeighborhoods.ts` a partir do JSON). **Cortado na F2 prep:** a curadoria que a F2 exigia foi para o **script** de geometrias, não para o catálogo, então o gerador não teria a quem servir; o `deepEqual` fixture↔código já pega drift. **Gatilho:** um terceiro consumidor precisar do catálogo em runtime não-TS.
- **Malha GeoSalvador em vez de IBGE.** Revisitar quando: spike F2 mostrar unmatched críticos em Salvador (>10% bairros TRE sem polígono).
- **Camada ZE estadual (B4 antigo).** Revisitar quando: produto pedir mapa de ZE fora de Salvador (improvável neste ciclo).

File diff suppressed because one or more lines are too long

View file

@ -33,6 +33,7 @@
"build:demographics": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-demographics.mjs",
"build:election-aggregates": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/build-election-aggregates.mjs",
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"dev": "node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev",
@ -108,6 +109,7 @@
"@types/pg": "^8.10.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/shpjs": "^3.4.7",
"@types/topojson-client": "^3.1.5",
"@types/topojson-server": "^3.0.4",
"@types/topojson-simplify": "^3.0.3",
@ -129,6 +131,7 @@
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.3.0",
"schema-dts": "^2.0.0",
"shpjs": "^6.2.0",
"tailwindcss": "^4.1.18",
"topojson-server": "^3.0.1",
"topojson-simplify": "^3.0.3",

View file

@ -147,6 +147,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/shpjs':
specifier: ^3.4.7
version: 3.4.7
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
@ -210,6 +213,9 @@ importers:
schema-dts:
specifier: ^2.0.0
version: 2.0.0(@typescript/typescript6@6.0.2)
shpjs:
specifier: ^6.2.0
version: 6.2.0
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
@ -3148,6 +3154,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/shpjs@3.4.7':
resolution: {integrity: sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@ -3834,6 +3843,9 @@ packages:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
but-unzip@0.1.10:
resolution: {integrity: sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -5627,6 +5639,9 @@ packages:
merge@2.1.1:
resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==}
mgrs@1.0.0:
resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==}
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
@ -5974,6 +5989,9 @@ packages:
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
parsedbf@2.0.0:
resolution: {integrity: sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -6239,6 +6257,14 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
proj4@2.20.9:
resolution: {integrity: sha512-GLBGqXaTcdWnppre3o1sMmy4DcMGSGq/ng+9k2MTNddarRK6SveINqlqYzi3xEXuy06ljY1TTrC6H9C4f360IQ==}
peerDependencies:
geotiff: '*'
peerDependenciesMeta:
geotiff:
optional: true
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@ -6607,6 +6633,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shpjs@6.2.0:
resolution: {integrity: sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==}
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@ -7318,6 +7347,9 @@ packages:
engines: {node: '>=8'}
hasBin: true
wkt-parser@1.5.6:
resolution: {integrity: sha512-cqHU3lzGt/gt2OqIORP0uVy5yeOX43WABmgFkmJsmcqH3HhQo9PiJG6ftytwGKmpQUbegeX7+pQc2BuNCRfcrw==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@ -10260,6 +10292,11 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/shpjs@3.4.7':
dependencies:
'@types/geojson': 7946.0.16
'@types/node': 22.19.9
'@types/statuses@2.0.6': {}
'@types/topojson-client@3.1.5':
@ -10912,6 +10949,8 @@ snapshots:
dependencies:
streamsearch: 1.1.0
but-unzip@0.1.10: {}
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@ -12826,6 +12865,8 @@ snapshots:
merge@2.1.1: {}
mgrs@1.0.0: {}
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.3.0
@ -13344,6 +13385,8 @@ snapshots:
dependencies:
entities: 6.0.1
parsedbf@2.0.0: {}
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
@ -13633,6 +13676,11 @@ snapshots:
process-warning@5.0.0: {}
proj4@2.20.9:
dependencies:
mgrs: 1.0.0
wkt-parser: 1.5.6
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@ -14209,6 +14257,14 @@ snapshots:
shebang-regex@3.0.0: {}
shpjs@6.2.0:
dependencies:
but-unzip: 0.1.10
parsedbf: 2.0.0
proj4: 2.20.9
transitivePeerDependencies:
- geotiff
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@ -14940,6 +14996,8 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
wkt-parser@1.5.6: {}
wmf@1.0.2: {}
word-wrap@1.2.5: {}

View file

@ -0,0 +1,410 @@
/**
* Builds the static TopoJSON of the zone municipalities — Salvador's 19 ZE
* (roadmap B8 F2). One feature per catalog `kind: 'zona'` entry, dissolved from
* the IBGE neighborhood mesh using the official TRE circunscrição list.
*
* Provenance (do not strip):
* - IBGE Malhas de bairros, Censo 2022 (shapefile, UF BA):
* https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip
* - Zone → neighborhood assignment: src/lib/municipalityZoneNeighborhoods.ts
* (TRE-BA Resolução Administrativa nº 2/2017, Anexo I).
*
* APPROXIMATION — the result is NOT an official TSE boundary. The TRE publishes
* the circunscrição as a list of neighborhoods, so the polygon is only as exact
* as the neighborhood mesh: where the resolution splits one neighborhood
* between two zones by a street, the polygon is atomic and goes whole to one of
* them (see SPLIT_NEIGHBORHOOD_ZONES). Every surface that draws it says so.
*
* Safety: does not touch any database — only downloads public IBGE data and
* writes a versioned artifact under src/lib/geometries/.
*
* Usage:
* pnpm build:municipality-zone-geometries
* pnpm build:municipality-zone-geometries --report # reconciliation only
* GEOMETRIES_CACHE_DIR=./data/geometries pnpm build:municipality-zone-geometries
*/
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import shp from 'shpjs'
import { merge, quantize } from 'topojson-client'
import { topology } from 'topojson-server'
import { presimplify, quantile, simplify } from 'topojson-simplify'
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityZoneNeighborhoods } =
await import('../src/lib/municipalityZoneNeighborhoods.ts')
const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const SOURCE = {
key: 'bahia-bairros-cd2022',
url: 'https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip',
ext: 'zip',
}
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.
* Identity beats the adjacency rule below: the resolution naming a
* neighborhood is the fact, adjacency is only the fallback for the ones it
* never mentions. Both sides are matched normalized and the target must exist
* in the TRE catalog, or the build fails.
*/
const IBGE_TO_TRE_NEIGHBORHOOD = {
'BEIRU TANCREDO NEVES': 'Tancredo Neves',
'CENTRO ADMINISTRATIVO DA BAHIA CAB': 'Centro Administrativo da Bahia',
'NORDESTE DO AMARALINA': 'Nordeste de Amaralina',
// Islands have no shared boundary, so the adjacency rule cannot place them —
// and it does not need to: RA 02/2017 names both in ZE 4.
'ILHA BOM JESUS DOS PASSOS': 'Ilha de Bom Jesus dos Passos',
'ILHA DOS FRADES ILHA DE SANTO ANTONIO': 'Ilha dos Frades',
'VILA RUY BARBOSA JARDIM CRUZEIRO': 'Vila Ruy Barbosa',
}
/**
* TRE neighborhoods with no polygon of their own because the IBGE mesh draws
* them inside a neighbor. Only legal within one zone — the build refuses a
* merge that would move territory across zones.
*/
const TRE_NEIGHBORHOODS_MERGED_INTO = {
'Jardim Cruzeiro': 'Vila Ruy Barbosa',
}
/**
* Neighborhoods the resolution splits between two zones by a street. The
* polygon is atomic without street-level geodata, so it goes whole to the zone
* named here, and every build reports it.
*/
const SPLIT_NEIGHBORHOOD_ZONES = {
// RA 02/2017 cuts Periperi at Rua das Pedrinhas (north = ZE 4, south = ZE 17).
// It goes to ZE 17, whose other neighborhoods (Alto do Cabrito, Itacaranha,
// Plataforma, Praia Grande, Rio Sena, São João do Cabrito) wrap it; ZE 4
// keeps Paripe, Coutos and the islands.
PERIPERI: 'salvador-ze-17',
}
const REPORT_ONLY = process.argv.includes('--report')
const die = (message) => {
console.error(`\n[build:municipality-zone-geometries] ${message}\n`)
process.exit(1)
}
const log = (message) => console.log(`[build:municipality-zone-geometries] ${message}`)
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
const cacheDir = () => process.env.GEOMETRIES_CACHE_DIR || join(ROOT, 'data', 'geometries')
const ensureCachedBinary = async ({ key, url, ext }) => {
const dir = cacheDir()
await mkdir(dir, { recursive: true })
const path = join(dir, `${key}.${ext}`)
try {
await access(path)
log(`cache hit ${path}`)
const buffer = await readFile(path)
return { url, hash: sha256(buffer), buffer }
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
log(`downloading ${url}`)
const buffer = await downloadToBuffer(url)
const hash = sha256(buffer)
await writeFile(path, buffer)
log(`saved ${path} (${buffer.length} bytes, sha256=${hash})`)
return { url, hash, buffer }
}
const writeJson = async (relativePath, value) => {
const path = join(ROOT, relativePath)
await mkdir(dirname(path), { recursive: true })
const body = `${JSON.stringify(value)}\n`
await writeFile(path, body)
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
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/\(.*?\)/g, ' ')
.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)
else into.add(item < 0 ? -1 - item : item)
}
return into
}
const arcLength = (arc) => {
let total = 0
for (let index = 1; index < arc.length; index += 1) {
const [previousX, previousY] = arc[index - 1]
const [x, y] = arc[index]
total += Math.hypot(x - previousX, y - previousY)
}
return total
}
/**
* Places a polygon RA 02/2017 never names in the zone it shares the most
* boundary with. Validated against the neighborhoods the resolution DOES name
* under a different label (Beiru/Tancredo Neves, CAB, Nordeste de Amaralina,
* Vila Ruy Barbosa): the rule reproduces the official zone in all four.
*/
const rankZonesBySharedBoundary = (topo, geometry, zoneSlugByGeometry) => {
const ownArcs = arcIndexes(geometry.arcs)
const lengthByZone = new Map()
for (const other of topo.objects.neighborhoods.geometries) {
const zone = zoneSlugByGeometry.get(other)
if (!zone || other === geometry) continue
const otherArcs = arcIndexes(other.arcs)
let shared = 0
for (const arc of ownArcs) if (otherArcs.has(arc)) shared += arcLength(topo.arcs[arc])
if (shared > 0) lengthByZone.set(zone, (lengthByZone.get(zone) ?? 0) + shared)
}
return [...lengthByZone.entries()].sort((left, right) => right[1] - left[1])
}
const main = async () => {
const zoneEntries = municipalityCatalog.filter((entry) => entry.kind === 'zona')
if (zoneEntries.length === 0) die('No zone municipalities in the catalog.')
const zoneIbgeCodes = new Set(zoneEntries.map((entry) => entry.ibgeCode))
// TRE neighborhood → zone slug. A name in two zones is a documented split.
const zoneSlugByNeighborhood = new Map()
const splitNeighborhoods = new Set()
for (const record of municipalityZoneNeighborhoods) {
for (const neighborhood of record.neighborhoods) {
const key = normalizeNeighborhood(neighborhood)
const previous = zoneSlugByNeighborhood.get(key)
if (previous && previous !== record.municipalitySlug) {
const resolved = SPLIT_NEIGHBORHOOD_ZONES[key]
if (!resolved) {
die(
`"${neighborhood}" is listed in ${previous} and ${record.municipalitySlug}; add it to SPLIT_NEIGHBORHOOD_ZONES.`,
)
}
splitNeighborhoods.add(key)
zoneSlugByNeighborhood.set(key, resolved)
continue
}
if (!previous) zoneSlugByNeighborhood.set(key, record.municipalitySlug)
}
}
const aliasTargets = new Map()
for (const [ibgeName, treName] of Object.entries(IBGE_TO_TRE_NEIGHBORHOOD)) {
const treKey = normalizeNeighborhood(treName)
if (!zoneSlugByNeighborhood.has(treKey)) {
die(`IBGE_TO_TRE_NEIGHBORHOOD maps "${ibgeName}" to unknown TRE neighborhood "${treName}".`)
}
aliasTargets.set(normalizeNeighborhood(ibgeName), treKey)
}
for (const [treName, hostName] of Object.entries(TRE_NEIGHBORHOODS_MERGED_INTO)) {
const treZone = zoneSlugByNeighborhood.get(normalizeNeighborhood(treName))
const hostZone = zoneSlugByNeighborhood.get(normalizeNeighborhood(hostName))
if (!treZone) die(`TRE_NEIGHBORHOODS_MERGED_INTO has unknown neighborhood "${treName}".`)
if (!hostZone) die(`TRE_NEIGHBORHOODS_MERGED_INTO has unknown host "${hostName}".`)
if (treZone !== hostZone) {
die(
`Merging "${treName}" (${treZone}) into "${hostName}" (${hostZone}) would move territory across zones.`,
)
}
}
const { hash, buffer } = await ensureCachedBinary(SOURCE)
const features = [await shp(buffer)]
.flat()
.flatMap((collection) => collection.features)
.filter((feature) => zoneIbgeCodes.has(feature.properties?.CD_MUN))
if (features.length === 0) die('No neighborhood polygons found for the zone municipalities.')
log(`${features.length} neighborhood polygons in the zone municipalities (sha256=${hash})`)
// Topology before anything else: the dissolve needs shared arcs to drop
// interior boundaries instead of leaving slivers, adjacency is read off those
// same arcs, and simplification then moves both sides of an arc identically.
const neighborhoodTopology = topology({
neighborhoods: {
type: 'FeatureCollection',
// `id` is what the simplified topology is re-indexed by further down.
// Neighborhood names are unique in today's mesh but nothing guarantees it,
// and keying by name would silently collapse two polygons into one.
features: features.map((feature, index) => ({
type: 'Feature',
properties: { id: index, name: feature.properties?.NM_BAIRRO ?? '' },
geometry: feature.geometry,
})),
},
})
const geometries = neighborhoodTopology.objects.neighborhoods.geometries
// 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)
if (slug) zoneSlugByGeometry.set(geometry, slug)
else unnamed.push(geometry)
}
// Pass 2 — polygons RA 02/2017 never names, by shared boundary.
const orphans = []
for (const geometry of unnamed) {
const ranked = rankZonesBySharedBoundary(neighborhoodTopology, geometry, zoneSlugByGeometry)
const [winner, runnerUp] = ranked
if (!winner) {
orphans.push(geometry.properties.name)
continue
}
zoneSlugByGeometry.set(geometry, winner[0])
const margin = runnerUp
? `+${(((winner[1] - runnerUp[1]) / runnerUp[1]) * 100).toFixed(0)}%`
: 'only neighbour'
log(
`not in RA 02/2017 — "${geometry.properties.name}" → ${winner[0]} by shared boundary (${margin}${
runnerUp ? ` over ${runnerUp[0]}` : ''
})`,
)
}
const coveredKeys = new Set(
geometries.map((geometry) => {
const key = normalizeNeighborhood(geometry.properties.name)
return aliasTargets.get(key) ?? key
}),
)
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)) {
if (host === hostName) coveredKeys.add(normalizeNeighborhood(treName))
}
}
const neighborhoodsWithoutPolygon = municipalityZoneNeighborhoods.flatMap((record) =>
record.neighborhoods
.filter((neighborhood) => !coveredKeys.has(normalizeNeighborhood(neighborhood)))
.map((neighborhood) => `${record.municipalitySlug}: ${neighborhood}`),
)
const geometriesByZone = new Map()
for (const [geometry, slug] of zoneSlugByGeometry) {
geometriesByZone.set(slug, [...(geometriesByZone.get(slug) ?? []), geometry])
}
log(
`assigned ${zoneSlugByGeometry.size}/${geometries.length} polygons to ${geometriesByZone.size}/${zoneEntries.length} zones`,
)
if (splitNeighborhoods.size > 0) {
log(
`split by RA 02/2017, kept whole: ${[...splitNeighborhoods]
.map((key) => `${key} → ${SPLIT_NEIGHBORHOOD_ZONES[key]}`)
.join(', ')}`,
)
}
if (orphans.length > 0) {
log(`polygons with no zone (${orphans.length}):`)
for (const name of [...orphans].sort()) console.log(` - ${name}`)
}
if (neighborhoodsWithoutPolygon.length > 0) {
log(`TRE neighborhoods with no polygon (${neighborhoodsWithoutPolygon.length}):`)
for (const entry of neighborhoodsWithoutPolygon.sort()) console.log(` - ${entry}`)
}
if (orphans.length > 0 || neighborhoodsWithoutPolygon.length > 0) {
die(
'Reconciliation is incomplete — extend IBGE_TO_TRE_NEIGHBORHOOD / TRE_NEIGHBORHOODS_MERGED_INTO until every polygon has a zone and every neighborhood has a polygon.',
)
}
if (REPORT_ONLY) {
log('--report: reconciliation complete, artifact not written.')
return
}
// Simplification keeps geometry order and properties, so the zone buckets
// above still address the right shapes.
const simplified = simplifyTopology(neighborhoodTopology)
const simplifiedById = new Map(
simplified.objects.neighborhoods.geometries.map((geometry) => [
geometry.properties.id,
geometry,
]),
)
const zoneFeatures = zoneEntries.map((entry) => {
const members = (geometriesByZone.get(entry.slug) ?? []).map((geometry) =>
simplifiedById.get(geometry.properties.id),
)
if (members.length === 0 || members.some((member) => !member)) {
die(`Missing neighborhood geometry for ${entry.slug}.`)
}
const geometry = merge(simplified, members)
if (!geometry || geometry.coordinates.length === 0) {
die(`Dissolve produced an empty polygon for ${entry.slug}.`)
}
return {
type: 'Feature',
properties: {
municipalitySlug: entry.slug,
name: entry.name,
zoneNumber: entry.zoneNumber,
ibgeCode: entry.ibgeCode,
},
geometry,
}
})
// 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,
),
)
for (const feature of zoneFeatures) {
const pieces = polygonCount(feature.geometry)
log(
`${feature.properties.municipalitySlug}: ${
geometriesByZone.get(feature.properties.municipalitySlug).length
} neighborhoods → ${pieces} ${pieces === 1 ? 'piece' : 'pieces'}`,
)
}
log(`done — ${zoneFeatures.length} zone polygons.`)
}
await main()

View file

@ -9,17 +9,16 @@ import 'leaflet/dist/leaflet.css'
import { Spinner } from '@/components/ui/Spinner'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
type MunicipalityGeometryModule,
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes'
import {
bubbleRadius,
buildHighlightSet,
buildLayerStyleContext,
canonicalMapKeysKey,
featureKeyFromProperties,
keyPropertyForMode,
featureMapKey,
resolvePathStyle,
type BahiaMapFillMode,
type ChoroplethFills,
@ -64,6 +63,32 @@ const applyBahiaMinZoom = (map: L.Map) => {
export type BahiaMapMode = 'municipality' | 'territory'
/**
* The municipality layer is two meshes: the 417 municípios plus the 19 Salvador
* zone polygons on top (insertion order is z-order in the SVG). The zone city's
* own polygon stays underneath as a BASE — no value, no pointer — so a sliver of
* disagreement between two independently simplified meshes cannot punch a hole
* in the state's outline.
*/
const loadLayerFeatures = async (
mode: BahiaMapMode,
): Promise<{ features: BahiaGeometryFeature[]; baseKeys: Set<string> }> => {
if (mode === 'territory') {
const { features } = await loadTerritoryGeometryModule()
return { features: [...features], baseKeys: new Set() }
}
const [municipalities, zones] = await Promise.all([
loadMunicipalityGeometryModule(),
loadMunicipalityZoneGeometryModule(),
])
return {
features: [...municipalities.features, ...zones.features],
baseKeys: new Set(zones.features.map((zone) => zone.properties.ibgeCode)),
}
}
export type BahiaMapFeatureInfo = {
key: string
name: string
@ -106,37 +131,28 @@ type BahiaMapProps = {
const getPointerType = (event: L.LeafletMouseEvent, fallback = 'mouse') =>
event.originalEvent instanceof PointerEvent ? event.originalEvent.pointerType : fallback
/**
* Fits from the drawn paths rather than the geometry modules: since B8 F2 a
* municipality layer is assembled from two meshes, and the paths are the one
* index that already knows which key came from which.
*/
const fitMapToHighlights = (
map: L.Map,
mode: BahiaMapMode,
highlightSet: Set<string>,
geometryModule: MunicipalityGeometryModule | TerritoryGeometryModule,
pathByKey: Map<string, L.Path>,
) => {
if (highlightSet.size === 0) {
const highlightBounds = L.latLngBounds([])
for (const key of highlightSet) {
const path = pathByKey.get(key)
if (path instanceof L.Polygon) highlightBounds.extend(path.getBounds())
}
if (!highlightBounds.isValid()) {
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
return
}
const highlightedFeatures =
mode === 'municipality'
? [...highlightSet].flatMap((key) => {
const feature = (geometryModule as MunicipalityGeometryModule).getMunicipalityFeature(key)
return feature ? [feature] : []
})
: [...highlightSet].flatMap((key) => {
const feature = (geometryModule as TerritoryGeometryModule).getTerritoryFeature(key)
return feature ? [feature] : []
})
if (highlightedFeatures.length === 0) {
map.fitBounds(BAHIA_BOUNDS, { padding: BAHIA_FIT_PADDING })
return
}
const highlightBounds = L.geoJSON(highlightedFeatures).getBounds()
if (highlightBounds.isValid()) {
map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 })
}
map.fitBounds(highlightBounds, { padding: [24, 24], maxZoom: 10 })
}
export const BahiaMap = ({
@ -160,9 +176,6 @@ export const BahiaMap = ({
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(
null,
)
const pathByKeyRef = useRef<Map<string, L.Path>>(new Map())
const styleContextRef = useRef<LayerStyleContext | null>(null)
const geometryReadyRef = useRef(false)
@ -299,7 +312,6 @@ export const BahiaMap = ({
map.remove()
mapRef.current = null
layerRef.current = null
geometryModuleRef.current = null
pathByKeyRef.current = new Map()
styleContextRef.current = null
geometryReadyRef.current = false
@ -317,19 +329,13 @@ export const BahiaMap = ({
const renderLayer = async () => {
try {
const geometryModule =
mode === 'municipality'
? await loadMunicipalityGeometryModule()
: await loadTerritoryGeometryModule()
const { features, baseKeys } = await loadLayerFeatures(mode)
if (cancelled) return
layerRef.current?.remove()
pathByKeyRef.current = new Map()
geometryModuleRef.current = geometryModule
const keyProperty = keyPropertyForMode(mode)
const syncMountStyleContext = () => {
styleContextRef.current = buildLayerStyleContext({
...stylePropsRef.current,
@ -340,21 +346,32 @@ export const BahiaMap = ({
syncMountStyleContext()
const mountContext = styleContextRef.current!
const layer = L.geoJSON([...geometryModule.features], {
/**
* The key this feature answers for, or `undefined` when it is a base
* polygon: those carry no value, take no pointer, and must not answer
* for a key the data has moved off of.
*/
const paintedKey = (properties: Feature['properties'] | undefined) => {
const key = featureMapKey(properties)
return key && !baseKeys.has(key) ? key : undefined
}
const layer = L.geoJSON(features, {
style: (feature?: Feature) => {
const properties = feature?.properties as Record<string, string> | undefined
const key = featureKeyFromProperties(properties, keyProperty)
const context = styleContextRef.current ?? mountContext
return resolvePathStyleForFeature(context, key ?? '')
const key = paintedKey(feature?.properties)
return key
? resolvePathStyleForFeature(context, key)
: { ...resolvePathStyle(context, ''), interactive: false }
},
onEachFeature: (feature, featureLayer) => {
if (!(featureLayer instanceof L.Path)) return
const properties = feature.properties as Record<string, string> | undefined
const key = featureKeyFromProperties(properties, keyProperty)
const key = paintedKey(feature.properties)
if (!key) return
const name = properties?.name ?? key
const name =
typeof feature.properties?.name === 'string' ? feature.properties.name : key
pathByKeyRef.current.set(key, featureLayer)
featureLayer.on('mouseover', (event) => {
@ -403,7 +420,7 @@ export const BahiaMap = ({
restyleAllPaths()
const fitIdentity = `${mode}:${viewportKey}`
fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule)
fitMapToHighlights(map, buildHighlightSet(viewportKey), pathByKeyRef.current)
lastFittedViewportKeyRef.current = fitIdentity
geometryReadyRef.current = true
@ -420,7 +437,6 @@ export const BahiaMap = ({
layerRef.current?.remove()
layerRef.current = null
pathByKeyRef.current = new Map()
geometryModuleRef.current = null
geometryReadyRef.current = false
}
// Geometry layer rebuilds only when `mode` changes; metric/hover updates use separate effects.
@ -434,10 +450,9 @@ export const BahiaMap = ({
if (lastFittedViewportKeyRef.current === fitIdentity) return
const map = mapRef.current
const geometryModule = geometryModuleRef.current
if (!map || !geometryModule || !layerRef.current) return
if (!map || !layerRef.current) return
fitMapToHighlights(map, mode, buildHighlightSet(viewportKey), geometryModule)
fitMapToHighlights(map, buildHighlightSet(viewportKey), pathByKeyRef.current)
lastFittedViewportKeyRef.current = fitIdentity
}, [viewportKey, mode])

View file

@ -103,11 +103,6 @@ export const MapFeatureReadout = ({
{/* The bubble's own line: mixing "votes he got" and "votes at stake"
into one sentence is how a reader ends up quoting the wrong one. */}
{bubbleReading ? <p className="text-xs text-muted-foreground">{bubbleReading}</p> : null}
{navigation?.kind === 'zones' ? (
<p className="text-xs text-muted-foreground">
Toque de novo no mapa ou role até os Municípios por zona abaixo.
</p>
) : null}
</div>
{navigation?.kind === 'navigate' ? (

View file

@ -202,20 +202,20 @@ export const MunicipalityMapPanel = ({
}, [comparisonActive, displayValues, percentScaleActive])
const scopedKeys = useMemo(
() => Object.keys(bundle.municipalitiesByIbgeCode),
[bundle.municipalitiesByIbgeCode],
() => Object.keys(bundle.municipalitiesByMapKey),
[bundle.municipalitiesByMapKey],
)
/** LQ per município against his statewide standard — the same ratio E10 classes on. */
const lqByCode = useMemo(() => {
const lqByMapKey = useMemo(() => {
const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0
if (statewideShare <= 0) return {}
const lq: Record<string, number> = {}
for (const [code, votes] of Object.entries(rawValues)) {
const validVotes = validVotesForYear[code] ?? 0
for (const [mapKey, votes] of Object.entries(rawValues)) {
const validVotes = validVotesForYear[mapKey] ?? 0
if (votes <= 0 || validVotes <= 0) continue
lq[code] = votes / validVotes / statewideShare
lq[mapKey] = votes / validVotes / statewideShare
}
return lq
}, [bundle.statewideShareByYear, rawValues, validVotesForYear, year])
@ -230,12 +230,12 @@ export const MunicipalityMapPanel = ({
case 'quantile':
return buildQuantileClassing(rawValues)
case 'lq':
return buildLqClassing(lqByCode)
return buildLqClassing(lqByMapKey)
case 'competitiveRank':
return buildCompetitiveRankClassing(
Object.fromEntries(
Object.entries(competitiveRankForYear).map(([code, placement]) => [
code,
Object.entries(competitiveRankForYear).map(([mapKey, placement]) => [
mapKey,
placement.rank,
]),
),
@ -243,7 +243,7 @@ export const MunicipalityMapPanel = ({
default:
return null
}
}, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues])
}, [competitiveRankForYear, effectiveScaleMode, lqByMapKey, rawValues])
// A classing with no classes paints nothing, so the legend must fall through
// to the continuous ramp — passing `{}` here would tell `BahiaMap` the map is
@ -320,16 +320,16 @@ export const MunicipalityMapPanel = ({
*
* Both sides are already stable references, so there is nothing to memoize.
*/
const bubbleValues = showBubbles ? bundle.projectedValidVotesByCode : undefined
const bubbleValues = showBubbles ? bundle.projectedValidVotesByMapKey : undefined
const bubbleFillByKey = useMemo(() => {
if (!showBubbles) return undefined
const fills: ChoroplethFills = {}
for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) {
fills[code] = territorialClassMapFill[territorialClass]
for (const [mapKey, territorialClass] of Object.entries(bundle.territorialClassByMapKey)) {
fills[mapKey] = territorialClassMapFill[territorialClass]
}
return fills
}, [bundle.territorialClassByCode, showBubbles])
}, [bundle.territorialClassByMapKey, showBubbles])
// The key's reference circles and the map's radius denominator have to be
// the same number, or the key labels a size the map never draws.
@ -339,14 +339,14 @@ export const MunicipalityMapPanel = ({
(key: string): string | null => {
if (!showBubbles) return null
const projected = bundle.projectedValidVotesByCode[key] ?? 0
const projected = bundle.projectedValidVotesByMapKey[key] ?? 0
if (projected <= 0) return null
const territorialClass = bundle.territorialClassByCode[key]
const territorialClass = bundle.territorialClassByMapKey[key]
const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null
return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}`
},
[bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles],
[bundle.projectedValidVotesByMapKey, bundle.territorialClassByMapKey, showBubbles],
)
/**
@ -366,7 +366,7 @@ export const MunicipalityMapPanel = ({
return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}`
}
case 'lq': {
const lq = lqByCode[key]
const lq = lqByMapKey[key]
return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq)
}
case 'competitiveRank': {
@ -378,7 +378,7 @@ export const MunicipalityMapPanel = ({
return null
}
},
[classing, competitiveRankForYear, effectiveScaleMode, lqByCode],
[classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey],
)
// Optimistic on the control, honest pending on the map: the comparison data
@ -582,7 +582,7 @@ export const MunicipalityMapPanel = ({
relativeReadingFor={relativeReadingFor}
bubbleReadingFor={bubbleReadingFor}
comparisonActive={comparisonActive}
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
municipalitiesByMapKey={bundle.municipalitiesByMapKey}
ariaLabel={
comparisonActive && comparison
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
@ -592,11 +592,12 @@ export const MunicipalityMapPanel = ({
{/* zone breakdown below */}
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<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">
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
detalhadas aqui.
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) => (
@ -645,7 +646,7 @@ const MunicipalityMapSelection = ({
relativeReadingFor,
bubbleReadingFor,
comparisonActive,
municipalitiesByIbgeCode,
municipalitiesByMapKey,
ariaLabel,
}: {
displayValues: ChoroplethValues
@ -660,7 +661,7 @@ const MunicipalityMapSelection = ({
relativeReadingFor: (key: string) => string | null
bubbleReadingFor: (key: string) => string | null
comparisonActive: boolean
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
municipalitiesByMapKey: MunicipalityMapBundle['municipalitiesByMapKey']
ariaLabel: string
}) => {
const router = useRouter()
@ -676,20 +677,12 @@ const MunicipalityMapSelection = ({
(key: string) => {
if (selectedKeyRef.current !== key) return
const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByIbgeCode)
const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByMapKey)
if (navigation.kind === 'navigate') {
router.push(`/campanha/municipios/${navigation.slug}`)
return
}
if (navigation.kind === 'zones') {
document.getElementById('municipality-zone-breakdown')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
},
[municipalitiesByIbgeCode, router],
[municipalitiesByMapKey, router],
)
const selectedMetricValue =
@ -703,9 +696,9 @@ const MunicipalityMapSelection = ({
const selectedNavigation = useMemo(
() =>
selectedFeature
? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByIbgeCode)
? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByMapKey)
: null,
[municipalitiesByIbgeCode, selectedFeature],
[municipalitiesByMapKey, selectedFeature],
)
return (

View file

@ -1,21 +1,25 @@
/**
* Lazy loaders for Bahia map geometries (B3 / B5 F1).
* Lazy loaders for Bahia map geometries (B3 / B5 F1 / B8 F2).
*
* TopoJSON decode runs only when a map surface mounts — not on the default `/campanha` graph.
* Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`.
* Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`,
* `bahiaMunicipalityZoneGeometries.ts`.
*/
export type {
MunicipalityGeometryModule,
MunicipalityZoneGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
import type {
MunicipalityGeometryModule,
MunicipalityZoneGeometryModule,
TerritoryGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
let municipalityModulePromise: Promise<MunicipalityGeometryModule> | null = null
let municipalityZoneModulePromise: Promise<MunicipalityZoneGeometryModule> | null = null
let territoryModulePromise: Promise<TerritoryGeometryModule> | null = null
export const loadMunicipalityGeometryModule = (): Promise<MunicipalityGeometryModule> => {
@ -25,6 +29,13 @@ export const loadMunicipalityGeometryModule = (): Promise<MunicipalityGeometryMo
return municipalityModulePromise
}
export const loadMunicipalityZoneGeometryModule = (): Promise<MunicipalityZoneGeometryModule> => {
municipalityZoneModulePromise ??= import('@/lib/bahiaMunicipalityZoneGeometries').then(
(module) => module.municipalityZoneGeometryModule,
)
return municipalityZoneModulePromise
}
export const loadTerritoryGeometryModule = (): Promise<TerritoryGeometryModule> => {
territoryModulePromise ??= import('@/lib/bahiaTerritoryGeometries').then(
(module) => module.territoryGeometryModule,

View file

@ -1,4 +1,4 @@
import type { Feature, MultiPolygon, Polygon } from 'geojson'
import type { Feature, GeoJsonProperties, MultiPolygon, Polygon } from 'geojson'
import type { GeometryCollection, Topology } from 'topojson-specification'
type BahiaMunicipalityProperties = {
@ -11,10 +11,26 @@ type BahiaTerritoryProperties = {
name: string
}
/**
* Zone municipalities (Salvador ZE 1–19): keyed by the catalog slug, because a
* zone has no IBGE code of its own — the whole city shares `ibgeCode`.
*/
type MunicipalityZoneProperties = {
municipalitySlug: string
name: string
zoneNumber: number
ibgeCode: string
}
export type BahiaMunicipalityFeature = Feature<Polygon | MultiPolygon, BahiaMunicipalityProperties>
export type BahiaTerritoryFeature = Feature<Polygon | MultiPolygon, BahiaTerritoryProperties>
export type MunicipalityZoneFeature = Feature<Polygon | MultiPolygon, MunicipalityZoneProperties>
/** Any feature of the committed meshes — for helpers that only read coordinates. */
export type BahiaGeometryFeature = Feature<Polygon | MultiPolygon, GeoJsonProperties>
export type MunicipalityTopology = Topology<{
municipalities: GeometryCollection<BahiaMunicipalityProperties>
}>
@ -23,6 +39,10 @@ export type TerritoryTopology = Topology<{
territories: GeometryCollection<BahiaTerritoryProperties>
}>
export type MunicipalityZoneTopology = Topology<{
municipalityZones: GeometryCollection<MunicipalityZoneProperties>
}>
export type MunicipalityGeometryModule = {
topology: MunicipalityTopology
features: readonly BahiaMunicipalityFeature[]
@ -34,3 +54,9 @@ export type TerritoryGeometryModule = {
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'
export type FeatureKeyProperty = 'codarea' | 'code'
/** Properties that carry a map key, most specific first (see `featureMapKey`). */
const MAP_KEY_PROPERTIES = ['municipalitySlug', 'codarea', 'code'] as const
/**
* Feature key → fill, for the discrete relative scales (B13). When present it
@ -91,13 +92,22 @@ export const buildHighlightSet = (highlightKey: string): Set<string> =>
export const canonicalMapKeysKey = (keys: string[]): string =>
keys.length > 0 ? [...keys].sort().join(',') : ''
export const keyPropertyForMode = (mode: 'municipality' | 'territory'): FeatureKeyProperty =>
mode === 'municipality' ? 'codarea' : 'code'
export const featureKeyFromProperties = (
properties: Record<string, string> | undefined,
keyProperty: FeatureKeyProperty,
): string | undefined => properties?.[keyProperty]
/**
* 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.
*/
export const featureMapKey = (
properties: Record<string, unknown> | undefined | null,
): string | undefined => {
for (const property of MAP_KEY_PROPERTIES) {
const value = properties?.[property]
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => {
const classed = context.fillByKey !== undefined

View file

@ -0,0 +1,23 @@
import { feature } from 'topojson-client'
import type {
MunicipalityZoneFeature,
MunicipalityZoneGeometryModule,
MunicipalityZoneTopology,
} from '@/lib/bahiaGeometriesTypes'
import municipalityZoneTopologyJson from '@/lib/geometries/bahia-municipality-zones.topo.json'
const topology = municipalityZoneTopologyJson as unknown as MunicipalityZoneTopology
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),
}

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,7 @@
import type { Position } from 'geojson'
import type {
BahiaGeometryFeature,
BahiaMunicipalityFeature,
MunicipalityGeometryModule,
} from '@/lib/bahiaGeometriesTypes'
@ -51,10 +52,13 @@ export type NearbyMunicipalityResolution =
| { kind: 'inScope'; municipality: AccessibleMunicipality }
/**
* The point falls inside a city modeled as several zone municipalities
* (Salvador). Without zone polygons (B8 F2) the honest answer is the filtered
* list, never a guessed ZE. `zoneCount` is what the actor can open, so an
* advisor with three zones is not told there are nineteen. `ibgeCode` is how
* the caller finds the filtered-list href the server serialized for it.
* (Salvador). The answer is the filtered list, never a guessed ZE. B8 F2 has
* since drawn the zone polygons, so resolving the exact ZE became possible —
* this resolver deliberately still does not, because that is a separate
* decision (an open debt) rather than a side effect of the map delivery.
* `zoneCount` is what the actor can open, so an advisor with three zones is
* not told there are nineteen. `ibgeCode` is how the caller finds the
* filtered-list href the server serialized for it.
*/
| { kind: 'zoneCity'; city: string; ibgeCode: string; zoneCount: number }
/** Inside Bahia, but in a município outside the actor's portfolio. */
@ -105,7 +109,7 @@ const isPointInRing = (point: GeoPoint, ring: readonly Position[]): boolean => {
return inside
}
const polygonRingsOf = (feature: BahiaMunicipalityFeature): readonly Position[][][] =>
const polygonRingsOf = (feature: BahiaGeometryFeature): readonly Position[][][] =>
feature.geometry.type === 'Polygon'
? [feature.geometry.coordinates]
: feature.geometry.coordinates
@ -114,7 +118,7 @@ const polygonRingsOf = (feature: BahiaMunicipalityFeature): 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: BahiaMunicipalityFeature, point: GeoPoint): boolean =>
export const featureContainsPoint = (feature: BahiaGeometryFeature, point: GeoPoint): boolean =>
polygonRingsOf(feature).some(([outerRing, ...holes]) => {
if (!outerRing || !isPointInRing(point, outerRing)) return false
return !holes.some((hole) => isPointInRing(point, hole))
@ -125,7 +129,7 @@ export const featureContainsPoint = (feature: BahiaMunicipalityFeature, point: G
* 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: BahiaMunicipalityFeature): GeoPoint => {
export const featureCentroid = (feature: BahiaGeometryFeature): GeoPoint => {
let weightedLng = 0
let weightedLat = 0
let totalArea = 0

View file

@ -6,6 +6,10 @@
* https://www.tre-ba.jus.br/legislacao/compilada/resolucao/2017/resolucao-administrativa-no-2-de-10-de-maio-de-2017
* (accessed 2026-07-21)
*
* Rows author the slug and the neighborhood list only: `city` and `zoneNumber`
* are hydrated from `municipalityCatalog`, which is also what makes an unknown
* or non-zona slug fail at import instead of drifting silently.
*
* Coverage is independently checked by tests/fixtures/municipality-zone-neighborhoods.official.json.
*/
@ -13,11 +17,9 @@ import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
export type MunicipalityZoneNeighborhoodSource = 'tre-ra-02-2017'
type MunicipalityZoneNeighborhoodCity = 'Salvador'
export type MunicipalityZoneNeighborhoodEntry = {
readonly municipalitySlug: string
readonly city: MunicipalityZoneNeighborhoodCity
readonly city: string
readonly zoneNumber: number
readonly source: MunicipalityZoneNeighborhoodSource
readonly neighborhoods: readonly string[]
@ -28,20 +30,25 @@ const sortNeighborhoods = (names: readonly string[]): readonly string[] =>
const entry = (
municipalitySlug: string,
city: MunicipalityZoneNeighborhoodCity,
zoneNumber: number,
source: MunicipalityZoneNeighborhoodSource,
neighborhoods: readonly string[],
): MunicipalityZoneNeighborhoodEntry => ({
municipalitySlug,
city,
zoneNumber,
source,
neighborhoods: sortNeighborhoods(neighborhoods),
})
): MunicipalityZoneNeighborhoodEntry => {
const catalogEntry = getMunicipalityCatalogEntry(municipalitySlug)
if (!catalogEntry || catalogEntry.kind !== 'zona' || catalogEntry.zoneNumber === undefined) {
throw new Error(`Missing zona municipality catalog entry for slug: ${municipalitySlug}`)
}
return {
municipalitySlug,
city: catalogEntry.city,
zoneNumber: catalogEntry.zoneNumber,
source,
neighborhoods: sortNeighborhoods(neighborhoods),
}
}
export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoodEntry[] = [
entry('salvador-ze-1', 'Salvador', 1, 'tre-ra-02-2017', [
entry('salvador-ze-1', 'tre-ra-02-2017', [
'Alto das Pombas',
'Barra',
'Calabar',
@ -52,7 +59,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Graça',
'Vitória',
]),
entry('salvador-ze-2', 'Salvador', 2, 'tre-ra-02-2017', [
entry('salvador-ze-2', 'tre-ra-02-2017', [
'Amaralina',
'Chapada do Rio Vermelho',
'Engenho Velho da Federação',
@ -60,7 +67,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Ondina',
'Rio Vermelho',
]),
entry('salvador-ze-3', 'Salvador', 3, 'tre-ra-02-2017', [
entry('salvador-ze-3', 'tre-ra-02-2017', [
'Baixa de Quintas',
'Cidade Nova',
'Fazenda Grande do Retiro',
@ -69,7 +76,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Pero Vaz',
'Santa Mônica',
]),
entry('salvador-ze-4', 'Salvador', 4, 'tre-ra-02-2017', [
entry('salvador-ze-4', 'tre-ra-02-2017', [
'Coutos',
'Fazenda Coutos',
'Ilha de Bom Jesus dos Passos',
@ -80,7 +87,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Periperi (parte ao norte da Rua das Pedrinhas)',
'São Tomé',
]),
entry('salvador-ze-5', 'Salvador', 5, 'tre-ra-02-2017', [
entry('salvador-ze-5', 'tre-ra-02-2017', [
'Barreiras',
'Cabula',
'Engomadeira',
@ -89,14 +96,14 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Retiro',
'São Gonçalo',
]),
entry('salvador-ze-6', 'Salvador', 6, 'tre-ra-02-2017', [
entry('salvador-ze-6', 'tre-ra-02-2017', [
'Acupe',
'Boa Vista de Brotas',
'Brotas',
'Candeal',
'Engenho Velho de Brotas',
]),
entry('salvador-ze-7', 'Salvador', 7, 'tre-ra-02-2017', [
entry('salvador-ze-7', 'tre-ra-02-2017', [
'Barbalho',
'Barris',
'Comércio',
@ -111,7 +118,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Tororó',
'Vila Laura',
]),
entry('salvador-ze-8', 'Salvador', 8, 'tre-ra-02-2017', [
entry('salvador-ze-8', 'tre-ra-02-2017', [
'Cajazeiras II',
'Cajazeiras IV',
'Cajazeiras V',
@ -125,7 +132,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Pirajá',
'Valéria',
]),
entry('salvador-ze-9', 'Salvador', 9, 'tre-ra-02-2017', [
entry('salvador-ze-9', 'tre-ra-02-2017', [
'Boa Viagem',
'Bonfim',
'Jardim Cruzeiro',
@ -137,7 +144,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Uruguai',
'Vila Ruy Barbosa',
]),
entry('salvador-ze-10', 'Salvador', 10, 'tre-ra-02-2017', [
entry('salvador-ze-10', 'tre-ra-02-2017', [
'Bairro da Paz',
'Boca do Rio',
'Costa Azul',
@ -148,7 +155,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Pituaçu',
'STIEP',
]),
entry('salvador-ze-11', 'Salvador', 11, 'tre-ra-02-2017', [
entry('salvador-ze-11', 'tre-ra-02-2017', [
'Águas Claras',
'Calabetão',
'Castelo Branco',
@ -161,7 +168,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Sete de Abril',
'Vila Canária',
]),
entry('salvador-ze-12', 'Salvador', 12, 'tre-ra-02-2017', [
entry('salvador-ze-12', 'tre-ra-02-2017', [
'Aeroporto',
'Alto do Coqueirinho',
'Areia Branca',
@ -171,7 +178,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'São Cristóvão',
'Stella Maris',
]),
entry('salvador-ze-13', 'Salvador', 13, 'tre-ra-02-2017', [
entry('salvador-ze-13', 'tre-ra-02-2017', [
'Caminho das Árvores',
'Itaigara',
'Nordeste de Amaralina',
@ -179,14 +186,14 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Santa Cruz',
'Vale das Pedrinhas',
]),
entry('salvador-ze-14', 'Salvador', 14, 'tre-ra-02-2017', [
entry('salvador-ze-14', 'tre-ra-02-2017', [
'Arenoso',
'Novo Horizonte',
'São Marcos',
'Sussuarana',
'Tancredo Neves',
]),
entry('salvador-ze-15', 'Salvador', 15, 'tre-ra-02-2017', [
entry('salvador-ze-15', 'tre-ra-02-2017', [
'Arraial do Retiro',
'Boa Vista de São Caetano',
'Bom Juá',
@ -194,7 +201,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Lobato',
'São Caetano',
]),
entry('salvador-ze-16', 'Salvador', 16, 'tre-ra-02-2017', [
entry('salvador-ze-16', 'tre-ra-02-2017', [
'Cabula VI',
'Canabrava',
'Centro Administrativo da Bahia',
@ -207,7 +214,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Saramandaia',
'Vale dos Lagos',
]),
entry('salvador-ze-17', 'Salvador', 17, 'tre-ra-02-2017', [
entry('salvador-ze-17', 'tre-ra-02-2017', [
'Alto da Terezinha',
'Alto do Cabrito',
'Itacaranha',
@ -217,7 +224,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Rio Sena',
'São João do Cabrito',
]),
entry('salvador-ze-18', 'Salvador', 18, 'tre-ra-02-2017', [
entry('salvador-ze-18', 'tre-ra-02-2017', [
"Caixa D'água",
'Calçada',
'Caminho de Areia',
@ -227,7 +234,7 @@ export const municipalityZoneNeighborhoods: readonly MunicipalityZoneNeighborhoo
'Mares',
'Roma',
]),
entry('salvador-ze-19', 'Salvador', 19, 'tre-ra-02-2017', [
entry('salvador-ze-19', 'tre-ra-02-2017', [
'Boca da Mata',
'Cajazeiras X',
'Cajazeiras XI',
@ -264,19 +271,3 @@ export const municipalityZoneNeighborhoodSourceLabel = (
return source satisfies never
}
}
const assertMunicipalityZoneNeighborhoodCatalogIntegrity = (): void => {
for (const record of municipalityZoneNeighborhoods) {
const catalogEntry = getMunicipalityCatalogEntry(record.municipalitySlug)
if (!catalogEntry || catalogEntry.kind !== 'zona') {
throw new Error(
`Missing zona municipality catalog entry for slug: ${record.municipalitySlug}`,
)
}
if (catalogEntry.city !== record.city || catalogEntry.zoneNumber !== record.zoneNumber) {
throw new Error(`Catalog mismatch for slug: ${record.municipalitySlug}`)
}
}
}
assertMunicipalityZoneNeighborhoodCatalogIntegrity()

View file

@ -1,7 +1,7 @@
import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates'
import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import type { MunicipalitiesByMapKey } from '@/utilities/municipalityMapNavigation'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/**
@ -89,16 +89,22 @@ type MunicipalityZoneBreakdownRow = {
export type MunicipalityMapComparison = {
candidateNumber: number
candidateName: string
/** TSE year (as string) → codarea → (sollaVotes − otherVotes). */
/** TSE year (as string) → map key → (sollaVotes − otherVotes). */
diffByYear: Record<string, Record<string, number>>
}
/**
* Every record below is keyed by MAP KEY, not by IBGE codarea: since B8 F2 the
* map paints one polygon per catalog unit, so Salvador's 19 zones are addressed
* by slug (`salvador-ze-3`) and every other município by its codarea. See
* `mapKeyForMunicipality`.
*/
export type MunicipalityMapBundle = {
/** year (as string) → codarea → value. 2026 = cenário médio (central). */
/** year (as string) → map key → value. 2026 = cenário médio (central). */
valuesByYear: Record<string, Record<string, number>>
/** 2026 totals per estimate scenario (codarea → votes). */
/** 2026 totals per estimate scenario (map key → votes). */
values2026ByScenario: Record<VoteEstimateScenario, Record<string, number>>
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
/** year (as string) → map key → votosValidos (federal T1). 2026 reuses 2022. */
validVotesByYear: Record<string, Record<string, number>>
/**
* year (as string) → his own STATEWIDE share of valid votes: the "1×" the LQ
@ -107,14 +113,18 @@ export type MunicipalityMapBundle = {
* the same standard. 2026 reuses the 2022 standard, like `validVotesByYear`.
*/
statewideShareByYear: Record<string, number>
/** codarea → E10 territorial class, aggregated over the slugs in scope. */
territorialClassByCode: Record<string, MunicipalityTerritorialClass>
/** codarea → his placement among the federal-deputy candidates voted there. */
/** map key → E10 territorial class of that unit. */
territorialClassByMapKey: Record<string, MunicipalityTerritorialClass>
/**
* map key → his placement among the federal-deputy candidates voted there. The
* TSE artifact ranks by codarea only, so Salvador's zones all carry the city's
* position (the legend says so).
*/
competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>>
/** codarea → projected 2026 valid votes (E8) — the size of the proportional symbol. */
projectedValidVotesByCode: Record<string, number>
/** IBGE codarea → accessible municipality slugs for map click navigation. */
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode
/** map key → projected 2026 valid votes (E8) — the size of the proportional symbol. */
projectedValidVotesByMapKey: Record<string, number>
/** map key → the municipality it opens on click. */
municipalitiesByMapKey: MunicipalitiesByMapKey
/** Zone municipalities in scope (Salvador) with per-year values. */
zoneBreakdown: MunicipalityZoneBreakdownRow[]
candidateName: string

View file

@ -36,10 +36,13 @@ import type {
MunicipalityMapBundle,
MunicipalityMapComparison,
} from '@/utilities/municipalityMapContract'
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import {
buildMunicipalitiesByMapKey,
mapKeyForMunicipality,
} from '@/utilities/municipalityMapNavigation'
import { projectedValidVotes } from '@/utilities/municipalityPotential'
import {
computeAggregateTerritorialClass,
computeMunicipalityTerritorialClass,
type MunicipalityTerritorialClass,
} from '@/utilities/municipalityTerritorialClass'
import {
@ -104,9 +107,9 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const statewideShareByYear: Record<string, number> = {}
const competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>> = {}
const zoneVotesBySlug = new Map<string, Record<string, number>>()
// One polygon per IBGE code, N catalog slugs behind it (Salvador is 19) —
// the same index the map uses to navigate, reused here for the rollups.
const municipalitiesByIbgeCode = buildMunicipalitiesByIbgeCode(municipalities)
// One polygon per catalog unit since B8 F2, so this index — the same one the
// map navigates by — is also the key of every rollup below.
const municipalitiesByMapKey = buildMunicipalitiesByMapKey(municipalities)
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
@ -114,11 +117,11 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const values: Record<string, number> = {}
const validValues: Record<string, number> = {}
for (const municipality of municipalities) {
const mapKey = mapKeyForMunicipality(municipality)
const baseline = getMunicipalityFederalBaseline(municipality.slug)
const votes = baseline.votesByYear[String(year)] ?? 0
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + votes
const valid = baseline.validVotesByYear[String(year)] ?? 0
validValues[municipality.ibgeCode] = (validValues[municipality.ibgeCode] ?? 0) + valid
values[mapKey] = votes
validValues[mapKey] = baseline.validVotesByYear[String(year)] ?? 0
if (municipality.kind === 'zona') {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
bySlug[String(year)] = votes
@ -135,10 +138,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
statewideShareByYear[String(year)] =
statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0
// Rank exists per city only, so every zone of Salvador carries the city's
// position — stated in the legend rather than faked per zone.
const ranks: Record<string, FederalCompetitiveRank> = {}
for (const ibgeCode of Object.keys(municipalitiesByIbgeCode)) {
const rank = getFederalCompetitiveRank(ibgeCode, year)
if (rank) ranks[ibgeCode] = rank
for (const municipality of municipalities) {
const rank = getFederalCompetitiveRank(municipality.ibgeCode, year)
if (rank) ranks[mapKeyForMunicipality(municipality)] = rank
}
competitiveRankByYear[String(year)] = ranks
}
@ -148,14 +153,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
// substitution `validVotesByYear` already makes.
statewideShareByYear['2026'] = statewideShareByYear['2022'] ?? 0
const territorialClassByCode: Record<string, MunicipalityTerritorialClass> = {}
const projectedValidVotesByCode: Record<string, number> = {}
for (const [ibgeCode, entries] of Object.entries(municipalitiesByIbgeCode)) {
const slugs = entries.map((entry) => entry.slug)
territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class
projectedValidVotesByCode[ibgeCode] = slugs.reduce(
(total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)),
0,
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),
)
}
@ -178,8 +181,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
scenario,
)
if (votes > 0) {
pledgeValuesByScenario[scenario][municipality.ibgeCode] =
(pledgeValuesByScenario[scenario][municipality.ibgeCode] ?? 0) + votes
pledgeValuesByScenario[scenario][mapKeyForMunicipality(municipality)] = votes
}
if (municipality.kind === 'zona') {
const byScenario = zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes()
@ -239,7 +241,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const solla =
getMunicipalityFederalBaseline(municipality.slug).votesByYear[String(year)] ?? 0
const other = sumVotesForGeography(otherVotes, municipality.geography)
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + (solla - other)
values[mapKeyForMunicipality(municipality)] = solla - other
}
diffByYear[String(year)] = values
}),
@ -258,10 +260,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
values2026ByScenario: pledgeValuesByScenario,
validVotesByYear,
statewideShareByYear,
territorialClassByCode,
territorialClassByMapKey,
competitiveRankByYear,
projectedValidVotesByCode,
municipalitiesByIbgeCode,
projectedValidVotesByMapKey,
municipalitiesByMapKey,
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,

View file

@ -5,12 +5,28 @@ type MunicipalityMapSlugEntry = {
name: string
}
/** IBGE codarea → the accessible catalog units inside it (Salvador has 19). */
export type MunicipalitiesByIbgeCode = Record<string, MunicipalityMapSlugEntry[]>
export type MunicipalityMapNavigation =
| { kind: 'none' }
| { kind: 'navigate'; slug: string }
| { kind: 'zones' }
/**
* 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.
*/
export type MunicipalitiesByMapKey = Record<string, MunicipalityMapSlugEntry>
export type MunicipalityMapNavigation = { kind: 'none' } | { kind: 'navigate'; slug: string }
/**
* What the choropleth paints a unit by: a zone municipality has no code of its
* own (the whole city shares one), so it is keyed by its immutable catalog slug,
* which is also the polygon's key in `bahia-municipality-zones.topo.json`.
*/
export const mapKeyForMunicipality = (municipality: {
kind: 'municipio' | 'zona'
slug: string
ibgeCode: string
}): string => (municipality.kind === 'zona' ? municipality.slug : municipality.ibgeCode)
export const buildMunicipalitiesByIbgeCode = (
municipalities: readonly AccessibleMunicipality[],
@ -30,12 +46,25 @@ export const buildMunicipalitiesByIbgeCode = (
return byIbge
}
export const resolveMunicipalityMapNavigation = (
ibgeCode: string,
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode,
): MunicipalityMapNavigation => {
const entries = municipalitiesByIbgeCode[ibgeCode]
if (!entries || entries.length === 0) return { kind: 'none' }
if (entries.length === 1) return { kind: 'navigate', slug: entries[0].slug }
return { kind: 'zones' }
export const buildMunicipalitiesByMapKey = (
municipalities: ReadonlyArray<AccessibleMunicipality & { kind: 'municipio' | 'zona' }>,
): MunicipalitiesByMapKey => {
const byMapKey: MunicipalitiesByMapKey = {}
for (const municipality of municipalities) {
byMapKey[mapKeyForMunicipality(municipality)] = {
slug: municipality.slug,
name: municipality.name,
}
}
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

@ -0,0 +1,63 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B8 F2 — Salvador used to be one polygon carrying the whole city's votes, so
* the choropleth could not answer "which piece of Salvador is weak / is mine".
* This pins the two halves of that fix: the zone mesh is drawn (one path per
* ZE, the city's own polygon left underneath as a non-interactive base) and a
* zone is addressable on its own — the readout names it and opens it.
*/
test('the dashboard map paints Salvador zone by zone and opens one', async ({ campaign, page }) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const coordinator = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Mapa'),
email: `${fixtures.value('map-coordinator')}@example.com`,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, coordinator.email!, password)
await expect(page.getByRole('heading', { name: 'Mapa dos Municípios' })).toBeVisible({
timeout: 60_000,
})
await page.waitForSelector('path.leaflet-interactive', { timeout: 60_000 })
// 417 municípios + 19 Salvador zones; Salvador's own polygon is the one path
// that stays out of the pointer's way.
await expect
.poll(
() =>
page.evaluate(() => {
const paths = Array.from(document.querySelectorAll('.leaflet-overlay-pane path'))
return {
total: paths.length,
interactive: paths.filter((path) => path.classList.contains('leaflet-interactive'))
.length,
}
}),
{ timeout: 30_000 },
)
.toEqual({ total: 436, interactive: 435 })
// Insertion order is z-order, so the zone mesh is the tail of the pane.
await page.evaluate(() => {
const paths = Array.from(document.querySelectorAll('.leaflet-overlay-pane path'))
paths.at(-1)?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
})
const readout = page.locator('div[aria-live="polite"]').first()
await expect(readout).toContainText(/Salvador — ZE \d+/)
await readout.getByRole('link', { name: 'Abrir município' }).click()
// The slug is the assertion: the detail page's own <h1> reads the municipality
// ROW, which other specs rename, while the polygon's name comes from the
// static catalog.
await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})

View file

@ -4,14 +4,22 @@ import { statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Position } from 'geojson'
import { describe, expect, it } from 'vitest'
import { loadMunicipalityGeometryModule, loadTerritoryGeometryModule } from '@/lib/bahiaGeometries'
import type { BahiaGeometryFeature } from '@/lib/bahiaGeometriesTypes'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { bahiaMunicipalityCodes } from '@/lib/bahiaMunicipalityCodes'
import { bahiaIdentityTerritoryRecords } from '@/lib/bahiaTerritories'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import {
featureCentroid,
featureContainsPoint,
findContainingMunicipality,
resolveNearbyMunicipality,
} from '@/lib/municipalityProximity'
@ -21,6 +29,9 @@ import { featureBounds } from '../helpers/featureBounds'
/** Soft ceiling against accidental unsimplified meshes (plan target ≤ ~600 KB). */
const MAX_TOPO_BYTES = 600 * 1024
/** 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
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..')
const hasNonEmptyGeometry = (
@ -31,6 +42,32 @@ const hasNonEmptyGeometry = (
return true
}
/** 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 ringArea = (ring: readonly Position[]): number => {
let sum = 0
for (
let current = 0, previous = ring.length - 1;
current < ring.length;
previous = current++
) {
sum += ring[previous][0] * ring[current][1] - ring[current][0] * ring[previous][1]
}
return Math.abs(sum) / 2
}
return (
total + ringArea(outerRing) - holes.reduce((holeTotal, hole) => holeTotal + ringArea(hole), 0)
)
}, 0)
}
describe('Bahia static geometries', () => {
it('keeps committed TopoJSON under the size budget', () => {
const municipalityBytes = statSync(
@ -39,11 +76,16 @@ describe('Bahia static geometries', () => {
const territoryBytes = statSync(
join(repoRoot, 'src/lib/geometries/bahia-identity-territories.topo.json'),
).size
const zoneBytes = statSync(
join(repoRoot, 'src/lib/geometries/bahia-municipality-zones.topo.json'),
).size
expect(municipalityBytes).toBeGreaterThan(10_000)
expect(territoryBytes).toBeGreaterThan(1_000)
expect(zoneBytes).toBeGreaterThan(10_000)
expect(municipalityBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(territoryBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
expect(zoneBytes).toBeLessThanOrEqual(MAX_TOPO_BYTES)
})
it('exposes 417 municipality features whose codarea set matches the code table', async () => {
@ -87,6 +129,72 @@ describe('Bahia static geometries', () => {
expect(getTerritoryFeature('99')).toBeUndefined()
})
/**
* B8 F2 — the 19 Salvador zones are dissolved from the IBGE neighborhood mesh,
* a different source than the municipal mesh above, so what is pinned here is
* the contract the map depends on: one feature per catalog zona, keyed by slug,
* geometrically inside the city it decomposes.
*/
describe('zone municipality geometries (B8 F2)', () => {
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()
expect(Object.keys(topology.objects)).toEqual(['municipalityZones'])
expect(topology.objects.municipalityZones.geometries).toHaveLength(zoneCatalog.length)
expect(features).toHaveLength(zoneCatalog.length)
for (const entry of zoneCatalog) {
const zone = getMunicipalityZoneFeature(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)
}
expect(getMunicipalityZoneFeature('feira-de-santana')).toBeUndefined()
})
it('keeps every zone inside the municipality it decomposes', async () => {
const [{ getMunicipalityFeature }, { features }] = await Promise.all([
loadMunicipalityGeometryModule(),
loadMunicipalityZoneGeometryModule(),
])
for (const zone of features) {
const city = getMunicipalityFeature(zone.properties.ibgeCode)
expect(city, zone.properties.municipalitySlug).toBeDefined()
expect(
featureContainsPoint(city!, featureCentroid(zone)),
zone.properties.municipalitySlug,
).toBe(true)
}
})
/**
* Salvador's land is ~324 km². It is NOT compared against the municipal
* polygon: that one measures ~690 km², matching the official IBGE area,
* because the municipal limits enclose the bay water around the islands —
* water the neighborhood mesh does not draw.
*
* Since each neighborhood polygon is assigned to exactly one zone, the sum is
* also the overlap check: a neighborhood counted twice, or dropped, moves this
* by its own area.
*/
it('sums to Salvador’s land area, so no neighborhood is dropped or counted twice', async () => {
const { features } = await loadMunicipalityZoneGeometryModule()
const zoneKm2 =
features.reduce((total, zone) => total + featureArea(zone), 0) * SQUARE_DEGREE_KM2
expect(zoneKm2).toBeGreaterThan(280)
expect(zoneKm2).toBeLessThan(360)
})
})
/**
* B14 resolves "onde estou" against this same mesh, so the containment used in
* the field is pinned here with real coordinates — the synthetic squares in

View file

@ -10,10 +10,10 @@ import { loadMunicipalityMapBundle } from '@/utilities/municipalityMapData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
const ZONE_MUNICIPALITY_COUNT = municipalityCatalog.filter((entry) => entry.kind === 'zona').length
const ZONE_MUNICIPALITY_CODES = new Set(
municipalityCatalog.filter((entry) => entry.kind === 'zona').map((entry) => entry.ibgeCode),
)
const zoneMunicipalities = municipalityCatalog.filter((entry) => entry.kind === 'zona')
const ZONE_MUNICIPALITY_COUNT = zoneMunicipalities.length
const ZONE_MUNICIPALITY_SLUGS = new Set(zoneMunicipalities.map((entry) => entry.slug))
const ZONE_MUNICIPALITY_CODES = new Set(zoneMunicipalities.map((entry) => entry.ibgeCode))
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@ -28,7 +28,7 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
payload = await getPayload({ config: await config })
})
it('scopes the map to zone municipalities when kind=zona is in the URL', async () => {
it('paints each zone under its own map key, never pooled under the city code', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const bundle = await loadMunicipalityMapBundle(payload, coordinator, { kind: 'zona' })
@ -36,11 +36,18 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
expect(bundle).not.toBeNull()
expect(bundle!.zoneBreakdown).toHaveLength(ZONE_MUNICIPALITY_COUNT)
const ibgeCodes = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(ibgeCodes.length).toBeGreaterThan(0)
for (const code of ibgeCodes) {
expect(ZONE_MUNICIPALITY_CODES.has(code)).toBe(true)
const mapKeys = Object.keys(bundle!.valuesByYear['2022'] ?? {})
expect(mapKeys.length).toBeGreaterThan(0)
for (const key of mapKeys) {
expect(ZONE_MUNICIPALITY_SLUGS.has(key), key).toBe(true)
expect(ZONE_MUNICIPALITY_CODES.has(key), key).toBe(false)
}
expect(Object.keys(bundle!.municipalitiesByMapKey).sort()).toEqual([...mapKeys].sort())
// The zones share the city's TSE rank — the artifact ranks by codarea only.
const ranks = bundle!.competitiveRankByYear['2022'] ?? {}
const positions = new Set(mapKeys.map((key) => ranks[key]?.rank))
expect(positions.size).toBe(1)
})
it('returns null when the filtered municipality set is empty', async () => {
@ -62,9 +69,9 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
expect(included!.zoneBreakdown.some((row) => row.slug === administered.slug)).toBe(
administered.kind === 'zona',
)
if (administered.kind !== 'zona') {
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([administered.ibgeCode])
}
expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([
administered.kind === 'zona' ? administered.slug : administered.ibgeCode,
])
const excluded =
administered.kind === 'zona'

View file

@ -16,7 +16,8 @@ type OfficialEvidence = {
zoneCount: number
assignments: Array<{
municipalitySlug: string
city: 'Salvador' | 'Camaçari'
/** Hydrated from the catalog since Fase 0 of B8 F2, so the fixture no longer narrows it. */
city: string
zoneNumber: number
source: 'tre-ra-02-2017' | 'tre-voting-locations-curated'
neighborhoods: string[]
@ -54,12 +55,19 @@ describe('Municipality zone neighborhood catalog (Salvador only)', () => {
for (const municipality of zoneMunicipalities) {
const record = municipalityZoneNeighborhoodEntryForSlug(municipality.slug)
expect(record, municipality.slug).toBeDefined()
expect(record?.city).toBe(municipality.city)
expect(record?.zoneNumber).toBe(municipality.zoneNumber)
// `city`/`zoneNumber` are copied from this same catalog, so comparing them
// here could not fail. The fixture test below is what actually checks them,
// against an independent transcription of the resolution.
expect(record?.neighborhoods.length, municipality.slug).toBeGreaterThan(0)
}
})
it('lists zones in catalog order', () => {
expect(municipalityZoneNeighborhoods.map((entry) => entry.municipalitySlug)).toEqual(
zoneMunicipalities.map((entry) => entry.slug),
)
})
it('keeps Salvador neighborhoods exclusive across zones', () => {
const seen = new Map<string, string>()

View file

@ -4,6 +4,7 @@ import {
buildLayerStyleContext,
canonicalMapKeysKey,
computeChoroplethMax,
featureMapKey,
resolvePathStyle,
} from '@/lib/bahiaMapStyle'
@ -29,6 +30,34 @@ 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 município by codarea and an identity territory by code', () => {
expect(featureMapKey({ codarea: '2905701', name: 'Barreiras' })).toBe('2905701')
expect(featureMapKey({ code: 'chapada-diamantina', name: 'Chapada Diamantina' })).toBe(
'chapada-diamantina',
)
})
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()
expect(featureMapKey({ codarea: '' })).toBeUndefined()
expect(featureMapKey({ codarea: 2905701 })).toBeUndefined()
})
})
describe('canonicalMapKeysKey', () => {
it('sorts keys for stable identity', () => {
expect(canonicalMapKeysKey(['2927408', '2905701'])).toBe('2905701,2927408')

View file

@ -2,50 +2,69 @@ import { describe, expect, it } from 'vitest'
import {
buildMunicipalitiesByIbgeCode,
buildMunicipalitiesByMapKey,
mapKeyForMunicipality,
resolveMunicipalityMapNavigation,
} from '@/utilities/municipalityMapNavigation'
const abaira = { slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108', kind: 'municipio' } as const
const salvadorZe1 = {
slug: 'salvador-ze-1',
name: 'Salvador — ZE 1',
ibgeCode: '2927408',
kind: 'zona',
} as const
const salvadorZe2 = {
slug: 'salvador-ze-2',
name: 'Salvador — ZE 2',
ibgeCode: '2927408',
kind: 'zona',
} as const
describe('municipalityMapNavigation', () => {
it('buildMunicipalitiesByIbgeCode groups municipalities by ibgeCode and sorts by name', () => {
const result = buildMunicipalitiesByIbgeCode([
{ slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' },
{ slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' },
{ slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' },
])
const result = buildMunicipalitiesByIbgeCode([salvadorZe2, salvadorZe1, abaira])
expect(result['2900108']).toEqual([{ slug: 'abaira', name: 'Abaíra' }])
expect(result['2927408']).toEqual([
{ slug: 'salvador-ze-1', name: 'Salvador ZE 1' },
{ slug: 'salvador-ze-2', name: 'Salvador ZE 2' },
{ slug: 'salvador-ze-1', name: 'Salvador — ZE 1' },
{ slug: 'salvador-ze-2', name: 'Salvador — ZE 2' },
])
})
it('resolveMunicipalityMapNavigation returns none when ibgeCode is missing or empty', () => {
const byIbge = buildMunicipalitiesByIbgeCode([
{ slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' },
])
expect(resolveMunicipalityMapNavigation('9999999', byIbge)).toEqual({ kind: 'none' })
expect(resolveMunicipalityMapNavigation('2900108', {})).toEqual({ kind: 'none' })
it('keys zone municipalities by slug and everything else by codarea', () => {
expect(mapKeyForMunicipality(abaira)).toBe('2900108')
expect(mapKeyForMunicipality(salvadorZe1)).toBe('salvador-ze-1')
})
it('resolveMunicipalityMapNavigation returns navigate for a single municipality', () => {
const byIbge = buildMunicipalitiesByIbgeCode([
{ slug: 'abaira', name: 'Abaíra', ibgeCode: '2900108' },
])
it('gives every zone its own map key instead of pooling them under the city', () => {
const byMapKey = buildMunicipalitiesByMapKey([salvadorZe1, salvadorZe2, abaira])
expect(resolveMunicipalityMapNavigation('2900108', byIbge)).toEqual({
kind: 'navigate',
slug: 'abaira',
expect(Object.keys(byMapKey).sort()).toEqual(['2900108', 'salvador-ze-1', 'salvador-ze-2'])
expect(byMapKey['2927408']).toBeUndefined()
expect(byMapKey['salvador-ze-2']).toEqual({
slug: 'salvador-ze-2',
name: 'Salvador — ZE 2',
})
})
it('resolveMunicipalityMapNavigation returns zones for multiple municipalities on the same ibgeCode', () => {
const byIbge = buildMunicipalitiesByIbgeCode([
{ slug: 'salvador-ze-1', name: 'Salvador ZE 1', ibgeCode: '2927408' },
{ slug: 'salvador-ze-2', name: 'Salvador ZE 2', ibgeCode: '2927408' },
])
it('resolveMunicipalityMapNavigation returns none for an unknown or empty key', () => {
const byMapKey = buildMunicipalitiesByMapKey([abaira])
expect(resolveMunicipalityMapNavigation('2927408', byIbge)).toEqual({ kind: 'zones' })
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',
})
})
})