B13: escala relativa no mapa dos Municipios (quantis/LQ/posicao) + artefato TSE v3

O coropleto do Inicio era quase monocromatico porque 0-100% dos validos comprime
uma disputa cujo topo estadual e 4,71%. Tres escalas relativas ao proprio
candidato, todas em classes discretas: quantis (novo default), padrao proprio
(LQ) e posicao no municipio, ao lado de "% dos validos" (B11) e "Total (votos)".

Artefato v3: federalRankByIbgeCode com a colocacao entre todos os candidatos com
votos > 0 na geografia (2014/2018/2022), chaveado por ibgeCode porque e o que o
mapa desenha. Fica no artefato commitado, nao em query de request, que varreria
~100 mil linhas por ano para um dado imutavel. 534 -> 623 KB (orcamento 700 KB).

LQ virou 5 classes nas ancoras do E10, nao diverging: o plano pedia as duas
coisas e se contradizia. Vermelho x azul continua significando "Solla x
adversario" e segue exclusivo do compare.

computeAggregateTerritorialClass classifica um grupo de slugs como um territorio
so, somando insumos e rodando o mesmo classificador (LQ e razao; a media de
razoes nao e a razao das somas) - o E12 herda o helper. TERRITORIAL_CLASS_ANCHORS
mudou para src/lib/territorialClassAnchors.ts para nao arrastar o artefato TSE
ao bundle do cliente.

Quantis sobre o escopo de acesso do usuario, degradando para 3 classes abaixo de
10 municipios com a legenda declarando a contagem efetiva. Rank indisponivel em
2026 (dado TSE); LQ em 2026 reusa o padrao estadual de 2022.

Bolhas opcionais por validos projetados (area proporcional ao valor, cor da
classe E10, interactive: false). 417 marcadores medidos antes de otimizar - nao
derrubaram o frame, entao o corte para top-K nao foi feito.

Sem migration, sem collection, sem server action; bundle client de /campanha
byte a byte igual.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 04:21:35 -03:00
parent b16d2c9831
commit 29f45e093a
26 changed files with 7040 additions and 69 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; bundle client de `/campanha` **byte a byte igual** (9,46 kB / 265 kB — o painel do mapa já carrega em chunk próprio). 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 client bundle is byte-identical. **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

@ -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`).
@ -89,6 +99,22 @@ 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.
## 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 bundle client de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) — o painel do mapa já carrega em chunk próprio.
## Referências
- `docs/roadmap.md` (Inteligência de campanha, B13) · [plano-mestre](inteligencia-campanha.md)

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

@ -21,6 +21,7 @@ import {
keyPropertyForMode,
resolvePathStyle,
type BahiaMapFillMode,
type ChoroplethFills,
type ChoroplethValues,
type LayerStyleContext,
} from '@/lib/bahiaMapStyle'
@ -31,6 +32,19 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
[-8.5, -37.0],
]
/**
* 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.
*/
export 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
/** Used when the caller gives no fill for a key — grey reads as "no class". */
const BUBBLE_NEUTRAL_FILL = '#78716c'
export type BahiaMapMode = 'municipality' | 'territory'
export type BahiaMapFeatureInfo = {
@ -45,6 +59,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 +127,9 @@ export const BahiaMap = ({
values,
fillMode = 'sequential',
scaleMax,
fillByKey,
bubbleValues,
bubbleFillByKey,
highlightKeys = [],
fitToKeys = [],
interactiveKeys = [],
@ -127,6 +158,7 @@ export const BahiaMap = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey: '',
selectedKey,
})
@ -146,6 +178,7 @@ export const BahiaMap = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey,
selectedKey,
}
@ -398,6 +431,7 @@ export const BahiaMap = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey,
selectedKey,
hoveredKey: previousContext?.hoveredKey ?? null,
@ -408,7 +442,50 @@ 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
let maxValue = 0
for (const value of Object.values(bubbleValues)) {
if (value > maxValue) maxValue = value
}
if (maxValue <= 0) return
// 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)
if (!(path instanceof L.Polygon)) continue
markers.push(
L.circleMarker(path.getBounds().getCenter(), {
radius: Math.max(BUBBLE_MIN_RADIUS, BUBBLE_MAX_RADIUS * Math.sqrt(value / maxValue)),
fillColor: bubbleFillByKey?.[key] ?? BUBBLE_NEUTRAL_FILL,
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()
}
}, [bubbleValues, bubbleFillByKey, status])
useEffect(() => {
const context = styleContextRef.current

View file

@ -5,12 +5,17 @@ type ChoroplethLegendProps = {
max: number
metricLabel: string
formatMax?: (max: number) => string
/** What the colour means, in the caller's words. Shares the id with the scale selector. */
noteId?: string
note?: string | null
}
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 +30,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 ?? `Escala: intensidade da cor indica ${metricLabel} no território.`}
</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) => (
<li key={entry.label} 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

@ -4,19 +4,32 @@ import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap'
import {
BahiaMap,
BUBBLE_MAX_RADIUS,
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 type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethColorScale'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
buildCompetitiveRankClassing,
buildLqClassing,
buildQuantileClassing,
fillsForClassing,
} from '@/lib/mapScaleClasses'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
voteEstimateScenarioLabels,
@ -24,8 +37,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 +54,74 @@ 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'
/**
* 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 * BUBBLE_MAX_RADIUS * Math.sqrt(fraction)
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 +138,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 +151,19 @@ 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 say so.
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 +197,83 @@ 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])
const fillByKey = useMemo(() => (classing ? fillsForClassing(classing) : undefined), [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 (classing) {
const painted = Object.keys(classing.classIndexByKey).length
const missing = scopedKeys.length - painted
if (effectiveScaleMode === 'quantile') {
parts.push(
classing.reduced
? `Só ${formatElectionNumber(painted)} municípios do seu escopo têm votos — poucos para cinco, então a escala usa ${classing.classes.length}.`
: `Aqui são ${formatElectionNumber(painted)} municípios com votos no seu escopo.`,
)
}
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.')
}
if (missing > 0) {
parts.push(
`${formatElectionNumber(missing)} sem dado ${missing === 1 ? 'ficou' : 'ficaram'} em cinza.`,
)
}
}
return parts.join(' ')
}, [classing, effectiveScaleMode, scopedKeys.length])
const metricLabel = useMemo(() => {
if (comparisonActive) {
return `diferença de votos em ${year}`
@ -108,6 +289,76 @@ export const MunicipalityMapPanel = ({
return `votos de ${bundle.candidateName} em ${year}`
}, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year])
/**
* 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.
*/
/**
* 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.
*/
const bubbleValues = useMemo(
() => (showBubbles ? bundle.projectedValidVotesByCode : undefined),
[bundle.projectedValidVotesByCode, showBubbles],
)
const bubbleFillByKey = useMemo(() => {
if (!showBubbles) return undefined
const fills: Record<string, string> = {}
for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) {
fills[code] = territorialClassMapFill[territorialClass]
}
return fills
}, [bundle.territorialClassByCode, showBubbles])
const largestBubbleValue = useMemo(() => {
if (!bubbleValues) return 0
return Object.values(bubbleValues).reduce((max, value) => Math.max(max, value), 0)
}, [bubbleValues])
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],
)
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 `${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 +391,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 — em 2026, pelas estimativas da campanha.`}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
@ -165,15 +416,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 +495,48 @@ export const MunicipalityMapPanel = ({
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : classing && classing.classes.length > 0 ? (
<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>
)}
{/* A second encoding, so it is opt-in and its key only appears with it. */}
{!comparisonActive ? (
<div className="flex flex-col gap-2">
<label className="flex min-h-11 w-fit items-center gap-2 text-sm">
<Checkbox
checked={bubblesEnabled}
onCheckedChange={(checked) => setBubblesEnabled(checked === true)}
/>
Bolhas por votos em jogo
</label>
{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 +548,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,9 +611,14 @@ const MunicipalityMapSelection = ({
displayValues,
rawValues,
displayMax,
fillByKey,
bubbleValues,
bubbleFillByKey,
scopedKeys,
metricLabel,
scaleMode,
relativeReadingFor,
bubbleReadingFor,
comparisonActive,
municipalitiesByIbgeCode,
ariaLabel,
@ -330,9 +626,14 @@ const MunicipalityMapSelection = ({
displayValues: Record<string, number>
rawValues: Record<string, number>
displayMax: number
fillByKey: Record<string, string> | undefined
bubbleValues: Record<string, number> | undefined
bubbleFillByKey: Record<string, string> | 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 +689,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 +707,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

@ -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

@ -13,20 +13,32 @@ 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
}
const NO_DATA_FILL = '#f4f4f5'
type FeatureStyleInput = {
metric: number
highlighted: boolean
fillMode: BahiaMapFillMode
max: number
classFill?: string
classed: boolean
}
const getFeatureStyle = ({
@ -34,12 +46,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 = (
@ -65,12 +82,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 +103,8 @@ export const resolvePathStyle = (context: LayerStyleContext, key: string): PathO
highlighted,
fillMode: context.fillMode,
max: context.max,
classFill: context.fillByKey?.[key],
classed,
})
}
@ -90,6 +112,7 @@ export const buildLayerStyleContext = ({
values,
fillMode,
scaleMax,
fillByKey,
highlightKey,
selectedKey,
hoveredKey = null,
@ -97,6 +120,7 @@ export const buildLayerStyleContext = ({
values: ChoroplethValues
fillMode: BahiaMapFillMode
scaleMax?: number
fillByKey?: ChoroplethFills
highlightKey: string
selectedKey: string | null
hoveredKey?: string | null
@ -104,6 +128,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

@ -17,6 +17,24 @@ 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" (#f4f4f5).
*/
const DISCRETE_CLASS_FLOOR = 0.18
export const discreteChoroplethFill = (index: number, total: number): string => {
if (total <= 0) return '#f4f4f5'
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

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

@ -0,0 +1,201 @@
import { discreteChoroplethFill } from '@/lib/choroplethColorScale'
import { formatElectionNumber } 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>
/**
* True when the set was too small to cut into the full number of classes —
* the legend says so instead of implying a five-way split that isn't there.
*/
reduced: boolean
}
const EMPTY_CLASSING: MapScaleClassing = { classes: [], classIndexByKey: {}, reduced: false }
const buildClassing = (
ranges: ReadonlyArray<{ label: string }>,
classIndexByKey: Record<string, number>,
reduced: boolean,
): MapScaleClassing => ({
classes: ranges.map((range, index) => ({
label: range.label,
fill: discreteChoroplethFill(index, ranges.length),
})),
classIndexByKey,
reduced,
})
/** 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) {
const index = upperBounds.findIndex((bound) => value <= bound)
const resolved = index === -1 ? upperBounds.length - 1 : index
classIndexByKey[key] = resolved
extremes[resolved].min = Math.min(extremes[resolved].min, value)
extremes[resolved].max = Math.max(extremes[resolved].max, value)
}
const ranges = extremes.map(({ min, max }) => ({
label:
min === max
? formatElectionNumber(min)
: `${formatElectionNumber(min)}–${formatElectionNumber(max)}`,
}))
return buildClassing(ranges, classIndexByKey, upperBounds.length < QUANTILE_CLASSES)
}
/**
* 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, false)
}
/**
* 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.
*/
const COMPETITIVE_RANK_RANGES = [
{ label: '4º ou pior' },
{ label: '2º–3º' },
{ label: '1º' },
] as const
/** Worst placement still inside each class, read from the strongest end. */
const COMPETITIVE_RANK_TOP = 1
const COMPETITIVE_RANK_CONTENDER = 3
const competitiveRankClassIndex = (rank: number): number => {
if (rank <= COMPETITIVE_RANK_TOP) return 2
if (rank <= COMPETITIVE_RANK_CONTENDER) return 1
return 0
}
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] = competitiveRankClassIndex(rank)
}
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
return buildClassing(COMPETITIVE_RANK_RANGES, classIndexByKey, false)
}
/** 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

@ -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,54 @@ 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. */
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 +91,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,15 @@ 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>>()
const slugsByIbgeCode = new Map<string, string[]>()
for (const municipality of municipalities) {
const slugs = slugsByIbgeCode.get(municipality.ibgeCode) ?? []
slugs.push(municipality.slug)
slugsByIbgeCode.set(municipality.ibgeCode, slugs)
}
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
@ -112,8 +130,36 @@ 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 slugsByIbgeCode.keys()) {
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, slugs] of slugsByIbgeCode) {
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,6 +259,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
valuesByYear,
values2026ByScenario: pledgeValuesByScenario,
validVotesByYear,
statewideShareByYear,
territorialClassByCode,
competitiveRankByYear,
projectedValidVotesByCode,
municipalitiesByIbgeCode: buildMunicipalitiesByIbgeCode(municipalities),
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,

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'
@ -220,6 +210,55 @@ 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 catalog-wide
* references (median field headroom, core block) stay exactly as they are:
* the aggregate is compared against the same statewide standard as everyone
* else.
*
* 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,
medianFieldHeadroom,
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,162 @@
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.reduced).toBe(false)
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)
expect(classing.reduced).toBe(true)
})
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.reduced).toBe(true)
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)
expect(classing.reduced).toBe(false)
})
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,
@ -150,6 +151,57 @@ 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('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(