Merge branch 'cursor/0578e7e1'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 05:13:09 -03:00
commit e0d0936fab
34 changed files with 7278 additions and 118 deletions

View file

@ -7,6 +7,15 @@ alwaysApply: false
## Status
- **B13 entregue em código (2026-07-26):** escala relativa no mapa do Início. O coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71% — três escalas **relativas ao próprio candidato**, em **classes discretas** (nunca rampa contínua): **quantis** (novo default), **padrão próprio (LQ)** e **posição no município**, ao lado de "% dos válidos" (B11) e "Total (votos)". **Auditoria do plano antes de codar** achou três defasagens, corrigidas em [`escala-relativa-mapa.md`](../../../docs/plans/escala-relativa-mapa.md) § Revisão na entrega: caminhos pós-Pass 2 W2 (`components/campaign/map/`), o nome inexistente `computeDfTerritorialClass` (é `computeMunicipalityTerritorialClass`) e a premissa "o mapa acompanha a lista" (desde a remodelagem o mapa só existe no Início) — resolvida como **quantis sobre o escopo de acesso do usuário**, decisão do usuário nesta sessão. Resolvi também a **contradição interna do plano** sobre LQ: os Objetivos pediam diverging em torno de 1 e as Decisões travadas reservavam diverging ao compare — ficou com as Decisões travadas, LQ como 5 classes nomeadas nas âncoras do E10, porque vermelho × azul significa "Solla × adversário" e não pode passar a significar duas coisas.
**Artefato v3 (decisão cara do usuário: construir).** "Posição no município" não existia nem no artefato nem em query reaproveitável, e computá-la em request seria varrer ~100 mil linhas por ano para um dado imutável. Foi para o artefato commitado (regra 3 da escada de cache, como o E8 na v2): `federalRankByIbgeCode: Record<codarea, Record<year, { rank, candidates }>>` — colocação entre **todos** os candidatos com votos > 0 na geografia, 2014/2018/2022, alimentada por `loadFederalVotesByCityZoneAndCandidate` (irmão CLI-only de `loadCampoFederalVotesByCityZone`). Chaveado por `ibgeCode` e não por slug porque é o que o mapa desenha (as 19 zonas de Salvador somam a cidade inteira); rank por slug entra quando dossiê/E11 pedirem. 534 → 623 KB, dentro do orçamento de 700 KB. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022.
**Peças novas que outros itens herdam.** `computeAggregateTerritorialClass(slugs)` classifica um grupo como um território só — soma os insumos e roda o **mesmo** `classifyMunicipalityTerritory`, nunca a média das classes por slug (LQ é razão, e a média de razões não é a razão das somas): **o E12 deve herdá-lo em vez de escrever um rollup por TI próprio**. `TERRITORIAL_CLASS_ANCHORS` e `AT_STANDARD_LQ` mudaram de casa para `src/lib/territorialClassAnchors.ts` porque o painel do mapa é client component e importar o classificador arrastaria o artefato TSE (~600 KB) para o bundle do navegador — números apenas, com classificador e copy lendo do mesmo lugar, então a recalibração do **E15** continua sendo um diff de uma linha. A matemática das escalas ficou em `src/lib/mapScaleClasses.ts` (arquivo novo; `bahiaMapStyle.ts` já carregava estilo de path), com **degradação declarada para 3 classes abaixo de 10 municípios** — não hipotético com carteira de assessor — e a legenda dizendo a contagem efetiva.
**UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`.
Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba".
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).

View file

@ -70,6 +70,8 @@ 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):** **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-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).
**Recently resolved (2026-07-25):** **E10** Classificação territorial relativa — `src/utilities/municipalityTerritorialClass.ts` classifies each of the 435 catalog municipalities as **`reduto | expansao | manutencao | marginal | sem_base`** (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and **replaces the majoritarian-era `Defesa/Ataque/Indecisa/Perdida`** — don't reintroduce it. The classifier is a **pure function over the committed TSE artifact** (no DB, no `campaignGoals`, no `user`, no migration): location quotient against the candidate's own statewide standard (`TERRITORIAL_CLASS_ANCHORS.strongLq` 2 / `weakLq` 0.5), share of his own statewide vote, uncaptured field ceiling (E8's `projectedFieldCeiling`) compared against the **catalog median**, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal") — that block is built by walking A11's already-memoized `computeVoteRankByYear` ordering, not by re-sorting the catalog. Cuts are ILLUSTRATIVE and versioned in one object — calibration is **E15**. `getStatewideFederalTotals(year)` was extracted and memoized into `bahiaElectionAggregates.ts`; `municipalityVoteRank.ts` reuses it instead of summing the state itself. **The UI never renders the label alone** (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a `title` attribute, which reaches neither keyboard nor touch. In the list that text is `sr-only` behind the badge and shown on hover/focus/tap through the **B23 `cellTooltip` seam** (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: `MunicipalityGoalAccountCard` (E8 — the class opens the diagnostics block it summarizes, no new card), the `MunicipalityDossier` cover (self-labeling badge "Classe reduto" + factors; print CSS forces `print-color-adjust: exact` on `[data-slot='badge']`, otherwise the printer flattens priority/trend/class into identical grey), and a new **"Classe" column** in `/campanha/municipios` right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in `DESIGN.md` § Status Badge; `sem_base` deliberately renders as an em dash in the list (matching the neighboring "2022" column) and keeps the flattest pill on the detail card and the dossier, where it sits in a labeled "Classe …" field. **List filtering is the one derived filter in the system:** `state.classes` is NOT part of `buildMunicipalityListWhere` — `municipalityPageData.ts` forces the `limit: 0, pagination: false` path whenever a class is selected (otherwise a 25-row page would drop rows and `totalDocs` would lie) and applies the same predicate to `staffScope.municipalities` before the overview rollup and to all three column facets. URL: repeatable `?class=` (all selected canonicalizes to absent, same as `trend`) and sort key `classe` (ordinal via `territorialClassSortWeight`, `desc` default = reduto first, `sem_base` last either way). Two new `diagnostico` concepts in `/campanha/conceitos` (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks **B13**, which should consume `computeMunicipalityTerritorialClass` as-is rather than reclassify. Plano: [`docs/plans/classificacao-territorial-relativa.md`](docs/plans/classificacao-territorial-relativa.md).
@ -148,7 +150,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.
- **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.
- **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)
@ -185,6 +187,8 @@ Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3).
Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. Since the Município remodel, the consumers are `municipalityElectoralBaseline.ts` (per-municipality series/turnout), `municipalityCandidateComparison.ts` (multi-candidate table) and `municipalityMapData.ts` (map, incl. the diverging compare mode); ticket roles live in `BASELINE_TICKET_2022`. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope). Two caching layers sit on top (2026-07-23 hardening): the immutable Solla/valid-votes series per municipality ships as a committed artifact — `pnpm build:election-aggregates` → `src/lib/electionAggregates/bahia-federal-baseline.json`, loader `src/lib/bahiaElectionAggregates.ts`, snapshot-tested against `municipalityCatalog` — so the map/dashboard never query these collections for historical years; and the elections-tab loaders (`municipalityElectoralBaseline`, `municipalityCandidateComparison`, `electionCandidateOptions`) are wrapped in `unstable_cache` under the `election-tse` tag (bust via `POST /api/revalidate?tag=election-tse` after re-seeding).
The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTallyByYear` / `majoritarian2022`, and B13 (2026-07-26) added `federalRankByIbgeCode` — the candidate's placement among **every** federal-deputy candidate with votes > 0 in that geography, per year, keyed by IBGE code (417 codes; Salvador's 19 zone slugs sum to the whole city, which is what the map draws) and read through `getFederalCompetitiveRank`. A geography where he had no votes gets no entry, never `rank: 0`. Its input is `loadFederalVotesByCityZoneAndCandidate` in `municipalityElectoralBaseline.ts` — the sibling of `loadCampoFederalVotesByCityZone`, same statewide scan, **CLI/build-time only**. The file is 623 KB against the 700 KB budget asserted in `tests/unit/bahiaElectionAggregates.unit.spec.ts`; anything new that would blow it needs a real measurement, not a bumped constant. Rank exists only by IBGE code — add a per-slug variant when the dossier or E11 actually needs one.
`pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached.
### Local verification and deploy checklist

View file

@ -258,6 +258,8 @@ Operational meaning for leadership/support/estimates. Always use `Badge` variant
**Territorial class (E10)** is a four-step ladder scanned down a 25-row column, so its variants must stay mutually legible: reduto `estimate-confirmed` (green, achieved), expansão `estimate-pending` (amber, asks for work), manutenção `secondary` (grey, routine), marginal `outline` (hairline). `sem_base` gets **no badge** — absent data is the em dash the neighboring columns already use. The map lives in `territorialClassBadgeVariant`; the class never renders without its "por quê" line.
The same ladder has a **second, unavoidable encoding**: B13 colors the map's proportional bubbles by territorial class, and Leaflet paints SVG with literal hex, not CSS variables. `territorialClassMapFill` (`municipalityLabels.ts`) is therefore the one place allowed to restate this palette as hex, and it must be re-derived by hand whenever `--support-*` / `--estimate-*` move — the two ladders are the same semantic scale and drifting them apart breaks the list↔map read. `sem_base` there is the shared `NO_DATA_FILL` (`choroplethColorScale.ts`), the same grey the choropleth uses for missing data.
### Campaign list system (Pass 2 W1 — canonical, do not fork)
Every `/campanha` list surface composes the shared pieces in `src/components/campaign/shared/`: `CampaignTable` (columns as data over house `ui/Table`; `rounded-xl border` container), `CampaignSearchForm` / `CampaignFilterChips` as the toolbar, `CampaignListFooter` (count + pagination), `CampaignListEmptyState` (`Empty` with icon + CTA, `min-h-72 border`), all inside `CampaignListPendingBoundary` so navigation dims the RESULTS region ("Feel the action"). Rich headers (sort + filter popovers) are the municipality islands (`MunicipalitySortableHead`/`MunicipalityHeaderFilter`) — the reference implementation, not yet generalized. Cell-level explanation IS generalized (B23 seam, first consumer E10's "Classe"): a column declares `cellTooltip: (row) => ReactNode` and `CampaignTable` wraps that cell in `CampaignCellTooltip`; returning `null` for a row leaves it untouched. The contract is that the tooltip is REDUNDANT — the same content must already be text in the cell (visible, or `sr-only` behind a badge), because Radix mounts tooltip content only on open and the table adds no tab stops. The hover primitive is `shared/CampaignHoverTooltip` (hover + keyboard focus + touch tap). New list = new column definitions + these shells; a bespoke table needs a documented exception (precedents: planos cards, `TerritoryOverviewTable`).

View file

@ -4,24 +4,24 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
## Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 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 | |
| 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 | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys latency-flake on loaded local machines (5s expect / 30s test budgets vs cold dev compiles; suite green with CI retries) | `tests/e2e/campaignMunicipalities.e2e.spec.ts` | test-infra | low | S | P3 | open — consider per-step budgets or a warmed dist dir | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 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 | |
| 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 | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
## Smell Inventory

View file

@ -0,0 +1,107 @@
# Escala e DRY pós-B13 (escala relativa no mapa dos Municípios)
Status: rascunho
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B13+**, fill-in de engenharia)
Impeccable: A — N/A (nenhuma superfície nova; as quatro fases preservam o comportamento visível do B13, exceto uma linha de copy em F2)
Appetite: ~0,5–0,75 dia eng; quatro fases independentes, nenhuma com migration, collection ou `Consent`
Responsável: —
## Dados → decisão → apresentação
- **Vou apresentar dados?** Não (N/A) — o lote é custo e coerência de dados já entregues pelo B13/E10. F2 muda **uma frase** do readout do mapa (declarar o escopo de um número que hoje aparece sem qualificação), não a métrica nem a forma.
- **Anti-goals de dado:** nenhuma escala nova, nenhuma métrica nova, nenhum chart. Se uma fase começar a propor escala, ela virou outro item.
## Contexto
**B13** ([escala-relativa-mapa.md](escala-relativa-mapa.md), entregue 2026-07-26) trocou o coropleto quase monocromático do Início por três escalas relativas em classes discretas — quantis (novo default), padrão próprio (LQ) e posição no município — mais uma camada opcional de bolhas por válidos projetados coloridas pela classe do E10, e estendeu o artefato TSE commitado para v3 (`federalRankByIbgeCode`). O `/simplify` da entrega (três revisores em paralelo — quality / performance / reuse, 2026-07-26) aplicou o cleanup barato na hora e deixou quatro achados **maiores que cleanup**, registrados aqui.
Os dois primeiros são de **coerência**, não de custo, e é por isso que abrem o lote: o B13 criou duas superfícies que dizem a mesma coisa sobre um município (o mapa e a coluna "Classe" da lista) usando dois caminhos de cálculo diferentes, e criou um readout onde dois números vizinhos têm escopos geográficos diferentes sem dizer isso ao leitor. Nenhum dos dois é bug hoje; ambos são o tipo de deriva que só aparece depois que alguém recalibra (**E15**) ou depois que um assessor abre o mapa achando que vê a mesma classe que o coordenador vê.
**Já resolvido no simplify (não reabrir):** o bug semântico do `computeAggregateTerritorialClass` (somava o headroom dos slugs e comparava contra a mediana **de uma unidade**, o que classificava um grupo grande como `expansao` quando ele é `marginal` — a mediana agora escala por `slugs.length`, com teste); as bolhas passaram do centro do bounding box para o centróide real (`path.getCenter()`) e ganharam **pane próprio** com `z-index: 450`, porque o `bringToFront` do hover escondia a bolha atrás do polígono; `mode` entrou nas deps do efeito de bolhas; legenda e pintura passaram a compartilhar o mesmo predicado (discordavam nas bordas); a copy dos quantis parou de dizer "faixas de igual tamanho" (quantil iguala a **contagem** por faixa, não a largura); `NO_DATA_FILL` e o raio das bolhas viraram fonte única compartilhada entre mapa e legenda; `formatMunicipalityVoteRank` virou `formatPlacementOrdinal` em `electionFormat.ts` (client-safe, 2 consumidores); o flag `reduced` ambíguo saiu de `MapScaleClassing`; os buckets de rank viraram dados como os de LQ; o `default` morto da `ChoroplethLegend` caiu; `buildMunicipalitiesByIbgeCode` substituiu o agrupamento manual duplicado no loader; e o subtítulo do painel parou de dizer "em 2026, pelas estimativas da campanha" com 2022 selecionado (defeito herdado do B11).
## Objetivos
- Um lugar só decide o que é "o padrão próprio dele" — mapa e lista não podem divergir numa recalibração.
- Todo número do readout do mapa declara a geografia sobre a qual foi calculado, ou é calculado sobre a mesma geografia do vizinho.
- Uma varredura da fatia estadual do TSE por ano no `pnpm build:election-aggregates`, em vez de três.
- O payload RSC do Início não carrega a escala mais pesada do B13 quando ninguém pediu por ela.
- Guardrails: sem migration, sem collection, sem `Consent`, sem server action; contrato de URL, escalas, cortes (`TERRITORIAL_CLASS_ANCHORS`) e o artefato v3 **inalterados**; `overrideAccess: false` preservado em toda leitura com `user`.
## Decisões travadas
- **F1 pina a concordância com teste, não extrai um "serviço de LQ".** A razão é uma linha (`votos / válidos / padrão estadual`) escrita em dois lados por um motivo real: o classificador é `server-only` (arrasta o artefato de ~600 KB) e o painel é client component — foi exatamente por isso que `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts` no B13. O que falta não é uma abstração, é a **prova** de que os dois caminhos concordam. Fonte: `/simplify` B13 (2026-07-26). **Rejeitado:** mover o cálculo do LQ para o servidor e mandar `lqByCode` no bundle (paga bytes em toda visita para uma escala opcional, o oposto de F4); expor um helper puro compartilhado só para a divisão (wrapper raso — `depth check` do `engineering-standards.mdc` reprova).
- **F2 declara o escopo em vez de mudar o cálculo.** No readout do mapa a **posição** vem do artefato e vale para a cidade inteira (Salvador = 19 zonas somadas), enquanto a **classe** é calculada sobre os slugs que aquele usuário enxerga — dois assessores com fatias diferentes de Salvador veem classes diferentes para o mesmo polígono, e a lista mostra uma terceira (por slug, sobre o catálogo inteiro). O caminho barato e honesto é o que o E10 já travou como regra: a classe nunca aparece sem o porquê, e agora também não aparece sem a geografia. **Rejeitado:** classificar sempre sobre o catálogo inteiro ignorando o escopo (o mapa do assessor passaria a pintar municípios que ele não administra com a autoridade de um dado dele); esconder a classe para assessor (rebaixa a tela de quem mais usa o mapa).
- **F3 amplia o loader mais geral em vez de somar um quarto.** `loadFederalVotesByCityZoneAndCandidate` já lê a mesma fatia estadual que `loadCampoFederalVotesByCityZone` e um superconjunto da de `loadCandidateVotesByCityZone`; falta `party` no `select` para ele virar superconjunto **estrito** dos três. É build-time, então o ganho é tempo de build e não latência de request — o que o torna barato, não urgente. **Rejeitado:** view materializada / índice novo no Postgres (schema por um script que roda à mão); manter três varreduras "porque o build é offline" (é o mesmo argumento que deixou o script de 2 min virar 6).
- **F4 corta bytes sem cortar a escala.** `competitiveRankByYear` é ~57 KB dos ~63,6 KB que o B13 somou ao payload RSC, e o par `{rank, candidates}` repete duas chaves 1.251 vezes. Tupla `[rank, candidates]` corta ~metade sem mudar nada do que o usuário vê. **Rejeitado:** carregar o rank sob demanda por endpoint quando o usuário escolhe a escala (troca 10 KB de gzip por um round-trip e um estado de carregamento numa troca que hoje é instantânea — o "Feel the action" perde mais do que os bytes ganham); mandar só o ano selecionado (a troca de ano é o gesto central do mapa).
- **i18n e naming** seguem o AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
## Questões em aberto
- **F2 deve dizer o escopo no readout, na legenda, ou nos dois?** **Opções:** (a) uma linha no readout, junto do "por quê" da classe; (b) uma frase na nota da legenda; (c) ambos. **Recomendação:** (a) — a assimetria é entre dois números que se tocam **no readout**; na legenda a frase vira ruído para o coordenador, que é quem mais usa a tela e para quem escopo = catálogo.
- **F4 vale sozinho ou espera a próxima escala?** **Opções:** (a) fazer agora; (b) esperar E11/E12/B13 v2 trazerem mais um campo por município ao bundle. **Recomendação:** (a) — a linha do ledger que pedia "profiling antes de agir" (2026-07-23) foi respondida pelo B13, e a tupla é uma mudança de encoding local a `municipalityMapContract.ts` + um consumidor; esperar só aumenta o número que se vai cortar depois.
## Abordagem proposta
```mermaid
flowchart LR
F1["F1 teste de concordância<br/>LQ mapa × classificador"] --> Coerencia["Mapa = lista após E15"]
F2["F2 escopo no readout<br/>classe × posição"] --> Coerencia
F3["F3 uma varredura por ano<br/>build:election-aggregates"] --> Build["pnpm build:election-aggregates"]
F4["F4 tupla no rank<br/>municipalityMapContract"] --> Payload["Payload RSC do Início"]
```
Componentes:
- **F1 — teste de concordância do LQ.** `MunicipalityMapPanel.tsx:210` calcula `lq[code] = votes / validVotes / statewideShare`; `municipalityTerritorialClass.ts:108` calcula `const lq = ownVotes / validVotes / stateShare`. Novo pin em `tests/unit/` que roda os dois caminhos sobre a mesma entrada (um município do catálogo, ano 2022) e exige o mesmo resultado, mais uma asserção de que ambos leem `TERRITORIAL_CLASS_ANCHORS`/o mesmo padrão estadual — a rede de segurança do **E15**, que vai mexer nos cortes. Sem mudança de runtime.
- **F2 — escopo no readout.** `MunicipalityMapPanel.tsx` (`relativeReadingFor` `:381` e `bubbleReadingFor`): quando o ator **não** é irrestrito e o polígono agrega mais de um slug do escopo, o texto da classe passa a nomear a geografia ("na sua carteira", contra "na cidade" da posição). A regra de quem é irrestrito já existe (`lib/campaignRoles.ts`); nada de novo no bundle.
- **F3 — uma varredura por ano.** `municipalityElectoralBaseline.ts:349` ganha `party: true` no `select`; `loadFederalVotesByCityZoneAndCandidate` passa a devolver o número **e** o partido por candidato, e `build-election-aggregates.mjs:87` (o `Promise.all` de 4 loaders) e `:189` (o laço do rank) passam a compartilhar uma leitura por ano — `loadCandidateVotesByCityZone` e `loadCampoFederalVotesByCityZone` viram dobras em memória (`isCampoParty` já é puro). Os dois loaders continuam exportados: são a superfície de leitura por candidato usada fora do build.
- **F4 — tupla no rank.** `municipalityMapContract.ts` troca `{ rank, candidates }` por `[rank, candidates]` em `competitiveRankByYear`, com um leitor nomeado no consumidor (`MunicipalityMapPanel`) para o índice não vazar como número mágico. `municipalityMapData.ts` emite a tupla; o teste de contrato do bundle acompanha.
- **Migration**: sem migration, sem collection, sem server action, sem rebuild de artefato (F3 não muda o **conteúdo** de `bahia-federal-baseline.json` — o snapshot test do artefato é justamente o pin de que não mudou).
## Dependências
- Nenhuma dura de outro item aberto. F1 é pré-requisito **suave** do **E15** (recalibrar os cortes sem o pin é onde mapa e lista divergem em silêncio); F2 é suave com **B13 v2**/E11 se estes tocarem o readout.
- Reusa: `territorialClassAnchors.ts`, `municipalityTerritorialClass.ts`, `municipalityElectoralBaseline.ts`, `municipalityMapContract.ts`, `campaignRoles.ts`, o snapshot test de `bahiaElectionAggregates`.
## Não escopo
- **Recalibrar as âncoras do LQ** — **E15** (backtest 2014–2022). Este lote só garante que, quando ela acontecer, as duas superfícies se movem juntas.
- **Rollup de Território de Identidade** — **E12**, que herda `computeAggregateTerritorialClass` do B13 em vez de escrever o seu.
- **Memoizar `computeAggregateTerritorialClass` para grupos multi-slug** — adiado com gatilho abaixo (o custo real só chega com os 27 TIs do E12).
- **`municipalityLabels` arrastando `municipalityCatalog` (~66 KB de fonte) para o chunk do mapa** — adiado com gatilho abaixo; medido em ~0,8 ms de init num chunk carregado sob demanda, ou seja, custo de leitura e não de usuário.
- **Rank em 2026** — não existe dado do TSE; a escala se declara indisponível e o painel cai para quantis (comportamento do B13, não débito).
## Rabbit holes
- **"Já que estou pinando o LQ, unifico o cálculo num módulo só."** Se alguém "só completar": ou o painel importa o classificador `server-only` (e o artefato de 600 KB entra no bundle do cliente), ou nasce um wrapper raso sobre uma divisão. **Mitigação:** F1 é **só teste**; qualquer mudança de runtime aqui precisa de evidência nova.
- **"Já que estou no readout, reescrevo a hierarquia do painel."** Se alguém "só completar": vira um ciclo Impeccable inteiro num lote classificado A. **Mitigação:** F2 é uma condição e uma string; o painel saiu de um critique recente.
- **"Já que estou no script, refatoro o pipeline de agregados."** Se alguém "só completar": o script vira framework e o snapshot do artefato passa a mudar por acidente. **Mitigação:** F3 tem um critério binário — `pnpm build:election-aggregates` roda e o snapshot test do artefato **não** muda.
## Adiado com gatilho
- **`CampaignConceptLink` / `MapLegendNote` compartilhados.** O link "Saiba mais" para `/campanha/conceitos` e o parágrafo de nota da legenda estão em 2–3 pontos (`MapScaleLegend`, `ChoroplethLegend`, tooltips do E18). Revisitar quando: **3º call site fora do mapa** pedir o mesmo par — abaixo disso é a abstração prematura que o `engineering-standards.mdc` proíbe (3+ call sites ou uma política com nome).
- **`municipalityLabels.ts` puxa `municipalityCatalog` + conceitos para o chunk do mapa.** Separar as constantes de apresentação da classe (`territorialClassMapFill`, labels) do resto do módulo removeria ~66 KB de fonte do chunk. Revisitar quando: uma **3ª superfície cliente** precisar da apresentação da classe (hoje são a lista e o mapa) — ou quando o chunk do mapa aparecer num orçamento de bundle real.
- **`computeAggregateTerritorialClass` sem memo.** Cada polígono multi-slug (só Salvador hoje) reclassifica por render. Revisitar no **E12**: 27 TIs × ~16 municípios cada é outra ordem de grandeza, e o precedente de memo de processo já está escrito (`bahiaElectionAggregates`, `municipalityVoteRank`).
## Explicitamente fora (descartes deste triage)
- **`lqMultipleFormatter` deveria morar em `electionFormat.ts`** — score 1; formatador de uma linha com um consumidor. Se ganhar o segundo, vai junto de graça.
- **`lqByCode` recalcula na troca de ano mesmo fora do modo LQ** — o próprio revisor de performance concluiu que o guard custa mais legibilidade do que economiza (417 divisões).
- **`MapFeatureReadout` e `TerritoryListColumns` com formatadores de percentual próprios** — pré-existente em `main`, fora do escopo do B13, e unificar exige decisão de produto sobre a casa decimal (mesmo descarte registrado no [escala-dry-pos-e10.md](escala-dry-pos-e10.md)).
- **Flakiness dos e2e** (`campaignMunicipalities`, `campaign-pwa`) — reproduzida em `main` limpo, portanto não é débito do B13: foi para o ledger ([TECH-DEBT.md](../TECH-DEBT.md), linha de test-infra, causa afinada e prioridade P3 → P2).
- **`territorialClassMapFill` como segunda paleta fora do `DESIGN.md`** — resolvido na própria sessão: o `DESIGN.md` § Status Badge agora declara o acoplamento (Leaflet pinta hex, não `var()`) e a obrigação de re-derivar à mão.
## Referências
- `docs/roadmap.md` (Fill-ins abertos — **B13+**)
- [escala-relativa-mapa.md](escala-relativa-mapa.md) — o pai do lote (B13 ✓)
- [classificacao-territorial-relativa.md](classificacao-territorial-relativa.md) — E10 ✓, dono dos cortes que F1 protege
- [conta-da-cadeira.md](conta-da-cadeira.md) — E8 ✓, dono dos válidos projetados que as bolhas dimensionam
- [TECH-DEBT.md](../TECH-DEBT.md) — linha "Map bundle serializes all years × scenarios" (F4 responde ao pedido de profiling de 2026-07-23) e a linha de flake dos e2e
- `src/components/campaign/map/MunicipalityMapPanel.tsx` (`:210` LQ, `:381` readout) — F1, F2
- `src/utilities/municipalityTerritorialClass.ts` (`:108` LQ) + `src/lib/territorialClassAnchors.ts` — F1
- `src/utilities/municipalityElectoralBaseline.ts` (`:242`, `:286`, `:329`) + `scripts/build-election-aggregates.mjs` (`:87`, `:189`) — F3
- `src/utilities/municipalityMapContract.ts` + `src/utilities/municipalityMapData.ts` — F4
- `DESIGN.md` § Status Badge — as duas encodificações da classe territorial (badge e fill do mapa)
- AGENTS.md — escada de cache, `overrideAccess: false` com `user`, gate por entrega

View file

@ -1,12 +1,22 @@
# B13 — Escala relativa no mapa dos Municípios (quantis/LQ/rank + símbolo proporcional)
Status: rascunho
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
Status: entregue (2026-07-26)
Atualizado em: 2026-07-26 (as-built da entrega; auditoria do plano aplicada)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", B13; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
Impeccable: B — estende `MunicipalityMapPanel`/`BahiaMap` existentes (seletor de escala, camada de símbolos); sem rota nova
Appetite: ~2 dias eng; sem migration
Responsável: —
## Revisão na entrega (2026-07-26)
Três correções do plano original, encontradas ao auditá-lo contra o repositório antes de codar:
1. **Caminhos e nomes defasados.** `BahiaMap.tsx`/`MunicipalityMapPanel.tsx` moraram em `src/components/campaign/` até o Pass 2 W2 e hoje estão em `src/components/campaign/map/`; o classificador do E10 chama-se `computeMunicipalityTerritorialClass`, não `computeDfTerritorialClass`. Os diagramas e a lista de componentes abaixo ficaram como estavam para preservar o registro do que foi planejado — leia-os com esta errata.
2. **"O mapa acompanha a lista" deixou de ser verdade.** Desde a remodelagem dos Municípios o mapa só existe no Início (`/campanha`) e recebe apenas `compare`; `/campanha/municipios` é lista/overview. A questão em aberto "quantis sobre o conjunto filtrado ou o estado inteiro" foi resolvida como **escopo de acesso do usuário** (435 municípios para coordenador/candidato, a carteira para o assessor), que é o análogo do "conjunto filtrado" na única superfície que o mapa tem.
3. **LQ como classes, não diverging.** Os Objetivos pediam "LQ diverging em torno de 1" e as Decisões travadas, três linhas abaixo, reservavam diverging exclusivamente ao compare. Resolvido a favor das decisões travadas: LQ virou **5 classes nomeadas** cortadas nas âncoras que o E10 já usa (`TERRITORIAL_CLASS_ANCHORS.weakLq` 0,5 / `strongLq` 2 mais a banda `AT_STANDARD_LQ` 0,95–1,15), na rampa sequencial do tema. Vermelho × azul continua significando "Solla × adversário" e só aparece no compare.
Uma decisão cara que o plano não previa: **"Posição no município" não existia em lugar nenhum** — nem no artefato, nem em query reaproveitável (`municipalityCandidateComparison.ts` é por município × candidatos escolhidos). Construí-la em request significaria varrer ~100 mil linhas por ano para um dado que nunca muda, então o ranking foi para o **artefato TSE commitado** (regra 3 da escada de cache), como o E8 fez na v2. O rabbit hole "rank v1 = posição entre os top-N carregados, documentado" desapareceu junto: o rank do artefato é sobre **todos** os candidatos com votos na geografia.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípio 5; anti-goal cartograma) / `DESIGN.md` (register `product`) · precedentes B10/B11/B12/B6 (`BahiaMap`, `bahiaMapStyle.ts`, `MapFeatureReadout`).
@ -88,6 +98,27 @@ Componentes:
- **Value-by-alpha (opacidade ∝ eleitorado).** Gatilho: teste com o time em reunião real pedir alternativa às bolhas.
- **Roll-off como métrica de mapa.** Gatilho: I-A entrar no motor (E11 fase 2) e o time pedir a leitura espacial.
- **`CampaignConceptLink` / `MapLegendNote` compartilhados** (2–3 cópias hoje). Gatilho: 3º call site fora do mapa. Registrado em [escala-dry-pos-b13.md](escala-dry-pos-b13.md).
- **`municipalityLabels.ts` arrastando `municipalityCatalog` (~66 KB) para o chunk do mapa.** Gatilho: 3ª superfície cliente precisar da apresentação da classe. Mesmo plano.
- **Memo de processo para `computeAggregateTerritorialClass`.** Gatilho: **E12** (27 TIs × ~16 municípios). Mesmo plano.
Os quatro débitos maiores que o cleanup do `/simplify` — pin de concordância do LQ, escopo declarado no readout, varredura única no script de agregados e a tupla do `competitiveRankByYear` — viraram o fill-in **B13+**: [escala-dry-pos-b13.md](escala-dry-pos-b13.md).
## As-built (2026-07-26)
**Artefato v3.** `pnpm build:election-aggregates` passou a emitir `federalRankByIbgeCode: Record<codarea, Record<year, { rank, candidates }>>` — a colocação do candidato entre **todos** os candidatos a deputado federal com votos > 0 naquela geografia, em 2014/2018/2022. Chaveado por `ibgeCode` (417 códigos) e não por slug porque é o que o mapa desenha: as 19 zonas de Salvador somam a cidade inteira. Geografia sem votos dele não recebe entrada (nunca `rank: 0`). O insumo é `loadFederalVotesByCityZoneAndCandidate` em `municipalityElectoralBaseline.ts` — irmão de `loadCampoFederalVotesByCityZone`, mesma varredura estadual, CLI/build-time only. O arquivo foi de 534 KB para 623 KB, dentro do orçamento de 700 KB que o E8 já tinha fixado.
**Escalas.** A matemática pura vive em `src/lib/mapScaleClasses.ts` (arquivo novo — `bahiaMapStyle.ts` já carregava estilo de path e teria virado duas responsabilidades): quantis sobre os valores > 0 do escopo renderizado, **degradando para 3 classes com menos de 10 municípios** e devolvendo a contagem efetiva para a legenda não mentir; faixas de LQ nas âncoras do E10; buckets de rank (1º · 2º–3º · 4º+). `resolvePathStyle` ganhou um caminho de fill discreto por chave (`fillByKey`) que **substitui** a rampa contínua quando presente — chave ausente é "sem dado", que não é a mesma coisa que valor zero.
As âncoras saíram de `municipalityTerritorialClass.ts` para `src/lib/territorialClassAnchors.ts` porque o painel do mapa é client component: importar o classificador lá arrastaria o artefato TSE inteiro para o bundle do navegador. Só números, sem dados nem lógica; classificador e copy leem do mesmo lugar, então recalibrar (E15) continua sendo um diff de uma linha em um arquivo.
**Bundle.** `computeAggregateTerritorialClass(slugs)` classifica um grupo de slugs como um território só — soma os insumos e roda o **mesmo** `classifyMunicipalityTerritory`, nunca a média das classes por slug (LQ é razão, e a média de razões não é a razão das somas). O E12 herda o helper para o rollup por TI. O loader do mapa preenche `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` e `projectedValidVotesByCode`, todos puros sobre o artefato: **nenhuma query nova em request**. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022, como `percentValid` já fazia.
**UI.** Default passou a ser **quantis**. A legenda discreta (`MapScaleLegend`) mostra um swatch por classe com a faixa real, do mais fraco para o mais forte, e a nota abaixo dela é ligada ao seletor por `aria-describedby` — inclusive a nota que declara a degradação ("cerca de um quinto dos N municípios com votos no seu escopo"). O readout nunca mostra a classe sozinha: soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Duas entradas novas em `/campanha/conceitos` (Quantis e Posição no município) e um "Saiba mais" inline na nota da legenda; LQ já estava coberto por `dominancia-relativa`.
**Bolhas.** `L.circleMarker` no centro dos bounds de cada feature, raio ∝ √(válidos projetados) — a **área** carrega o valor. Fill pela classe do E10, `interactive: false` (a bolha fica em cima do próprio município, então deixar o ponteiro atravessar mantém um alvo de hover e um readout, não dois empilhados), desenhadas da menor para a maior para que os prêmios grandes não fiquem cobertos. Toggle desligado por default, com legenda de tamanho + cor que só aparece com a camada. Medido antes de otimizar, como o plano mandava: 417 marcadores não derrubaram o frame, então o corte para top-K **não** foi feito.
**Sem migration, sem collection, sem server action.** O **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`); o chunk cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,10 @@
* #13) votes+tally — cut by the same `municipalityCatalog` geography used at
* runtime (Salvador split into zone municipalities, every other municipality
* whole) removes all historical-year DB work from the request/build path.
* The same reasoning covers B13's competitive placement
* (`federalRankByIbgeCode`): ranking him against every other candidate means
* reading the whole statewide slice, which is exactly what must not happen
* per request.
*
* Why not compute during `pnpm build`: Vercel builds must not depend on the
* production database CONTENT (TSE seeds are local-only by policy), and the
@ -47,8 +51,10 @@ const { municipalityElectionGeography } =
const {
loadCampoFederalVotesByCityZone,
loadCandidateVotesByCityZone,
loadFederalVotesByCityZoneAndCandidate,
loadOfficeTallyByCityZone,
loadValidVotesByCityZone,
sumCandidateVotesForGeography,
sumOfficeTallyForGeography,
sumVotesForGeography,
} = await import('../src/utilities/municipalityElectoralBaseline.ts')
@ -166,6 +172,63 @@ console.log(
`[build:election-aggregates] 2022 majoritarian: ${presidentTotal} votes for president #${BASELINE_TICKET_2022.president.candidateNumber}.`,
)
// B13 "posição no município": where the candidate placed among every federal
// deputy voted inside each municipality. Keyed by IBGE code rather than by
// catalog slug because the competitive question only has an answer for a whole
// city — Salvador's 19 zone municipalities are one placement, and the map
// paints one polygon for them anyway.
const geographiesByIbgeCode = new Map()
for (const entry of municipalityCatalog) {
const geographies = geographiesByIbgeCode.get(entry.ibgeCode) ?? []
geographies.push(municipalityElectionGeography(entry))
geographiesByIbgeCode.set(entry.ibgeCode, geographies)
}
const federalRankByIbgeCode = {}
for (const year of HISTORICAL_SERIES_YEARS) {
const votesByCityZoneAndCandidate = await loadFederalVotesByCityZoneAndCandidate(
payload,
cliReader,
{ year },
)
for (const [ibgeCode, geographies] of geographiesByIbgeCode) {
const byCandidate = new Map()
for (const geography of geographies) {
for (const [number, votes] of sumCandidateVotesForGeography(
votesByCityZoneAndCandidate,
geography,
)) {
byCandidate.set(number, (byCandidate.get(number) ?? 0) + votes)
}
}
const ownVotes = byCandidate.get(candidateNumber) ?? 0
// No votes here means no placement — a last place would read as a fact
// when it is really absence of data.
if (ownVotes <= 0) continue
let ahead = 0
let candidates = 0
for (const votes of byCandidate.values()) {
if (votes <= 0) continue
candidates += 1
if (votes > ownVotes) ahead += 1
}
federalRankByIbgeCode[ibgeCode] ??= {}
// Competition rank: tied candidates share a placement.
federalRankByIbgeCode[ibgeCode][String(year)] = { rank: ahead + 1, candidates }
}
const ranked = Object.values(federalRankByIbgeCode).filter(
(byYear) => byYear[String(year)] !== undefined,
).length
console.log(
`[build:election-aggregates] ${year}: placement computed for ${ranked}/${geographiesByIbgeCode.size} IBGE municipalities.`,
)
}
const artifact = {
provenance:
'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.',
@ -173,6 +236,7 @@ const artifact = {
candidateName: BASELINE_TICKET_2022.candidate.name,
years: [...HISTORICAL_SERIES_YEARS],
municipalities,
federalRankByIbgeCode,
}
await mkdir(OUTPUT_DIR, { recursive: true })

View file

@ -14,6 +14,7 @@ import {
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import {
bubbleRadius,
buildHighlightSet,
buildLayerStyleContext,
canonicalMapKeysKey,
@ -21,9 +22,11 @@ import {
keyPropertyForMode,
resolvePathStyle,
type BahiaMapFillMode,
type ChoroplethFills,
type ChoroplethValues,
type LayerStyleContext,
} from '@/lib/bahiaMapStyle'
import { choroplethMaxValue } from '@/lib/choroplethColorScale'
import { cn } from '@/lib/utils'
const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
@ -31,6 +34,13 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
[-8.5, -37.0],
]
/**
* Bubbles get their own pane above the polygons: hovering a município calls
* `bringToFront()` on its path, which in a shared pane would raise the polygon
* over the very bubble it describes.
*/
const BUBBLE_PANE = 'municipality-bubbles'
export type BahiaMapMode = 'municipality' | 'territory'
export type BahiaMapFeatureInfo = {
@ -45,6 +55,20 @@ type BahiaMapProps = {
fillMode?: BahiaMapFillMode
/** When set, overrides auto max from `values` (e.g. fixed 1 for 0–100% shares). */
scaleMax?: number
/**
* Discrete class fills by feature key (B13). When provided they replace the
* continuous ramp entirely, and a key that is absent renders as "no data".
* Must be referentially stable across renders, or every render repaints
* every path.
*/
fillByKey?: ChoroplethFills
/**
* B13 — proportional symbols: feature key → magnitude, drawn as a circle at
* the feature's centre with radius ∝ √value. Absent keys get no bubble.
*/
bubbleValues?: ChoroplethValues
/** Bubble fill by feature key; keys without one fall back to the neutral fill. */
bubbleFillByKey?: ChoroplethFills
highlightKeys?: string[]
/** Fit viewport to these keys without highlighting them. */
fitToKeys?: string[]
@ -99,6 +123,9 @@ export const BahiaMap = ({
values,
fillMode = 'sequential',
scaleMax,
fillByKey,
bubbleValues,
bubbleFillByKey,
highlightKeys = [],
fitToKeys = [],
interactiveKeys = [],
@ -127,6 +154,7 @@ export const BahiaMap = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey: '',
selectedKey,
})
@ -146,6 +174,7 @@ export const BahiaMap = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey,
selectedKey,
}
@ -398,6 +427,7 @@ export const BahiaMap = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey,
selectedKey,
hoveredKey: previousContext?.hoveredKey ?? null,
@ -408,7 +438,59 @@ export const BahiaMap = ({
restyleAllPaths()
// `selectedKey` changes use the dedicated 2-path effect below (not full O(n) restyle).
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedKey excluded on purpose
}, [values, fillMode, scaleMax, highlightKey, mode])
}, [values, fillMode, scaleMax, fillByKey, highlightKey, mode])
useEffect(() => {
const map = mapRef.current
if (!map || !bubbleValues || status !== 'ready') return
const maxValue = choroplethMaxValue(bubbleValues)
if (maxValue <= 0) return
if (!map.getPane(BUBBLE_PANE)) {
const pane = map.createPane(BUBBLE_PANE)
pane.style.zIndex = '450'
pane.style.pointerEvents = 'none'
}
// Smallest first so the big prizes end up drawn on top of the crowd
// instead of being covered by whichever key the object happened to list last.
const ordered = Object.entries(bubbleValues)
.filter(([, value]) => value > 0)
.sort(([, left], [, right]) => left - right)
const markers: L.CircleMarker[] = []
for (const [key, value] of ordered) {
const path = pathByKeyRef.current.get(key)
const fillColor = bubbleFillByKey?.[key]
if (!(path instanceof L.Polygon) || !fillColor) continue
markers.push(
// Polygon centroid, not the bounding box's centre: a concave or
// L-shaped município would put a box centre outside its own outline.
L.circleMarker(path.getCenter(), {
pane: BUBBLE_PANE,
radius: bubbleRadius(value, maxValue),
fillColor,
fillOpacity: 0.8,
color: '#ffffff',
weight: 0.75,
// Deliberately not interactive: every bubble sits on top of its own
// município, so letting the pointer fall through keeps ONE hover
// target and one readout instead of two stacked ones.
interactive: false,
}),
)
}
const group = L.layerGroup(markers).addTo(map)
return () => {
group.remove()
}
// `mode` rebuilds the geometry layer this reads centroids from, so the
// markers must be rebuilt with it or they would sit at stale positions.
}, [bubbleValues, bubbleFillByKey, status, mode])
useEffect(() => {
const context = styleContextRef.current

View file

@ -5,12 +5,18 @@ type ChoroplethLegendProps = {
max: number
metricLabel: string
formatMax?: (max: number) => string
/** What the colour means, in the caller's words. */
note: string
/** Ties the note to the scale selector through `aria-describedby`. */
noteId: string
}
export const ChoroplethLegend = ({
max,
metricLabel,
formatMax = formatElectionNumber,
noteId,
note,
}: ChoroplethLegendProps) => (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
@ -25,8 +31,8 @@ export const ChoroplethLegend = ({
{formatMax(max)}
</span>
</div>
<p className="text-xs text-muted-foreground">
Escala: intensidade da cor indica {metricLabel} no território.
<p id={noteId} className="text-xs text-muted-foreground">
{note}
</p>
</div>
)

View file

@ -12,6 +12,14 @@ type MapFeatureReadoutProps = {
rawMetricValue: number | undefined
metricLabel: string
scaleMode: MunicipalityMapScaleMode
/**
* What the colour means for THIS município, spelled out — "4ª de 5 faixas",
* "1,8× o padrão estadual do candidato", "12º entre 663 candidatos". A class
* without its reason is a verdict the reader cannot check.
*/
relativeReading: string | null
/** What the bubble encodes for THIS município — only while the layer is on. */
bubbleReading: string | null
comparisonActive: boolean
navigation: MunicipalityMapNavigation | null
}
@ -41,6 +49,8 @@ export const MapFeatureReadout = ({
rawMetricValue,
metricLabel,
scaleMode,
relativeReading,
bubbleReading,
comparisonActive,
navigation,
}: MapFeatureReadoutProps) => {
@ -53,6 +63,10 @@ export const MapFeatureReadout = ({
}
const formattedValue = formatMetricValue(metricValue, scaleMode, comparisonActive)
// Every mode except the percentage prints the metric next to the number, so
// announcing it again would read it twice.
const showMetricLabel =
!comparisonActive && metricValue !== undefined && scaleMode !== 'percentValid'
const showPercentSecondary =
!comparisonActive &&
scaleMode === 'percentValid' &&
@ -68,23 +82,27 @@ export const MapFeatureReadout = ({
<div className="flex min-w-0 flex-col gap-0.5">
<p className="truncate text-sm font-medium">{feature.name}</p>
<p className="text-sm text-muted-foreground">
<span className="sr-only">{metricLabel}: </span>
{showMetricLabel ? null : <span className="sr-only">{metricLabel}: </span>}
<span className="tabular-nums">{formattedValue}</span>
{!comparisonActive && metricValue !== undefined && scaleMode === 'absolute' ? (
<span className="text-muted-foreground"> {metricLabel}</span>
) : null}
{showMetricLabel ? <span className="text-muted-foreground"> {metricLabel}</span> : null}
{!comparisonActive && metricValue !== undefined && scaleMode === 'percentValid' ? (
<span className="text-muted-foreground"> dos válidos</span>
) : null}
{comparisonActive && metricValue !== undefined ? (
<span className="text-muted-foreground"> (diferença)</span>
) : null}
{relativeReading ? (
<span className="text-muted-foreground"> · {relativeReading}</span>
) : null}
</p>
{showPercentSecondary ? (
<p className="text-xs text-muted-foreground tabular-nums">
{formatElectionNumber(rawMetricValue)} votos
</p>
) : null}
{/* 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.

View file

@ -0,0 +1,61 @@
import Link from 'next/link'
import { campaignConceptHref, type CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
import type { MapScaleClassing } from '@/lib/mapScaleClasses'
type MapScaleLegendProps = {
classing: MapScaleClassing
/** What the swatches measure — "votos de Jorge Solla em 2022". */
metricLabel: string
/** What the colour means plus any caveat (small scope, missing data). */
note: string
/** Ties the note to the scale selector through `aria-describedby`. */
noteId: string
/** Glossary entry for this scale, when it has one (E18). */
conceptID?: CampaignConceptId
}
/**
* Discrete counterpart of `ChoroplethLegend`: a swatch per class with its real
* range, weakest on the left. A gradient bar cannot answer "which band is this
* município in", which is the only question a classed map is asked.
*/
export const MapScaleLegend = ({
classing,
metricLabel,
note,
noteId,
conceptID,
}: MapScaleLegendProps) => (
<div className="flex flex-col gap-1.5">
<ul
className="flex flex-wrap items-center gap-x-3 gap-y-1.5"
aria-label={`Faixas de cor para ${metricLabel}`}
>
{classing.classes.map((entry, index) => (
<li key={index} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-[3px] ring-1 ring-foreground/15"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs tabular-nums text-muted-foreground">{entry.label}</span>
</li>
))}
</ul>
<p id={noteId} className="text-xs text-muted-foreground">
{note}
{conceptID ? (
<>
{' '}
<Link
href={campaignConceptHref(conceptID)}
className="font-medium underline underline-offset-2"
>
Saiba mais
</Link>
</>
) : null}
</p>
</div>
)

View file

@ -7,16 +7,36 @@ import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap'
import { ChoroplethLegend } from '@/components/campaign/map/ChoroplethLegend'
import { MapFeatureReadout } from '@/components/campaign/map/MapFeatureReadout'
import { MapScaleLegend } from '@/components/campaign/map/MapScaleLegend'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import {
VOTE_ESTIMATE_SCENARIO_MAP_HINT,
VoteEstimateScenarioField,
} from '@/components/campaign/votePledge/VoteEstimateScenarioField'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { computeChoroplethMax } from '@/lib/bahiaMapStyle'
import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethColorScale'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
bubbleRadius,
computeChoroplethMax,
type ChoroplethFills,
type ChoroplethValues,
} from '@/lib/bahiaMapStyle'
import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
import {
choroplethMaxValue,
computeValidVoteShares,
divergingGradientCss,
} from '@/lib/choroplethColorScale'
import { formatElectionNumber, formatPlacementOrdinal } from '@/lib/electionFormat'
import {
buildCompetitiveRankClassing,
buildLqClassing,
buildQuantileClassing,
fillsForClassing,
QUANTILE_CLASSES,
QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT,
} from '@/lib/mapScaleClasses'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
voteEstimateScenarioLabels,
@ -24,8 +44,16 @@ import {
} from '@/lib/voteEstimate'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import {
formatDominanceAgainstOwnStandard,
territorialClassLabels,
territorialClassMapFill,
} from '@/utilities/municipalityLabels'
import {
DEFAULT_MUNICIPALITY_MAP_SCALE_MODE,
isMunicipalityMapScaleModeAvailable,
MUNICIPALITY_MAP_SCALE_MODES,
MUNICIPALITY_MAP_YEARS,
municipalityMapScaleModeHints,
municipalityMapScaleModeLabels,
municipalityMapYearLabels,
type MunicipalityMapBundle,
@ -33,6 +61,75 @@ import {
type MunicipalityMapYear,
} from '@/utilities/municipalityMapContract'
import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/** The legend's note doubles as the scale selector's description. */
const SCALE_NOTE_ID = 'municipality-map-scale-note'
const BUBBLE_TOGGLE_ID = 'municipality-map-bubbles'
/**
* Scales that have their own glossary entry (E18). LQ is documented as
* "dominância relativa" — the same ratio under the name the list already uses.
*/
const SCALE_CONCEPT_ID: Partial<Record<MunicipalityMapScaleMode, CampaignConceptId>> = {
quantile: 'quantis-do-mapa',
lq: 'dominancia-relativa',
competitiveRank: 'posicao-no-municipio',
}
/** Reference sizes in the bubble key: the largest bubble and a quarter of it. */
const BUBBLE_KEY_FRACTIONS = [0.25, 1] as const
/**
* Two encodings need decoding when the layer is on — how big the prize is
* (area) and how he stands there (colour) — so the key carries both, with
* real circles rather than a sentence about sizes nobody can eyeball.
*/
const MapBubbleKey = ({ largestValue }: { largestValue: number }) => (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<ul className="flex items-center gap-3" aria-label="Tamanho da bolha por votos em jogo">
{BUBBLE_KEY_FRACTIONS.map((fraction) => {
const diameter = 2 * bubbleRadius(fraction, 1)
return (
<li key={fraction} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="shrink-0 rounded-full bg-muted-foreground/70 ring-1 ring-background"
style={{ width: diameter, height: diameter }}
/>
<span className="text-xs tabular-nums text-muted-foreground">
{formatElectionNumber(Math.round(largestValue * fraction))}
</span>
</li>
)
})}
</ul>
<ul
className="flex flex-wrap items-center gap-x-3 gap-y-1.5"
aria-label="Cor da bolha por classe do município"
>
{Object.entries(territorialClassLabels).map(([territorialClass, label]) => (
<li key={territorialClass} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-full ring-1 ring-background"
style={{
backgroundColor:
territorialClassMapFill[territorialClass as MunicipalityTerritorialClass],
}}
/>
<span className="text-xs text-muted-foreground">{label}</span>
</li>
))}
</ul>
</div>
<p className="text-xs text-muted-foreground">
Tamanho = votos válidos projetados para 2026, o que está em jogo ali. Cor = classe do
município, a leitura da lista.
</p>
</div>
)
type MunicipalityMapPanelProps = {
bundle: MunicipalityMapBundle
@ -49,7 +146,10 @@ export const MunicipalityMapPanel = ({
const pathname = usePathname()
const searchParams = useSearchParams()
const [year, setYear] = useState<MunicipalityMapYear>(defaultYear)
const [scaleMode, setScaleMode] = useState<MunicipalityMapScaleMode>('percentValid')
const [scaleMode, setScaleMode] = useState<MunicipalityMapScaleMode>(
DEFAULT_MUNICIPALITY_MAP_SCALE_MODE,
)
const [bubblesEnabled, setBubblesEnabled] = useState(false)
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const [localEstimateScenario, setLocalEstimateScenario] = useState<VoteEstimateScenario>(
DEFAULT_VOTE_ESTIMATE_SCENARIO,
@ -59,7 +159,20 @@ export const MunicipalityMapPanel = ({
const comparison = bundle.comparison
const comparisonActive = comparison !== null && year !== 2026
const percentScaleActive = !comparisonActive && scaleMode === 'percentValid'
// Comparing forces the diverging absolute scale, and the competitive
// placement has no 2026 reading (it is a TSE result). Rather than let the
// select show a mode the map isn't painting, fall back — and the legend note
// below says which mode stepped aside, so the fallback isn't silent.
const effectiveScaleMode: MunicipalityMapScaleMode = comparisonActive
? 'absolute'
: isMunicipalityMapScaleModeAvailable(scaleMode, year)
? scaleMode
: DEFAULT_MUNICIPALITY_MAP_SCALE_MODE
const percentScaleActive = effectiveScaleMode === 'percentValid'
// The compare map already spends colour on two candidates; a third encoding
// on top of it would be unreadable, so the bubbles step aside there.
const showBubbles = bubblesEnabled && !comparisonActive
const rawValues = useMemo(() => {
if (comparisonActive && comparison) {
@ -93,6 +206,98 @@ export const MunicipalityMapPanel = ({
[bundle.municipalitiesByIbgeCode],
)
/** LQ per município against his statewide standard — the same ratio E10 classes on. */
const lqByCode = 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
if (votes <= 0 || validVotes <= 0) continue
lq[code] = votes / validVotes / statewideShare
}
return lq
}, [bundle.statewideShareByYear, rawValues, validVotesForYear, year])
const competitiveRankForYear = useMemo(
() => bundle.competitiveRankByYear[String(year)] ?? {},
[bundle.competitiveRankByYear, year],
)
const classing = useMemo(() => {
switch (effectiveScaleMode) {
case 'quantile':
return buildQuantileClassing(rawValues)
case 'lq':
return buildLqClassing(lqByCode)
case 'competitiveRank':
return buildCompetitiveRankClassing(
Object.fromEntries(
Object.entries(competitiveRankForYear).map(([code, placement]) => [
code,
placement.rank,
]),
),
)
default:
return null
}
}, [competitiveRankForYear, effectiveScaleMode, lqByCode, 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
// classed and grey out every polygon under a red gradient legend.
const classed = classing !== null && classing.classes.length > 0
const fillByKey = useMemo(
() => (classed && classing ? fillsForClassing(classing) : undefined),
[classed, classing],
)
/**
* What the colour means, said ONCE and next to the swatches that need
* decoding — putting it under the selector instead made the control column
* taller than the title beside it, and repeated the sentence the legend was
* already carrying.
*/
const legendNote = useMemo(() => {
const parts = [municipalityMapScaleModeHints[effectiveScaleMode]]
if (!isMunicipalityMapScaleModeAvailable(scaleMode, year) && !comparisonActive) {
parts.push(
`${municipalityMapScaleModeLabels[scaleMode]} só existe nos anos com resultado do TSE, então ${year} volta para esta escala.`,
)
}
if (classed && classing) {
const painted = Object.keys(classing.classIndexByKey).length
// One sentence for the coverage, not two: "N com votos" plus "M em
// cinza" made the reader add them up to check they matched the scope.
parts.push(
painted === scopedKeys.length
? `Todos os ${formatElectionNumber(painted)} municípios do seu escopo entraram na escala.`
: `${formatElectionNumber(painted)} de ${formatElectionNumber(scopedKeys.length)} municípios do seu escopo entraram na escala; os demais ficam em cinza.`,
)
if (effectiveScaleMode === 'quantile' && classing.classes.length < QUANTILE_CLASSES) {
parts.push(
painted < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT
? `São poucos para cinco faixas, então a escala usa ${classing.classes.length}.`
: `Os valores se repetem demais para cinco faixas, então a escala usa ${classing.classes.length}.`,
)
}
if (effectiveScaleMode === 'lq') {
parts.push('1× é a média estadual dele, não a dos municípios em exibição.')
}
if (effectiveScaleMode === 'competitiveRank') {
parts.push('Vale para a cidade inteira: em Salvador, as 19 zonas dividem a mesma posição.')
}
}
return parts.join(' ')
}, [classed, classing, comparisonActive, effectiveScaleMode, scaleMode, scopedKeys.length, year])
const metricLabel = useMemo(() => {
if (comparisonActive) {
return `diferença de votos em ${year}`
@ -108,6 +313,74 @@ export const MunicipalityMapPanel = ({
return `votos de ${bundle.candidateName} em ${year}`
}, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year])
/**
* Votes at stake, not votes won: the bubble asks "how big is the prize
* here", which is a different question from the colour's "how is he doing
* here" — reading them together is the point of the layer.
*
* Both sides are already stable references, so there is nothing to memoize.
*/
const bubbleValues = showBubbles ? bundle.projectedValidVotesByCode : undefined
const bubbleFillByKey = useMemo(() => {
if (!showBubbles) return undefined
const fills: ChoroplethFills = {}
for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) {
fills[code] = territorialClassMapFill[territorialClass]
}
return fills
}, [bundle.territorialClassByCode, 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.
const largestBubbleValue = bubbleValues ? choroplethMaxValue(bubbleValues) : 0
const bubbleReadingFor = useCallback(
(key: string): string | null => {
if (!showBubbles) return null
const projected = bundle.projectedValidVotesByCode[key] ?? 0
if (projected <= 0) return null
const territorialClass = bundle.territorialClassByCode[key]
const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null
return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}`
},
[bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles],
)
/**
* The relative sentence for one município — the class alone ("Q4") is a
* verdict without evidence, so the readout always spells out what the colour
* means for the município under the cursor.
*/
const relativeReadingFor = useCallback(
(key: string): string | null => {
if (!classing) return null
const classIndex = classing.classIndexByKey[key]
switch (effectiveScaleMode) {
case 'quantile': {
if (classIndex === undefined) return null
const range = classing.classes[classIndex]?.label
return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}`
}
case 'lq': {
const lq = lqByCode[key]
return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq)
}
case 'competitiveRank': {
const placement = competitiveRankForYear[key]
if (!placement) return null
return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui`
}
default:
return null
}
},
[classing, competitiveRankForYear, effectiveScaleMode, lqByCode],
)
// Optimistic on the control, honest pending on the map: the comparison data
// is a server round trip, so the select flips at once and the map region
// dims until the refreshed bundle arrives.
@ -140,12 +413,12 @@ export const MunicipalityMapPanel = ({
<h2 id="municipality-map-title" className="text-base font-medium">
Mapa dos Municípios
</h2>
{/* What the map is made of stays here; what the COLOUR means is
said once, next to the scale selector that decides it. */}
<p className="text-sm text-muted-foreground">
{comparisonActive && comparison
? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.`
: percentScaleActive
? `Cor pela participação nos votos válidos — em 2026, estimativas sobre válidos de 2022.`
: `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`}
: `Votação de ${bundle.candidateName} por município — ${year === 2026 ? 'em 2026, pelas estimativas da campanha' : `em ${year}, pelo resultado do TSE`}.`}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
@ -165,15 +438,18 @@ export const MunicipalityMapPanel = ({
</NativeSelect>
</Field>
{!comparisonActive ? (
<Field className="w-full sm:w-44">
<Field className="w-full sm:w-52">
<FieldLabel htmlFor="municipality-map-scale">Escala</FieldLabel>
<NativeSelect
id="municipality-map-scale"
value={scaleMode}
value={effectiveScaleMode}
onChange={(event) => setScaleMode(event.target.value as MunicipalityMapScaleMode)}
className="min-h-11 w-full"
aria-describedby={SCALE_NOTE_ID}
>
{MUNICIPALITY_MAP_SCALE_MODES.map((mode) => (
{MUNICIPALITY_MAP_SCALE_MODES.filter((mode) =>
isMunicipalityMapScaleModeAvailable(mode, year),
).map((mode) => (
<NativeSelectOption key={mode} value={mode}>
{municipalityMapScaleModeLabels[mode]}
</NativeSelectOption>
@ -241,16 +517,51 @@ export const MunicipalityMapPanel = ({
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : classed && classing ? (
<MapScaleLegend
classing={classing}
metricLabel={metricLabel}
note={legendNote}
noteId={SCALE_NOTE_ID}
conceptID={SCALE_CONCEPT_ID[effectiveScaleMode]}
/>
) : displayMax > 0 ? (
<ChoroplethLegend
max={displayMax}
metricLabel={metricLabel}
formatMax={percentScaleActive ? () => '100%' : undefined}
note={legendNote}
noteId={SCALE_NOTE_ID}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
<p id={SCALE_NOTE_ID} className="text-sm text-muted-foreground">
Sem dados para este ano no seu escopo.
</p>
)}
{/* A second encoding, so it is opt-in and its key only appears with it. */}
{!comparisonActive ? (
<div className="flex flex-col gap-2">
<Field orientation="horizontal" className="min-h-11 w-fit">
<Checkbox
id={BUBBLE_TOGGLE_ID}
checked={bubblesEnabled}
onCheckedChange={(checked) => setBubblesEnabled(checked === true)}
/>
<FieldLabel htmlFor={BUBBLE_TOGGLE_ID}>Bolhas por votos em jogo</FieldLabel>
</Field>
{showBubbles ? (
largestBubbleValue > 0 ? (
<MapBubbleKey largestValue={largestBubbleValue} />
) : (
<p className="text-xs text-muted-foreground">
Sem projeção de votos válidos para os municípios do seu escopo.
</p>
)
) : null}
</div>
) : null}
{comparison && year === 2026 ? (
<p className="text-sm text-muted-foreground">
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para
@ -262,9 +573,14 @@ export const MunicipalityMapPanel = ({
displayValues={displayValues}
rawValues={rawValues}
displayMax={displayMax}
fillByKey={fillByKey}
bubbleValues={bubbleValues}
bubbleFillByKey={bubbleFillByKey}
scopedKeys={scopedKeys}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
scaleMode={effectiveScaleMode}
relativeReadingFor={relativeReadingFor}
bubbleReadingFor={bubbleReadingFor}
comparisonActive={comparisonActive}
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
ariaLabel={
@ -320,19 +636,29 @@ const MunicipalityMapSelection = ({
displayValues,
rawValues,
displayMax,
fillByKey,
bubbleValues,
bubbleFillByKey,
scopedKeys,
metricLabel,
scaleMode,
relativeReadingFor,
bubbleReadingFor,
comparisonActive,
municipalitiesByIbgeCode,
ariaLabel,
}: {
displayValues: Record<string, number>
rawValues: Record<string, number>
displayValues: ChoroplethValues
rawValues: ChoroplethValues
displayMax: number
fillByKey: ChoroplethFills | undefined
bubbleValues: ChoroplethValues | undefined
bubbleFillByKey: ChoroplethFills | undefined
scopedKeys: string[]
metricLabel: string
scaleMode: MunicipalityMapScaleMode
relativeReadingFor: (key: string) => string | null
bubbleReadingFor: (key: string) => string | null
comparisonActive: boolean
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
ariaLabel: string
@ -388,6 +714,9 @@ const MunicipalityMapSelection = ({
mode="municipality"
values={displayValues}
scaleMax={displayMax > 0 ? displayMax : undefined}
fillByKey={fillByKey}
bubbleValues={bubbleValues}
bubbleFillByKey={bubbleFillByKey}
fillMode={comparisonActive ? 'diverging' : 'sequential'}
fitToKeys={scopedKeys}
interactiveKeys={scopedKeys}
@ -403,6 +732,8 @@ const MunicipalityMapSelection = ({
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={scaleMode}
relativeReading={selectedFeature ? relativeReadingFor(selectedFeature.key) : null}
bubbleReading={selectedFeature ? bubbleReadingFor(selectedFeature.key) : null}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>

View file

@ -1,10 +1,10 @@
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat'
import {
DEFAULT_VOTE_RANK_YEAR,
formatMunicipalityVoteRank,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
formatElectionNumber,
formatPlacementOrdinal,
formatVoteSharePercent,
} from '@/lib/electionFormat'
import { DEFAULT_VOTE_RANK_YEAR, getMunicipalityVoteRank } from '@/lib/municipalityVoteRank'
import { computeVoteTrend } from '@/lib/voteTrend'
import type { MunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
import { formatMunicipalityConcentrationHint } from '@/utilities/municipalityLabels'
@ -81,10 +81,10 @@ export const MunicipalityBaselineCard = ({
{position ? (
<dd
className="mt-1 flex flex-col gap-0.5 text-xs tabular-nums text-muted-foreground"
aria-label={`${formatVoteSharePercent(position.share)} da votação estadual, ${formatMunicipalityVoteRank(position.rank)} de ${formatElectionNumber(position.totalUnits)}`}
aria-label={`${formatVoteSharePercent(position.share)} da votação estadual, ${formatPlacementOrdinal(position.rank)} de ${formatElectionNumber(position.totalUnits)}`}
>
<span>{formatVoteSharePercent(position.share)}</span>
<span>{formatMunicipalityVoteRank(position.rank)}</span>
<span>{formatPlacementOrdinal(position.rank)}</span>
</dd>
) : (
<dd className="mt-1 text-xs text-muted-foreground">—</dd>

View file

@ -25,8 +25,11 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat'
import { formatMunicipalityVoteRank } from '@/lib/municipalityVoteRank'
import {
formatElectionNumber,
formatPlacementOrdinal,
formatVoteSharePercent,
} from '@/lib/electionFormat'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
@ -95,7 +98,7 @@ const VotePositionReadout = ({
layout: 'table' | 'card'
}) => {
const share = formatVoteSharePercent(position.share)
const rank = formatMunicipalityVoteRank(position.rank)
const rank = formatPlacementOrdinal(position.rank)
const metaLine = `${formatElectionNumber(position.votes)} · ${rank}`
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`

View file

@ -16,6 +16,12 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
* this artifact; a future consumer that needs it extends `majoritarian2022`
* into a `majoritarianByYear` map.
*
* v3 (B13 "posição no município"): adds `federalRankByIbgeCode` — where the
* candidate placed among every federal-deputy candidate voted inside that
* municipality. Keyed by IBGE code, not by catalog slug, because it is the
* map's key and because the competitive question only has an answer for a
* whole city: Salvador's 19 zone municipalities share one placement.
*
* Generated by `pnpm build:election-aggregates` from the locally seeded TSE
* data — never computed at build or request time (see the script header for
* the rationale). A snapshot test pins artifact keys ↔ catalog slugs.
@ -50,12 +56,22 @@ export type MunicipalityFederalBaseline = {
}
}
/** Where the candidate placed among the federal-deputy candidates voted in one municipality. */
export type FederalCompetitiveRank = {
/** 1 = most voted. Ties share a placement (competition rank). */
rank: number
/** Candidates with at least one vote there — the honest denominator for "12º de …". */
candidates: number
}
type FederalBaselineArtifact = {
provenance: string
candidateNumber: number
candidateName: string
years: number[]
municipalities: Record<string, MunicipalityFederalBaseline>
/** IBGE codarea → year (as string) → placement. Absent where he took no votes. */
federalRankByIbgeCode: Record<string, Record<string, FederalCompetitiveRank>>
}
const artifact = artifactJson as FederalBaselineArtifact
@ -79,6 +95,15 @@ const EMPTY_BASELINE: MunicipalityFederalBaseline = {
export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline =>
artifact.municipalities[slug] ?? EMPTY_BASELINE
/**
* Competitive placement inside one IBGE municipality for one year, or `null`
* where the candidate took no votes there (never a misleading last place).
*/
export const getFederalCompetitiveRank = (
ibgeCode: string,
year: number,
): FederalCompetitiveRank | null => artifact.federalRankByIbgeCode[ibgeCode]?.[String(year)] ?? null
/** Statewide sums of the artifact for one year — the denominators of every relative reading. */
type StatewideFederalTotals = {
/** Candidate's own nominal votes across every catalog municipality. */

View file

@ -5,6 +5,7 @@ import {
choroplethMaxAbsValue,
choroplethMaxValue,
divergingFillColor,
NO_DATA_FILL,
} from '@/lib/choroplethColorScale'
export type ChoroplethValues = Record<string, number>
@ -13,10 +14,18 @@ export type BahiaMapFillMode = 'sequential' | 'diverging'
export type FeatureKeyProperty = 'codarea' | 'code'
/**
* Feature key → fill, for the discrete relative scales (B13). When present it
* REPLACES the continuous ramp: a key absent from the record has no class and
* renders as "no data", which is not the same as a value of zero.
*/
export type ChoroplethFills = Record<string, string>
export type LayerStyleContext = {
values: ChoroplethValues
fillMode: BahiaMapFillMode
max: number
fillByKey?: ChoroplethFills
highlightSet: Set<string>
selectedKey: string | null
hoveredKey: string | null
@ -27,6 +36,8 @@ type FeatureStyleInput = {
highlighted: boolean
fillMode: BahiaMapFillMode
max: number
classFill?: string
classed: boolean
}
const getFeatureStyle = ({
@ -34,12 +45,17 @@ const getFeatureStyle = ({
highlighted,
fillMode,
max,
classFill,
classed,
}: FeatureStyleInput): PathOptions => ({
weight: highlighted ? 2 : 1,
color: highlighted ? '#c51414' : '#a8a29e',
fillColor:
fillMode === 'diverging' ? divergingFillColor(metric, max) : choroplethFillColor(metric, max),
fillOpacity: metric !== 0 ? 0.78 : 0.35,
fillColor: classed
? (classFill ?? NO_DATA_FILL)
: fillMode === 'diverging'
? divergingFillColor(metric, max)
: choroplethFillColor(metric, max),
fillOpacity: (classed ? classFill !== undefined : metric !== 0) ? 0.78 : 0.35,
})
export const computeChoroplethMax = (
@ -50,6 +66,25 @@ export const computeChoroplethMax = (
scaleMax ??
(fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values))
/**
* B13 — proportional symbols scale by the SQUARE ROOT of the magnitude, so the
* AREA of the circle carries the value: sizing the radius directly would make
* a município with twice the votes look four times as big. Flannery's
* perceptual correction is deliberately skipped — it inflates the large end on
* purpose, and here the exact number is one hover away in the readout.
*
* Lives here rather than in the Leaflet component because the legend draws its
* reference circles from the same formula; two implementations would let the
* key claim a size the map does not paint.
*/
const BUBBLE_MAX_RADIUS = 16
/** Small enough that a crowded east reads as texture, not as a second choropleth. */
const BUBBLE_MIN_RADIUS = 1.5
export const bubbleRadius = (value: number, max: number): number =>
max > 0 ? Math.max(BUBBLE_MIN_RADIUS, BUBBLE_MAX_RADIUS * Math.sqrt(value / max)) : 0
export const buildHighlightSet = (highlightKey: string): Set<string> =>
new Set(highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [])
@ -65,12 +100,15 @@ export const featureKeyFromProperties = (
): string | undefined => properties?.[keyProperty]
export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => {
const classed = context.fillByKey !== undefined
if (!key) {
return getFeatureStyle({
metric: 0,
highlighted: false,
fillMode: context.fillMode,
max: context.max,
classed,
})
}
@ -83,6 +121,8 @@ export const resolvePathStyle = (context: LayerStyleContext, key: string): PathO
highlighted,
fillMode: context.fillMode,
max: context.max,
classFill: context.fillByKey?.[key],
classed,
})
}
@ -90,6 +130,7 @@ export const buildLayerStyleContext = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey,
selectedKey,
hoveredKey = null,
@ -97,6 +138,7 @@ export const buildLayerStyleContext = ({
values: ChoroplethValues
fillMode: BahiaMapFillMode
scaleMax?: number
fillByKey?: ChoroplethFills
highlightKey: string
selectedKey: string | null
hoveredKey?: string | null
@ -104,6 +146,7 @@ export const buildLayerStyleContext = ({
values,
fillMode,
max: computeChoroplethMax(values, fillMode, scaleMax),
fillByKey,
highlightSet: buildHighlightSet(highlightKey),
selectedKey,
hoveredKey,

View file

@ -10,9 +10,11 @@
* actually computes; when a formula changes, this text changes with it.
*
* v1 covers the E8 "conta da cadeira" numbers; E10 added dominância relativa
* and a classe territorial. Each later slice of the intelligence program (B13
* escala do mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its
* own entries as part of its own delivery.
* and a classe territorial; B13 added the two map scales that are not just a
* re-rendering of an existing number (quantis and posição no município — LQ is
* already documented as dominância relativa). Each later slice of the
* intelligence program (E11 sugestões, E12 TI, E13 giros, E14 níveis) appends
* its own entries as part of its own delivery.
*/
export type CampaignConceptId =
@ -23,6 +25,8 @@ export type CampaignConceptId =
| 'roll-off'
| 'dominancia-relativa'
| 'classe-territorial'
| 'quantis-do-mapa'
| 'posicao-no-municipio'
| 'meta'
| 'cobertura-da-meta'
@ -146,6 +150,34 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
whereItAppears:
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, e a coluna "Classe" da lista de municípios (com filtro e ordenação).',
},
{
id: 'quantis-do-mapa',
categoryID: 'diagnostico',
title: 'Quantis (escala do mapa)',
oneLiner:
'Divide os municípios em cinco faixas com o mesmo número de municípios cada, da menor votação para a maior.',
formula:
'Ordena os municípios do seu escopo que tiveram votos e corta a lista em cinco partes iguais em QUANTIDADE de municípios. Cada faixa da legenda mostra o menor e o maior valor real que caíram nela. Com menos de 10 municípios em tela a escala cai para três faixas, e a legenda avisa.',
example:
'Com 415 municípios, cada faixa tem cerca de 83. A faixa mais escura pode ir de 267 a 27.264 votos — as distâncias entre faixas não são iguais, o número de municípios é.',
whyItMatters:
'Numa disputa de deputado federal, uma escala de 0 a 100% pinta a Bahia inteira de uma cor só: o melhor município dele dá menos de 5% dos válidos de lá. O quantil sempre usa a paleta inteira, então o mapa volta a discriminar. O preço é que a cor é relativa ao conjunto em tela — não compare a cor de uma carteira de assessor com a cor do mapa estadual.',
whereItAppears: 'Mapa dos Municípios, no Início — é a escala padrão.',
},
{
id: 'posicao-no-municipio',
categoryID: 'diagnostico',
title: 'Posição no município',
oneLiner:
'Em que lugar o Jorge Solla ficou entre todos os candidatos a deputado federal votados naquele município.',
formula:
'Ordena todos os candidatos a deputado federal por votos no município (1º turno do ano escolhido) e lê a colocação dele. Vale para a cidade inteira: em Salvador, as 19 zonas dividem a mesma posição, porque o mapa desenha a cidade e não a zona.',
example:
'5.005 votos em Vitória da Conquista o colocaram em 6º entre 663 candidatos votados lá.',
whyItMatters:
'É a leitura de competição que falta nas outras escalas: dominância compara ele com ele mesmo, e a posição compara ele com quem disputa a mesma cadeira ali. Ser 1º num município pequeno é uma relação política concreta; ser 40º num município grande diz que o voto está pulverizado. Só existe para anos com resultado do TSE — 2026 não tem posição.',
whereItAppears: 'Mapa dos Municípios, no Início (escala "Posição no município").',
},
{
id: 'meta',
categoryID: 'meta',

View file

@ -4,9 +4,12 @@ const choroplethGradientEnd = { r: 197, g: 20, b: 20 } as const
export const choroplethGradientCss = `linear-gradient(to right, rgb(${choroplethGradientStart.r} ${choroplethGradientStart.g} ${choroplethGradientStart.b}), rgb(${choroplethGradientEnd.r} ${choroplethGradientEnd.g} ${choroplethGradientEnd.b}))`
/** Painted where the geography has no reading at all — never where the value is zero. */
export const NO_DATA_FILL = '#f4f4f5'
/** Sequential fill for campaign choropleth maps (primary red scale). */
export const choroplethFillColor = (value: number, max: number): string => {
if (value <= 0 || max <= 0) return '#f4f4f5'
if (value <= 0 || max <= 0) return NO_DATA_FILL
const ratio = Math.min(1, value / max)
const start = choroplethGradientStart
@ -17,6 +20,23 @@ export const choroplethFillColor = (value: number, max: number): string => {
return `rgb(${r}, ${g}, ${b})`
}
/**
* The same sequential ramp sampled at `total` evenly spaced steps — the fill
* for class `index` (0 = weakest). Discrete classes are what make a relative
* scale readable at a glance: a continuous ramp over a federal-deputy race
* paints almost the whole state the same shade (B11's lesson).
*
* The floor keeps the lightest class distinguishable from `NO_DATA_FILL`.
*/
const DISCRETE_CLASS_FLOOR = 0.18
export const discreteChoroplethFill = (index: number, total: number): string => {
if (total === 1) return choroplethFillColor(1, 1)
const step = index / (total - 1)
return choroplethFillColor(DISCRETE_CLASS_FLOOR + (1 - DISCRETE_CLASS_FLOOR) * step, 1)
}
export const choroplethMaxValue = (values: Record<string, number>): number => {
const entries = Object.values(values).filter((value) => value > 0)
return entries.length > 0 ? Math.max(...entries) : 0

File diff suppressed because it is too large Load diff

View file

@ -22,3 +22,10 @@ export const formatElectionNumber = (value: number): string => numberFormatter.f
*/
export const formatVoteSharePercent = (share: number): string =>
`${oneDecimalFormatter.format(share * 100)}%`
/**
* A placement as a pt-BR ordinal — "4º". Used for both rankings the product
* shows (a município among the 435, A11; the candidate among those votados
* num município, B13), and here for the same bundle reason as the share above.
*/
export const formatPlacementOrdinal = (rank: number): string => `${formatElectionNumber(rank)}º`

195
src/lib/mapScaleClasses.ts Normal file
View file

@ -0,0 +1,195 @@
import { discreteChoroplethFill } from '@/lib/choroplethColorScale'
import { formatElectionNumber, formatPlacementOrdinal } from '@/lib/electionFormat'
import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
/**
* B13 — relative map scales: the pure statistics behind the choropleth's
* discrete classes, with no Leaflet and no data access, so the map and its
* legend read the same source and a unit test can pin every cut.
*
* Why relative at all: "% dos válidos" (B11) is honest and useless here — a
* federal-deputy candidate tops out around 5% of a município's valid votes,
* so a 0–100% ramp paints Bahia one colour. Every scale below is measured
* against the candidate himself (his own distribution, his own statewide
* standard, his own placement), which is what discriminates.
*/
type MapScaleClass = {
/** Fill painted on the polygon and swatched in the legend. */
fill: string
/** pt-BR range label — "13–48", "2× ou mais", "2º–3º". */
label: string
}
export type MapScaleClassing = {
/** Weakest first, so every legend reads left-to-right the same way. */
classes: MapScaleClass[]
/** Feature key → class index. Keys without a class are absent, never 0. */
classIndexByKey: Record<string, number>
}
const EMPTY_CLASSING: MapScaleClassing = { classes: [], classIndexByKey: {} }
const buildClassing = (
ranges: ReadonlyArray<{ label: string }>,
classIndexByKey: Record<string, number>,
): MapScaleClassing => ({
classes: ranges.map((range, index) => ({
label: range.label,
fill: discreteChoroplethFill(index, ranges.length),
})),
classIndexByKey,
})
/** Full quantile split; below this many features the split degrades. */
export const QUANTILE_CLASSES = 5
/**
* An advisor's portfolio can be a handful of municípios. Cutting eight values
* into five "quantiles" invents precision, so the split drops to three.
*/
export const QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT = 10
const QUANTILE_REDUCED_CLASSES = 3
/**
* Quantile classes over the positive values of the rendered set (Brewer &
* Pickle: quantiles discriminate by construction and stay comparable across
* years, unlike Jenks, whose breaks move with the data).
*
* Labels carry the real extremes of each class rather than the computed
* break, so no gap is ever implied between "até 12" and "13–48".
*/
export const buildQuantileClassing = (values: Record<string, number>): MapScaleClassing => {
const entries = Object.entries(values).filter(([, value]) => value > 0)
if (entries.length === 0) return EMPTY_CLASSING
const sorted = entries.map(([, value]) => value).sort((left, right) => left - right)
const distinct = new Set(sorted).size
const desired =
sorted.length < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT
? QUANTILE_REDUCED_CLASSES
: QUANTILE_CLASSES
const count = Math.min(desired, distinct)
// Upper bound of each quantile, de-duplicated: heavy ties (many municípios
// on the same vote count) can collapse two quantiles into one bound, and a
// repeated bound would render as an empty class in the legend.
const upperBounds: number[] = []
for (let index = 1; index <= count; index += 1) {
const position = Math.ceil((index * sorted.length) / count) - 1
const bound = sorted[position]
if (upperBounds.at(-1) !== bound) upperBounds.push(bound)
}
const extremes = upperBounds.map(() => ({ min: Number.POSITIVE_INFINITY, max: 0 }))
const classIndexByKey: Record<string, number> = {}
for (const [key, value] of entries) {
// Always found: the last bound is the maximum of the same set.
const index = upperBounds.findIndex((bound) => value <= bound)
classIndexByKey[key] = index
extremes[index].min = Math.min(extremes[index].min, value)
extremes[index].max = Math.max(extremes[index].max, value)
}
const ranges = extremes.map(({ min, max }) => ({
label:
min === max
? formatElectionNumber(min)
: `${formatElectionNumber(min)}–${formatElectionNumber(max)}`,
}))
return buildClassing(ranges, classIndexByKey)
}
/**
* Two decimals, not one: the "no padrão" band starts at 0,95× and rounding
* that to "1,0×" would print the same number on two adjacent legend swatches.
*/
const lqMultipleFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 })
const formatLqMultiple = (value: number): string => `${lqMultipleFormatter.format(value)}×`
/**
* Location-quotient classes cut on the SAME anchors as E10's territorial
* class, so the map and the list's "Classe" column can never disagree about
* where "reduto" starts.
*
* Deliberately NOT the diverging red↔blue scale: in this product that palette
* means "Solla × adversário" (the compare mode), and reusing it for "above ×
* below his own standard" would make two different questions look identical.
*/
const LQ_CLASS_RANGES = [
{
upperBound: TERRITORIAL_CLASS_ANCHORS.weakLq,
label: `menos de ${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.weakLq)}`,
},
{
upperBound: AT_STANDARD_LQ.min,
label: `${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.weakLq)}–${formatLqMultiple(AT_STANDARD_LQ.min)}`,
},
{ upperBound: AT_STANDARD_LQ.max, label: 'no padrão (≈1×)' },
{
upperBound: TERRITORIAL_CLASS_ANCHORS.strongLq,
label: `${formatLqMultiple(AT_STANDARD_LQ.max)}–${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.strongLq)}`,
},
{
upperBound: Number.POSITIVE_INFINITY,
label: `${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.strongLq)} ou mais`,
},
] as const
export const buildLqClassing = (lqByKey: Record<string, number>): MapScaleClassing => {
const classIndexByKey: Record<string, number> = {}
for (const [key, lq] of Object.entries(lqByKey)) {
if (!(lq > 0)) continue
classIndexByKey[key] = LQ_CLASS_RANGES.findIndex((range) => lq < range.upperBound)
}
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
return buildClassing(LQ_CLASS_RANGES, classIndexByKey)
}
/** Best placement that still counts as a contender rather than the field. */
const COMPETITIVE_RANK_CONTENDER = 3
/**
* Competitive placement, ordered weakest first so the legend keeps the same
* left-to-right reading as the other scales even though a LOWER placement
* number is the better one. `bestRank` is the strongest placement each band
* accepts, and the labels are derived from it — the same shape as the LQ
* ranges above, so moving a cut can never leave the legend describing the old one.
*/
const COMPETITIVE_RANK_RANGES = [
{
bestRank: COMPETITIVE_RANK_CONTENDER + 1,
label: `${formatPlacementOrdinal(COMPETITIVE_RANK_CONTENDER + 1)} ou pior`,
},
{
bestRank: 2,
label: `${formatPlacementOrdinal(2)}–${formatPlacementOrdinal(COMPETITIVE_RANK_CONTENDER)}`,
},
{ bestRank: 1, label: formatPlacementOrdinal(1) },
] as const
export const buildCompetitiveRankClassing = (
rankByKey: Record<string, number>,
): MapScaleClassing => {
const classIndexByKey: Record<string, number> = {}
for (const [key, rank] of Object.entries(rankByKey)) {
if (!(rank >= 1)) continue
classIndexByKey[key] = COMPETITIVE_RANK_RANGES.findIndex((range) => rank >= range.bestRank)
}
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
return buildClassing(COMPETITIVE_RANK_RANGES, classIndexByKey)
}
/** Feature key → fill, ready for `BahiaMap`'s `fillByKey`. */
export const fillsForClassing = (classing: MapScaleClassing): Record<string, string> => {
const fills: Record<string, string> = {}
for (const [key, index] of Object.entries(classing.classIndexByKey)) {
const fill = classing.classes[index]?.fill
if (fill) fills[key] = fill
}
return fills
}

View file

@ -3,7 +3,6 @@ import {
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
} from '@/lib/bahiaElectionAggregates'
import { formatElectionNumber } from '@/lib/electionFormat'
/** Year the mesa uses as the current priority lens (A11 list sort + default readout). */
export const DEFAULT_VOTE_RANK_YEAR = 2022
@ -72,8 +71,6 @@ export const getMunicipalityVoteRank = (
year: number = DEFAULT_VOTE_RANK_YEAR,
): MunicipalityVoteRankEntry | null => computeVoteRankByYear(year).get(slug) ?? null
export const formatMunicipalityVoteRank = (rank: number): string => `${formatElectionNumber(rank)}º`
/** Compare for list sort=votos. Zero-vote rows always sort last (any dir). */
export const compareMunicipalityVotesForSort = (
aVotes: number,

View file

@ -0,0 +1,27 @@
/**
* The cut points behind E10's territorial class, in one client-safe module.
*
* They live in `lib/` rather than next to the classifier because the map
* (B13) paints an LQ scale on the SAME cuts the list's "Classe" column uses,
* and the map panel is a client component: importing the classifier there
* would drag the committed TSE artifact (~600 KB of JSON) into the browser
* bundle. Numbers only, no data, no logic — the classifier
* (`municipalityTerritorialClass.ts`) and the copy
* (`municipalityLabels.ts`) both read from here, so a recalibration is still
* a one-line diff in one file.
*
* Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape
* (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact
* numbers can only come from a backtest against 2014–2022 — that is **E15**.
*/
export const TERRITORIAL_CLASS_ANCHORS = {
/** LQ at or above this = performing at least 2× his own statewide standard. */
strongLq: 2,
/** LQ below this = performing at less than half his own standard. */
weakLq: 0.5,
/** Municípios that together hold this share of his statewide vote are the "core block". */
coreCumulativeShare: 0.5,
} as const
/** Band where the LQ multiple rounds to "1×", which says nothing — name it instead. */
export const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const

View file

@ -316,6 +316,72 @@ export const loadCampoFederalVotesByCityZone = async (
return votes
}
/**
* cityCode:zone → (candidateNumber → nominal deputado-federal T1 votes) for
* one year — the whole statewide slice, ungrouped by candidate, so a caller
* can fold it over any municipality geography and rank candidates against
* each other (B13's "posição no município").
*
* CLI/build-time use only, same as `loadCampoFederalVotesByCityZone`: this
* scans ~100k rows and must never run on a request path — the ranking it
* feeds is precomputed into the committed artifact.
*/
export const loadFederalVotesByCityZoneAndCandidate = async (
payload: Payload,
user: ElectionReader,
{ year }: { year: number },
): Promise<Map<string, Map<number, number>>> => {
assertCanReadElectionData(user)
const result = await payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { equals: year } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { cityCode: true, zoneNumber: true, candidateNumber: true, votes: true },
overrideAccess: true,
})
const byCityZone = new Map<string, Map<number, number>>()
for (const row of result.docs) {
const key = `${row.cityCode}:${row.zoneNumber}`
let byCandidate = byCityZone.get(key)
if (!byCandidate) {
byCandidate = new Map<number, number>()
byCityZone.set(key, byCandidate)
}
byCandidate.set(
row.candidateNumber,
(byCandidate.get(row.candidateNumber) ?? 0) + (row.votes ?? 0),
)
}
return byCityZone
}
/** Folds `loadFederalVotesByCityZoneAndCandidate` over one municipality's cityCode×zones. */
export const sumCandidateVotesForGeography = (
votesByCityZoneAndCandidate: Map<string, Map<number, number>>,
geography: MunicipalityElectionGeography,
): Map<number, number> => {
const total = new Map<number, number>()
for (const zone of geography.zones) {
const byCandidate = votesByCityZoneAndCandidate.get(`${geography.cityCode}:${zone}`)
if (!byCandidate) continue
for (const [candidateNumber, votes] of byCandidate) {
total.set(candidateNumber, (total.get(candidateNumber) ?? 0) + votes)
}
}
return total
}
/** Turnout/valid/blank/null tally cell — same shape the committed artifact stores per year. */
export type OfficeTallyCell = FederalBaselineTallyCell

View file

@ -12,6 +12,7 @@ import {
oneDecimalFormatter,
} from '@/lib/electionFormat'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { AT_STANDARD_LQ } from '@/lib/territorialClassAnchors'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage'
@ -83,8 +84,24 @@ export const territorialClassBadgeVariant = {
sem_base: 'ghost',
} as const
/** Band where the multiple rounds to "1×", which says nothing — name it instead. */
const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const
/**
* The same ladder as fills for the map's proportional symbols (B13). Literal
* hex rather than the CSS tokens above because Leaflet writes the SVG `fill`
* ATTRIBUTE, where `var(--estimate-confirmed)` does not resolve — these mirror
* the token values in `styles.css` (see `DESIGN.md` § Status Badge) and must
* be changed together with them.
*
* Saturated over a basemap: the badge tints are tuned for a white row and
* would disappear on top of tiles, so each step is the readable sibling of
* its token, not the token itself.
*/
export const territorialClassMapFill: Record<MunicipalityTerritorialClass, string> = {
reduto: '#16a34a',
expansao: '#d97706',
manutencao: '#78716c',
marginal: '#a8a29e',
sem_base: '#d6d3d1',
}
/**
* The "por quê" behind a class, in the mesa's own phrasing — "aqui a votação
@ -92,15 +109,19 @@ const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const
* survives the table; "LQ 0,6" is not. The possessive names the candidate
* ("do candidato"), never "seu", which an assessor reads as their own.
*/
export const formatDominanceAgainstOwnStandard = (lq: number): string => {
if (lq >= AT_STANDARD_LQ.min && lq < AT_STANDARD_LQ.max) {
return 'no padrão estadual do candidato'
}
return lq >= 1
? `${oneDecimalFormatter.format(lq)}× o padrão estadual do candidato`
: `${formatRatioAsPercentLabel(1 - lq)} abaixo do padrão estadual do candidato`
}
const formatTerritorialClassFactor = (factor: TerritorialFactor): string => {
switch (factor.id) {
case 'dominance':
if (factor.value >= AT_STANDARD_LQ.min && factor.value < AT_STANDARD_LQ.max) {
return 'no padrão estadual do candidato'
}
return factor.value >= 1
? `${oneDecimalFormatter.format(factor.value)}× o padrão estadual do candidato`
: `${formatRatioAsPercentLabel(1 - factor.value)} abaixo do padrão estadual do candidato`
return formatDominanceAgainstOwnStandard(factor.value)
case 'ownShare':
return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato`
case 'field':

View file

@ -1,6 +1,8 @@
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 { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
/**
* Client-safe contract of the municipality map: the bundle shape the server
@ -13,18 +15,63 @@ import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNaviga
export const MUNICIPALITY_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
export type MunicipalityMapYear = (typeof MUNICIPALITY_MAP_YEARS)[number]
export type MunicipalityMapScaleMode = 'absolute' | 'percentValid'
/**
* B13: the first three modes are RELATIVE to the candidate himself and are
* what actually discriminate on a federal-deputy map — his best município
* gives him ~5% of its valid votes, so `percentValid` and `absolute` paint
* Bahia in one shade plus Salvador. The two absolute modes stay because they
* answer "how many votes, really", which the relative ones deliberately hide.
*/
export type MunicipalityMapScaleMode =
| 'quantile'
| 'lq'
| 'competitiveRank'
| 'percentValid'
| 'absolute'
export const MUNICIPALITY_MAP_SCALE_MODES = [
'quantile',
'lq',
'competitiveRank',
'percentValid',
'absolute',
] as const satisfies readonly MunicipalityMapScaleMode[]
export const DEFAULT_MUNICIPALITY_MAP_SCALE_MODE: MunicipalityMapScaleMode = 'quantile'
export const municipalityMapScaleModeLabels: Record<MunicipalityMapScaleMode, string> = {
absolute: 'Total (votos)',
quantile: 'Quantis (5 faixas)',
lq: 'Padrão próprio (LQ)',
competitiveRank: 'Posição no município',
percentValid: '% dos válidos',
absolute: 'Total (votos)',
}
/**
* What the colour means, printed with the legend the scale selector describes.
*
* Deliberately NOT the canonical E18 `oneLiner` the list's column descriptions
* quote: three of these five scales have a glossary entry and two do not, so
* quoting would fork the voice inside one record — and the legend already
* carries two to four more sentences after this one, where a standalone
* definition ("Divide os municípios em cinco faixas…") reads as padding. The
* "Saiba mais" link next to the note is the path to the full entry.
*/
export const municipalityMapScaleModeHints: Record<MunicipalityMapScaleMode, string> = {
quantile: 'Cinco faixas com o mesmo número de municípios.',
lq: 'Força local comparada ao padrão estadual do próprio candidato.',
competitiveRank: 'Colocação dele entre os candidatos a deputado federal votados ali.',
percentValid:
'Participação nos votos válidos — honesta, mas comprime quase tudo numa faixa estreita.',
absolute: 'Votos em números absolutos — favorece os municípios grandes.',
}
/** Scales that need TSE results and therefore have no 2026 reading. */
export const isMunicipalityMapScaleModeAvailable = (
mode: MunicipalityMapScaleMode,
year: MunicipalityMapYear,
): boolean => mode !== 'competitiveRank' || year !== 2026
export const municipalityMapYearLabels: Record<MunicipalityMapYear, string> = {
2014: '2014 (TSE)',
2018: '2018 (TSE)',
@ -53,6 +100,19 @@ export type MunicipalityMapBundle = {
values2026ByScenario: Record<VoteEstimateScenario, Record<string, number>>
/** year (as string) → codarea → 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
* scale divides by. Statewide, never the share of the municipalities on
* screen, so an advisor's map and the list's "Classe" column measure against
* 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. */
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
/** Zone municipalities in scope (Salvador) with per-year values. */

View file

@ -2,7 +2,12 @@ import 'server-only'
import type { Payload } from 'payload'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import {
getFederalCompetitiveRank,
getMunicipalityFederalBaseline,
getStatewideFederalTotals,
type FederalCompetitiveRank,
} from '@/lib/bahiaElectionAggregates'
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import {
@ -32,6 +37,11 @@ import type {
MunicipalityMapComparison,
} from '@/utilities/municipalityMapContract'
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import { projectedValidVotes } from '@/utilities/municipalityPotential'
import {
computeAggregateTerritorialClass,
type MunicipalityTerritorialClass,
} from '@/utilities/municipalityTerritorialClass'
import {
emptyMunicipalityPledgeAggregate,
resolveMunicipalityStaffVoteTotal,
@ -91,7 +101,12 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const valuesByYear: Record<string, Record<string, number>> = {}
const validVotesByYear: Record<string, Record<string, number>> = {}
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)
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
@ -112,8 +127,37 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
}
valuesByYear[String(year)] = values
validVotesByYear[String(year)] = validValues
// The LQ denominator is his STATEWIDE standard, not the average of what is
// on screen: an advisor looking at 14 municípios must read the same "2×"
// that the list's "Classe" column shows.
const statewide = getStatewideFederalTotals(year)
statewideShareByYear[String(year)] =
statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0
const ranks: Record<string, FederalCompetitiveRank> = {}
for (const ibgeCode of Object.keys(municipalitiesByIbgeCode)) {
const rank = getFederalCompetitiveRank(ibgeCode, year)
if (rank) ranks[ibgeCode] = rank
}
competitiveRankByYear[String(year)] = ranks
}
validVotesByYear['2026'] = validVotesByYear['2022'] ?? {}
// 2026 has no statewide truth to measure against — the estimates only exist
// where the mesa filled them in — so it keeps 2022 as the standard, the same
// 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 pledgeValuesByScenario = Object.fromEntries(
VOTE_ESTIMATE_SCENARIOS.map((scenario) => [scenario, {} as Record<string, number>]),
@ -213,7 +257,11 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
valuesByYear,
values2026ByScenario: pledgeValuesByScenario,
validVotesByYear,
municipalitiesByIbgeCode: buildMunicipalitiesByIbgeCode(municipalities),
statewideShareByYear,
territorialClassByCode,
competitiveRankByYear,
projectedValidVotesByCode,
municipalitiesByIbgeCode,
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,

View file

@ -6,7 +6,13 @@ import {
} from '@/lib/bahiaElectionAggregates'
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
import { captureRate, ownVotes2022, projectedFieldCeiling } from '@/utilities/municipalityPotential'
import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
import {
captureRate,
fieldCeiling,
ownVotes2022,
projectedFieldCeiling,
} from '@/utilities/municipalityPotential'
/**
* E10 "classificação territorial relativa" — the operational class of one
@ -36,22 +42,6 @@ export const TERRITORIAL_CLASSES = [
export type MunicipalityTerritorialClass = (typeof TERRITORIAL_CLASSES)[number]
/**
* Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape
* (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact
* numbers can only come from a backtest against 2014–2022 — that is **E15**.
* They live here, named and versioned, so recalibration is a one-line diff
* with a test to prove what moved.
*/
export const TERRITORIAL_CLASS_ANCHORS = {
/** LQ at or above this = performing at least 2× his own statewide standard. */
strongLq: 2,
/** LQ below this = performing at less than half his own standard. */
weakLq: 0.5,
/** Municípios that together hold this share of his statewide vote are the "core block". */
coreCumulativeShare: 0.5,
} as const
/** The axes behind a class — the UI always shows the "por quê", never the label alone. */
type TerritorialFactorId = 'dominance' | 'ownShare' | 'field' | 'capture'
@ -83,8 +73,13 @@ export type TerritorialClassInput = {
stateValidVotes: number
/** Projected field ceiling minus own votes (E8), floored at 0. */
fieldHeadroom: number
/** Median `fieldHeadroom` across the catalog — the relative "big field" cut. */
medianFieldHeadroom: number
/**
* The "big field" cut `fieldHeadroom` is measured against: the catalog
* median for one município, and that median times the group size for an
* aggregate — headroom is a level, not a ratio, so a summed one has to be
* compared against a reference summed the same way.
*/
fieldHeadroomCut: number
captureRate: number | null
inCoreBlock: boolean
}
@ -130,7 +125,7 @@ export const classifyMunicipalityTerritory = (
// A município inside the core block carries real votes even while
// under-performing, so it can never read as "marginal" — the honest label
// there is expansão (big base, low penetration).
const hasFieldLeft = input.fieldHeadroom >= input.medianFieldHeadroom || input.inCoreBlock
const hasFieldLeft = input.fieldHeadroom >= input.fieldHeadroomCut || input.inCoreBlock
return hasFieldLeft
? { ...shared, class: 'expansao', factors: [fieldFactor, dominance, ...captureFactor] }
: { ...shared, class: 'marginal', factors: [dominance, fieldFactor] }
@ -211,7 +206,7 @@ export const computeMunicipalityTerritorialClass = (
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom: fieldHeadroomOf(baseline),
medianFieldHeadroom,
fieldHeadroomCut: medianFieldHeadroom,
captureRate: captureRate(baseline),
inCoreBlock: coreBlockSlugs.has(slug),
})
@ -220,6 +215,56 @@ export const computeMunicipalityTerritorialClass = (
return classification
}
/**
* The class of a GROUP of catalog slugs read as one territory — the map paints
* one polygon per IBGE municipality, and Salvador is 19 catalog slugs.
*
* It sums the inputs and runs the SAME `classifyMunicipalityTerritory`; it
* never averages or votes on the per-slug classes, because LQ is a ratio and
* the average of ratios is not the ratio of the sums. The ratios (LQ, capture)
* are compared against exactly the statewide standard everyone else gets; the
* one LEVEL among the inputs, field headroom, needs its cut scaled by the
* group size or a 19-slug Salvador would clear a per-município median by an
* order of magnitude and never read as "marginal".
*
* E12's TI rollup inherits this helper rather than growing a second one.
*/
export const computeAggregateTerritorialClass = (
slugs: ReadonlyArray<string>,
): MunicipalityTerritorialClassification => {
if (slugs.length === 1) return computeMunicipalityTerritorialClass(slugs[0])
if (slugs.length === 0) return UNCLASSIFIED
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
let ownVotes = 0
let validVotes = 0
let fieldHeadroom = 0
let fieldCeiling2022 = 0
let inCoreBlock = false
for (const slug of slugs) {
const baseline = getMunicipalityFederalBaseline(slug)
ownVotes += ownVotes2022(baseline)
validVotes += baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0
fieldHeadroom += fieldHeadroomOf(baseline)
fieldCeiling2022 += fieldCeiling(baseline)
inCoreBlock ||= coreBlockSlugs.has(slug)
}
return classifyMunicipalityTerritory({
ownVotes,
validVotes,
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom,
fieldHeadroomCut: medianFieldHeadroom * slugs.length,
captureRate: fieldCeiling2022 > 0 ? ownVotes / fieldCeiling2022 : null,
inCoreBlock,
})
}
/**
* Sort weight for the list's `classe` ordering: descending puts the
* municípios that carry the campaign (reduto) first and the ones without a

View file

@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
import {
FEDERAL_BASELINE_CANDIDATE_NUMBER,
federalBaselineMunicipalitySlugs,
getFederalCompetitiveRank,
getMunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import {
@ -111,12 +112,51 @@ describe('election aggregates artifact', () => {
expect(HISTORICAL_SERIES_YEARS).toContain(ELECTION_YEAR_2022)
})
it('has a plausible competitive placement per IBGE municipality (v3 field)', () => {
const ibgeCodes = new Set(municipalityCatalog.map((entry) => entry.ibgeCode))
let placed = 0
for (const ibgeCode of ibgeCodes) {
for (const year of HISTORICAL_SERIES_YEARS) {
const placement = getFederalCompetitiveRank(ibgeCode, year)
// Absent is legitimate: he took no votes in that municipality that
// year (351/417 municipalities placed in 2014, 416/417 in 2022).
if (!placement) continue
placed += 1
expect(placement.rank).toBeGreaterThanOrEqual(1)
expect(placement.candidates).toBeGreaterThanOrEqual(placement.rank)
}
}
// A statewide campaign places in most municipalities in most years.
expect(placed).toBeGreaterThan(ibgeCodes.size * 2)
})
it('gives Salvador one whole-city placement, not one per zone municipality', () => {
const salvadorZones = municipalityCatalog.filter((entry) => entry.kind === 'zona')
const salvadorCodes = new Set(salvadorZones.map((entry) => entry.ibgeCode))
// The 19 zone municipalities share Salvador's IBGE code, which is why the
// placement is keyed by code: "which place did he come in" only has an
// answer for the whole city.
expect(salvadorZones.length).toBeGreaterThan(1)
expect(salvadorCodes.size).toBe(1)
const [salvadorCode] = salvadorCodes
expect(getFederalCompetitiveRank(salvadorCode, ELECTION_YEAR_2022)).not.toBeNull()
})
it('has no placement for an unknown IBGE code', () => {
expect(getFederalCompetitiveRank('0000000', ELECTION_YEAR_2022)).toBeNull()
})
it('stays under the committed artifact byte budget', () => {
const { size } = statSync(ARTIFACT_PATH)
// v1 (votes + valid votes only) was ~96 KB; v2 adds campo votes, federal
// tally and the 2022 majoritarian slice, measured at ~522 KB — budget
// with headroom, not a precise pin, so future fields don't require
// touching this test.
// tally and the 2022 majoritarian slice, measured at ~522 KB; v3 adds the
// per-IBGE competitive placement, measured at ~613 KB — budget with
// headroom, not a precise pin, so future fields don't require touching
// this test.
expect(size).toBeLessThan(700 * 1024)
})
})

View file

@ -108,4 +108,31 @@ describe('bahiaMapStyle', () => {
expect(style.weight).toBe(1)
})
})
describe('discrete class fills (B13)', () => {
const classedContext = buildLayerStyleContext({
values: { '2927408': 500, '2905701': 200, '2910800': 0 },
fillMode: 'sequential',
fillByKey: { '2927408': '#111111', '2905701': '#eeeeee' },
highlightKey: '',
selectedKey: null,
})
it('paints the class fill instead of the continuous ramp', () => {
expect(resolvePathStyle(classedContext, '2927408').fillColor).toBe('#111111')
expect(resolvePathStyle(classedContext, '2905701').fillColor).toBe('#eeeeee')
})
it('renders a key without a class as no data, even when it has a value', () => {
const style = resolvePathStyle(classedContext, '2910800')
expect(style.fillColor).toBe('#f4f4f5')
expect(style.fillOpacity).toBe(0.35)
})
it('still highlights hover and selection', () => {
const style = resolvePathStyle({ ...classedContext, hoveredKey: '2927408' }, '2927408')
expect(style.weight).toBe(2)
expect(style.fillColor).toBe('#111111')
})
})
})

View file

@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import {
buildCompetitiveRankClassing,
buildLqClassing,
buildQuantileClassing,
fillsForClassing,
QUANTILE_CLASSES,
QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT,
} from '@/lib/mapScaleClasses'
import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
const valuesFrom = (numbers: number[]): Record<string, number> =>
Object.fromEntries(numbers.map((value, index) => [`k${index}`, value]))
describe('buildQuantileClassing', () => {
it('cuts a large set into five classes ordered weakest to strongest', () => {
const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1))
const classing = buildQuantileClassing(values)
expect(classing.classes).toHaveLength(QUANTILE_CLASSES)
expect(classing.classIndexByKey.k0).toBe(0)
expect(classing.classIndexByKey.k99).toBe(QUANTILE_CLASSES - 1)
})
it('splits evenly: each quantile holds about a fifth of the features', () => {
const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1))
const classing = buildQuantileClassing(values)
const counts = new Array(QUANTILE_CLASSES).fill(0)
for (const index of Object.values(classing.classIndexByKey)) counts[index] += 1
expect(counts).toEqual([20, 20, 20, 20, 20])
})
it('degrades to three classes below the minimum feature count', () => {
const values = valuesFrom(
Array.from({ length: QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT - 1 }, (_, i) => (i + 1) * 10),
)
const classing = buildQuantileClassing(values)
expect(classing.classes).toHaveLength(3)
})
it('collapses classes rather than repeating a break when values tie heavily', () => {
const classing = buildQuantileClassing(valuesFrom([5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 900]))
// Eleven features share one value: quantiles 1-4 all end at 5, so the
// legend gets two honest classes instead of five with duplicate bounds.
expect(classing.classes).toHaveLength(2)
expect(classing.classes[0].label).toBe('5')
expect(classing.classes[1].label).toBe('900')
})
it('excludes zero and negative values from the classing', () => {
const classing = buildQuantileClassing({ a: 0, b: -5, c: 10, d: 20 })
expect(classing.classIndexByKey).not.toHaveProperty('a')
expect(classing.classIndexByKey).not.toHaveProperty('b')
expect(Object.keys(classing.classIndexByKey)).toEqual(['c', 'd'])
})
it('returns nothing to paint when no feature has a positive value', () => {
const classing = buildQuantileClassing({ a: 0, b: 0 })
expect(classing.classes).toEqual([])
expect(classing.classIndexByKey).toEqual({})
})
it('labels each class with its real extremes, never an invented gap', () => {
const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900]))
expect(classing.classes.map((entry) => entry.label)).toEqual(['1–3', '40–60', '700–900'])
})
it('labels a single-value class with that value alone', () => {
const classing = buildQuantileClassing({ a: 42 })
expect(classing.classes).toHaveLength(1)
expect(classing.classes[0].label).toBe('42')
})
})
describe('buildLqClassing', () => {
const classOf = (lq: number) => buildLqClassing({ a: lq }).classIndexByKey.a
it('cuts on E10 anchors, so the map and the list agree on "reduto"', () => {
expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq - 0.01)).toBe(0)
expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq)).toBe(1)
expect(classOf(AT_STANDARD_LQ.min)).toBe(2)
expect(classOf(AT_STANDARD_LQ.max)).toBe(3)
expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq)).toBe(4)
expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq + 10)).toBe(4)
})
it('always offers the five named bands, even when only one is populated', () => {
const classing = buildLqClassing({ a: 1 })
expect(classing.classes).toHaveLength(5)
})
it('spells the band edges without rounding 0,95 into 1', () => {
const labels = buildLqClassing({ a: 1 }).classes.map((entry) => entry.label)
expect(labels).toEqual([
'menos de 0,5×',
'0,5×–0,95×',
'no padrão (≈1×)',
'1,15×–2×',
'2× ou mais',
])
})
it('skips municípios with no measurable standard', () => {
expect(buildLqClassing({ a: 0 }).classIndexByKey).toEqual({})
})
})
describe('buildCompetitiveRankClassing', () => {
const classOf = (rank: number) => buildCompetitiveRankClassing({ a: rank }).classIndexByKey.a
it('puts the strongest placement in the darkest class', () => {
expect(classOf(1)).toBe(2)
expect(classOf(2)).toBe(1)
expect(classOf(3)).toBe(1)
expect(classOf(4)).toBe(0)
expect(classOf(120)).toBe(0)
})
it('reads weakest to strongest, like every other legend', () => {
const labels = buildCompetitiveRankClassing({ a: 1 }).classes.map((entry) => entry.label)
expect(labels).toEqual(['4º ou pior', '2º–3º', '1º'])
})
it('ignores an impossible placement instead of painting it', () => {
expect(buildCompetitiveRankClassing({ a: 0 }).classIndexByKey).toEqual({})
})
})
describe('fillsForClassing', () => {
it('maps every classed key to its class fill', () => {
const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900]))
const fills = fillsForClassing(classing)
expect(Object.keys(fills)).toHaveLength(9)
expect(fills.k0).toBe(classing.classes[0].fill)
expect(fills.k8).toBe(classing.classes[2].fill)
})
it('gives each class a distinguishable fill', () => {
const classing = buildQuantileClassing(
valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1)),
)
expect(new Set(classing.classes.map((entry) => entry.fill)).size).toBe(QUANTILE_CLASSES)
})
})

View file

@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
import {
classifyMunicipalityTerritory,
computeAggregateTerritorialClass,
computeMunicipalityTerritorialClass,
TERRITORIAL_CLASS_ANCHORS,
TERRITORIAL_CLASSES,
territorialClassSortWeight,
type MunicipalityTerritorialClass,
@ -22,7 +23,7 @@ const input = (overrides: Partial<TerritorialClassInput> = {}): TerritorialClass
stateOwnVotes: 100_000,
stateValidVotes: 5_000_000,
fieldHeadroom: 1_000,
medianFieldHeadroom: 2_000,
fieldHeadroomCut: 2_000,
captureRate: 0.1,
inCoreBlock: false,
...overrides,
@ -58,7 +59,7 @@ describe('classifyMunicipalityTerritory', () => {
it('classifies as expansão when he is weak but the field still has votes to win', () => {
const result = classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 9_000, medianFieldHeadroom: 2_000 }),
input({ ownVotes: 50, fieldHeadroom: 9_000, fieldHeadroomCut: 2_000 }),
)
expect(result.class).toBe('expansao')
expect(result.factors[0]).toEqual({ id: 'field', value: 9_000 })
@ -67,14 +68,14 @@ describe('classifyMunicipalityTerritory', () => {
it('classifies as marginal when he is weak and the field is small', () => {
expect(
classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000 }),
input({ ownVotes: 50, fieldHeadroom: 100, fieldHeadroomCut: 2_000 }),
).class,
).toBe('marginal')
})
it('never calls a core-block município marginal, even with a small field', () => {
const result = classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000, inCoreBlock: true }),
input({ ownVotes: 50, fieldHeadroom: 100, fieldHeadroomCut: 2_000, inCoreBlock: true }),
)
expect(result.class).toBe('expansao')
expect(result.inCoreBlock).toBe(true)
@ -150,6 +151,71 @@ describe('computeMunicipalityTerritorialClass over the committed artifact', () =
})
})
describe('computeAggregateTerritorialClass', () => {
const salvadorSlugs = municipalityCatalog
.filter((entry) => entry.kind === 'zona')
.map((entry) => entry.slug)
it('reads Salvador as one territory, not nineteen', () => {
expect(salvadorSlugs.length).toBe(19)
const aggregate = computeAggregateTerritorialClass(salvadorSlugs)
expect(aggregate.class).not.toBe('sem_base')
expect(aggregate.lq).toBeGreaterThan(0)
})
it('sums the inputs instead of averaging the zones\u2019 ratios', () => {
const aggregate = computeAggregateTerritorialClass(salvadorSlugs)
const zoneLqs = salvadorSlugs
.map((slug) => computeMunicipalityTerritorialClass(slug).lq)
.filter((lq): lq is number => lq !== null)
const meanZoneLq = zoneLqs.reduce((total, lq) => total + lq, 0) / zoneLqs.length
// The aggregate is vote-weighted, so it must land inside the zones' range
// but not on their unweighted mean — LQ is a ratio, and the mean of ratios
// is not the ratio of the sums.
expect(aggregate.lq).toBeGreaterThanOrEqual(Math.min(...zoneLqs))
expect(aggregate.lq).toBeLessThanOrEqual(Math.max(...zoneLqs))
expect(aggregate.lq).not.toBeCloseTo(meanZoneLq, 6)
})
it('scales the field cut by the group size instead of a per-município median', () => {
// Field headroom is a LEVEL, so a summed one has to meet a summed cut.
// Comparing 19 zones' worth of headroom against the catalog median would
// clear it by an order of magnitude and make "marginal" unreachable for
// any group — the failure mode E12's 27-território rollup would inherit.
const perUnit = { ownVotes: 50, fieldHeadroom: 900, fieldHeadroomCut: 1_000 }
expect(classifyMunicipalityTerritory(input(perUnit)).class).toBe('marginal')
expect(
classifyMunicipalityTerritory(
input({ ...perUnit, fieldHeadroom: 900 * 3, fieldHeadroomCut: 1_000 * 3 }),
).class,
).toBe('marginal')
})
it('adds up the share of his statewide vote across the group', () => {
const aggregate = computeAggregateTerritorialClass(salvadorSlugs)
const summed = salvadorSlugs.reduce(
(total, slug) => total + computeMunicipalityTerritorialClass(slug).ownShare,
0,
)
expect(aggregate.ownShare).toBeCloseTo(summed, 10)
})
it('matches the single-slug classifier for a group of one', () => {
const slug = municipalityCatalog.find((entry) => entry.kind === 'municipio')!.slug
expect(computeAggregateTerritorialClass([slug])).toEqual(
computeMunicipalityTerritorialClass(slug),
)
})
it('has no class for an empty group', () => {
expect(computeAggregateTerritorialClass([]).class).toBe('sem_base')
})
})
describe('territorialClassSortWeight', () => {
it('ranks reduto first and leaves sem_base without a weight', () => {
expect(territorialClassSortWeight.reduto).toBeGreaterThan(

View file

@ -1,12 +1,11 @@
import { describe, expect, it } from 'vitest'
import { federalBaselineMunicipalitySlugs } from '@/lib/bahiaElectionAggregates'
import { formatVoteSharePercent } from '@/lib/electionFormat'
import { formatPlacementOrdinal, formatVoteSharePercent } from '@/lib/electionFormat'
import {
compareMunicipalityVotesForSort,
computeVoteRankByYear,
DEFAULT_VOTE_RANK_YEAR,
formatMunicipalityVoteRank,
getMunicipalityVoteRank,
} from '@/lib/municipalityVoteRank'
@ -57,7 +56,7 @@ describe('municipalityVoteRank', () => {
})
it('formats rank as colocação only (catalog size lives in the header hint)', () => {
expect(formatMunicipalityVoteRank(12)).toBe('12º')
expect(formatPlacementOrdinal(12)).toBe('12º')
expect(formatVoteSharePercent(0.031)).toMatch(/3[,.]1%/)
})