Merge branch 'cursor/a2a2fd95'
This commit is contained in:
commit
1d3ec3c772
27 changed files with 1130 additions and 277 deletions
File diff suppressed because one or more lines are too long
13
AGENTS.md
13
AGENTS.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 | |
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
410
scripts/build-municipality-zone-geometries.mjs
Normal file
410
scripts/build-municipality-zone-geometries.mjs
Normal 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()
|
||||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
23
src/lib/bahiaMunicipalityZoneGeometries.ts
Normal file
23
src/lib/bahiaMunicipalityZoneGeometries.ts
Normal 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),
|
||||
}
|
||||
1
src/lib/geometries/bahia-municipality-zones.topo.json
Normal file
1
src/lib/geometries/bahia-municipality-zones.topo.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
63
tests/e2e/campaignZoneMap.e2e.spec.ts
Normal file
63
tests/e2e/campaignZoneMap.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue