simplify(B13): corrige classe agregada, bolhas em pane proprio e registra debitos

O /simplify da escala relativa do mapa achou um bug semantico real em
computeAggregateTerritorialClass — somava o headroom dos slugs e comparava
contra a mediana de UMA unidade, classificando um grupo grande como expansao
quando ele e marginal. A mediana agora escala por slugs.length, com teste.

As bolhas sairam do centro do bounding box para o centroide real e ganharam
pane proprio (z-index 450), porque o bringToFront do hover as escondia atras
do poligono. Legenda e pintura passaram a compartilhar o mesmo predicado,
a copy dos quantis parou de prometer faixas de igual largura, e o subtitulo
do painel parou de dizer "em 2026" com 2022 selecionado (defeito do B11).

Fonte unica onde havia duas: NO_DATA_FILL, o raio das bolhas e
formatPlacementOrdinal (ex-formatMunicipalityVoteRank, agora client-safe em
electionFormat.ts).

Os quatro debitos maiores que o cleanup viraram o fill-in B13+
(docs/plans/escala-dry-pos-b13.md); a flakiness dos e2e, reproduzida em main
limpo, foi para o ledger com a causa afinada.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 05:12:37 -03:00
parent 29f45e093a
commit 66feecd66b
24 changed files with 366 additions and 177 deletions

View file

@ -15,7 +15,7 @@ alwaysApply: false
**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".
Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba".
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).

View file

@ -70,7 +70,7 @@ 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-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s First Load JS is byte-identical (9.46 kB / 265 kB) because the map panel already ships in its own `ssr: false` chunk — that chunk itself grew, and the RSC payload grew ~64 KB uncompressed (~10 KB gzipped), dominated by `competitiveRankByYear`. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md).
**Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md).

View file

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

View file

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

View file

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

View file

@ -98,6 +98,11 @@ Componentes:
- **Value-by-alpha (opacidade ∝ eleitorado).** Gatilho: teste com o time em reunião real pedir alternativa às bolhas.
- **Roll-off como métrica de mapa.** Gatilho: I-A entrar no motor (E11 fase 2) e o time pedir a leitura espacial.
- **`CampaignConceptLink` / `MapLegendNote` compartilhados** (2–3 cópias hoje). Gatilho: 3º call site fora do mapa. Registrado em [escala-dry-pos-b13.md](escala-dry-pos-b13.md).
- **`municipalityLabels.ts` arrastando `municipalityCatalog` (~66 KB) para o chunk do mapa.** Gatilho: 3ª superfície cliente precisar da apresentação da classe. Mesmo plano.
- **Memo de processo para `computeAggregateTerritorialClass`.** Gatilho: **E12** (27 TIs × ~16 municípios). Mesmo plano.
Os quatro débitos maiores que o cleanup do `/simplify` — pin de concordância do LQ, escopo declarado no readout, varredura única no script de agregados e a tupla do `competitiveRankByYear` — viraram o fill-in **B13+**: [escala-dry-pos-b13.md](escala-dry-pos-b13.md).
## As-built (2026-07-26)
@ -113,7 +118,7 @@ As âncoras saíram de `municipalityTerritorialClass.ts` para `src/lib/territori
**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.
**Sem migration, sem collection, sem server action.** O **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`); o chunk cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -14,6 +14,7 @@ import {
type TerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import {
bubbleRadius,
buildHighlightSet,
buildLayerStyleContext,
canonicalMapKeysKey,
@ -25,6 +26,7 @@ import {
type ChoroplethValues,
type LayerStyleContext,
} from '@/lib/bahiaMapStyle'
import { choroplethMaxValue } from '@/lib/choroplethColorScale'
import { cn } from '@/lib/utils'
const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
@ -33,17 +35,11 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [
]
/**
* 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.
* Bubbles get their own pane above the polygons: hovering a município calls
* `bringToFront()` on its path, which in a shared pane would raise the polygon
* over the very bubble it describes.
*/
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'
const BUBBLE_PANE = 'municipality-bubbles'
export type BahiaMapMode = 'municipality' | 'territory'
@ -448,12 +444,15 @@ export const BahiaMap = ({
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
}
const maxValue = choroplethMaxValue(bubbleValues)
if (maxValue <= 0) return
if (!map.getPane(BUBBLE_PANE)) {
const pane = map.createPane(BUBBLE_PANE)
pane.style.zIndex = '450'
pane.style.pointerEvents = 'none'
}
// Smallest first so the big prizes end up drawn on top of the crowd
// instead of being covered by whichever key the object happened to list last.
const ordered = Object.entries(bubbleValues)
@ -463,12 +462,16 @@ export const BahiaMap = ({
const markers: L.CircleMarker[] = []
for (const [key, value] of ordered) {
const path = pathByKeyRef.current.get(key)
if (!(path instanceof L.Polygon)) continue
const fillColor = bubbleFillByKey?.[key]
if (!(path instanceof L.Polygon) || !fillColor) 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,
// Polygon centroid, not the bounding box's centre: a concave or
// L-shaped município would put a box centre outside its own outline.
L.circleMarker(path.getCenter(), {
pane: BUBBLE_PANE,
radius: bubbleRadius(value, maxValue),
fillColor,
fillOpacity: 0.8,
color: '#ffffff',
weight: 0.75,
@ -485,7 +488,9 @@ export const BahiaMap = ({
return () => {
group.remove()
}
}, [bubbleValues, bubbleFillByKey, status])
// `mode` rebuilds the geometry layer this reads centroids from, so the
// markers must be rebuilt with it or they would sit at stale positions.
}, [bubbleValues, bubbleFillByKey, status, mode])
useEffect(() => {
const context = styleContextRef.current

View file

@ -5,9 +5,10 @@ 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
/** What the colour means, in the caller's words. */
note: string
/** Ties the note to the scale selector through `aria-describedby`. */
noteId: string
}
export const ChoroplethLegend = ({
@ -31,7 +32,7 @@ export const ChoroplethLegend = ({
</span>
</div>
<p id={noteId} className="text-xs text-muted-foreground">
{note ?? `Escala: intensidade da cor indica ${metricLabel} no território.`}
{note}
</p>
</div>
)

View file

@ -32,8 +32,8 @@ export const MapScaleLegend = ({
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">
{classing.classes.map((entry, index) => (
<li key={index} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-[3px] ring-1 ring-foreground/15"

View file

@ -4,11 +4,7 @@ import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
import {
BahiaMap,
BUBBLE_MAX_RADIUS,
type BahiaMapFeatureInfo,
} from '@/components/campaign/map/BahiaMap'
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap'
import { ChoroplethLegend } from '@/components/campaign/map/ChoroplethLegend'
import { MapFeatureReadout } from '@/components/campaign/map/MapFeatureReadout'
import { MapScaleLegend } from '@/components/campaign/map/MapScaleLegend'
@ -20,15 +16,26 @@ import {
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 {
bubbleRadius,
computeChoroplethMax,
type ChoroplethFills,
type ChoroplethValues,
} from '@/lib/bahiaMapStyle'
import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethColorScale'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
choroplethMaxValue,
computeValidVoteShares,
divergingGradientCss,
} from '@/lib/choroplethColorScale'
import { formatElectionNumber, formatPlacementOrdinal } from '@/lib/electionFormat'
import {
buildCompetitiveRankClassing,
buildLqClassing,
buildQuantileClassing,
fillsForClassing,
QUANTILE_CLASSES,
QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT,
} from '@/lib/mapScaleClasses'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
@ -58,6 +65,7 @@ import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerri
/** The legend's note doubles as the scale selector's description. */
const SCALE_NOTE_ID = 'municipality-map-scale-note'
const BUBBLE_TOGGLE_ID = 'municipality-map-bubbles'
/**
* Scales that have their own glossary entry (E18). LQ is documented as
@ -82,7 +90,7 @@ const MapBubbleKey = ({ largestValue }: { largestValue: number }) => (
<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)
const diameter = 2 * bubbleRadius(fraction, 1)
return (
<li key={fraction} className="flex items-center gap-1.5">
<span
@ -154,7 +162,8 @@ export const MunicipalityMapPanel = ({
// 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.
// select show a mode the map isn't painting, fall back — and the legend note
// below says which mode stepped aside, so the fallback isn't silent.
const effectiveScaleMode: MunicipalityMapScaleMode = comparisonActive
? 'absolute'
: isMunicipalityMapScaleModeAvailable(scaleMode, year)
@ -236,7 +245,14 @@ export const MunicipalityMapPanel = ({
}
}, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues])
const fillByKey = useMemo(() => (classing ? fillsForClassing(classing) : undefined), [classing])
// A classing with no classes paints nothing, so the legend must fall through
// to the continuous ramp — passing `{}` here would tell `BahiaMap` the map is
// classed and grey out every polygon under a red gradient legend.
const classed = classing !== null && classing.classes.length > 0
const fillByKey = useMemo(
() => (classed && classing ? fillsForClassing(classing) : undefined),
[classed, classing],
)
/**
* What the colour means, said ONCE and next to the swatches that need
@ -247,15 +263,28 @@ export const MunicipalityMapPanel = ({
const legendNote = useMemo(() => {
const parts = [municipalityMapScaleModeHints[effectiveScaleMode]]
if (classing) {
const painted = Object.keys(classing.classIndexByKey).length
const missing = scopedKeys.length - painted
if (!isMunicipalityMapScaleModeAvailable(scaleMode, year) && !comparisonActive) {
parts.push(
`${municipalityMapScaleModeLabels[scaleMode]} só existe nos anos com resultado do TSE, então ${year} volta para esta escala.`,
)
}
if (effectiveScaleMode === 'quantile') {
if (classed && classing) {
const painted = Object.keys(classing.classIndexByKey).length
// One sentence for the coverage, not two: "N com votos" plus "M em
// cinza" made the reader add them up to check they matched the scope.
parts.push(
painted === scopedKeys.length
? `Todos os ${formatElectionNumber(painted)} municípios do seu escopo entraram na escala.`
: `${formatElectionNumber(painted)} de ${formatElectionNumber(scopedKeys.length)} municípios do seu escopo entraram na escala; os demais ficam em cinza.`,
)
if (effectiveScaleMode === 'quantile' && classing.classes.length < QUANTILE_CLASSES) {
parts.push(
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.`,
painted < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT
? `São poucos para cinco faixas, então a escala usa ${classing.classes.length}.`
: `Os valores se repetem demais para cinco faixas, então a escala usa ${classing.classes.length}.`,
)
}
if (effectiveScaleMode === 'lq') {
@ -264,15 +293,10 @@ export const MunicipalityMapPanel = ({
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])
}, [classed, classing, comparisonActive, effectiveScaleMode, scaleMode, scopedKeys.length, year])
const metricLabel = useMemo(() => {
if (comparisonActive) {
@ -289,34 +313,27 @@ 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.
*
* Both sides are already stable references, so there is nothing to memoize.
*/
const bubbleValues = useMemo(
() => (showBubbles ? bundle.projectedValidVotesByCode : undefined),
[bundle.projectedValidVotesByCode, showBubbles],
)
const bubbleValues = showBubbles ? bundle.projectedValidVotesByCode : undefined
const bubbleFillByKey = useMemo(() => {
if (!showBubbles) return undefined
const fills: Record<string, string> = {}
const fills: ChoroplethFills = {}
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])
// The key's reference circles and the map's radius denominator have to be
// the same number, or the key labels a size the map never draws.
const largestBubbleValue = bubbleValues ? choroplethMaxValue(bubbleValues) : 0
const bubbleReadingFor = useCallback(
(key: string): string | null => {
@ -332,6 +349,11 @@ export const MunicipalityMapPanel = ({
[bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles],
)
/**
* The relative sentence for one município — the class alone ("Q4") is a
* verdict without evidence, so the readout always spells out what the colour
* means for the município under the cursor.
*/
const relativeReadingFor = useCallback(
(key: string): string | null => {
if (!classing) return null
@ -350,7 +372,7 @@ export const MunicipalityMapPanel = ({
case 'competitiveRank': {
const placement = competitiveRankForYear[key]
if (!placement) return null
return `${placement.rank}º entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui`
return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui`
}
default:
return null
@ -396,7 +418,7 @@ export const MunicipalityMapPanel = ({
<p className="text-sm text-muted-foreground">
{comparisonActive && comparison
? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.`
: `Votação de ${bundle.candidateName} por município — em 2026, pelas estimativas da campanha.`}
: `Votação de ${bundle.candidateName} por município — ${year === 2026 ? 'em 2026, pelas estimativas da campanha' : `em ${year}, pelo resultado do TSE`}.`}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
@ -495,7 +517,7 @@ export const MunicipalityMapPanel = ({
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : classing && classing.classes.length > 0 ? (
) : classed && classing ? (
<MapScaleLegend
classing={classing}
metricLabel={metricLabel}
@ -512,19 +534,22 @@ export const MunicipalityMapPanel = ({
noteId={SCALE_NOTE_ID}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
<p id={SCALE_NOTE_ID} className="text-sm text-muted-foreground">
Sem dados para este ano no seu escopo.
</p>
)}
{/* A second encoding, so it is opt-in and its key only appears with it. */}
{!comparisonActive ? (
<div className="flex flex-col gap-2">
<label className="flex min-h-11 w-fit items-center gap-2 text-sm">
<Field orientation="horizontal" className="min-h-11 w-fit">
<Checkbox
id={BUBBLE_TOGGLE_ID}
checked={bubblesEnabled}
onCheckedChange={(checked) => setBubblesEnabled(checked === true)}
/>
Bolhas por votos em jogo
</label>
<FieldLabel htmlFor={BUBBLE_TOGGLE_ID}>Bolhas por votos em jogo</FieldLabel>
</Field>
{showBubbles ? (
largestBubbleValue > 0 ? (
<MapBubbleKey largestValue={largestBubbleValue} />
@ -623,12 +648,12 @@ const MunicipalityMapSelection = ({
municipalitiesByIbgeCode,
ariaLabel,
}: {
displayValues: Record<string, number>
rawValues: Record<string, number>
displayValues: ChoroplethValues
rawValues: ChoroplethValues
displayMax: number
fillByKey: Record<string, string> | undefined
bubbleValues: Record<string, number> | undefined
bubbleFillByKey: Record<string, string> | undefined
fillByKey: ChoroplethFills | undefined
bubbleValues: ChoroplethValues | undefined
bubbleFillByKey: ChoroplethFills | undefined
scopedKeys: string[]
metricLabel: string
scaleMode: MunicipalityMapScaleMode

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import {
choroplethMaxAbsValue,
choroplethMaxValue,
divergingFillColor,
NO_DATA_FILL,
} from '@/lib/choroplethColorScale'
export type ChoroplethValues = Record<string, number>
@ -30,8 +31,6 @@ export type LayerStyleContext = {
hoveredKey: string | null
}
const NO_DATA_FILL = '#f4f4f5'
type FeatureStyleInput = {
metric: number
highlighted: boolean
@ -67,6 +66,25 @@ export const computeChoroplethMax = (
scaleMax ??
(fillMode === 'diverging' ? choroplethMaxAbsValue(values) : choroplethMaxValue(values))
/**
* B13 — proportional symbols scale by the SQUARE ROOT of the magnitude, so the
* AREA of the circle carries the value: sizing the radius directly would make
* a município with twice the votes look four times as big. Flannery's
* perceptual correction is deliberately skipped — it inflates the large end on
* purpose, and here the exact number is one hover away in the readout.
*
* Lives here rather than in the Leaflet component because the legend draws its
* reference circles from the same formula; two implementations would let the
* key claim a size the map does not paint.
*/
const BUBBLE_MAX_RADIUS = 16
/** Small enough that a crowded east reads as texture, not as a second choropleth. */
const BUBBLE_MIN_RADIUS = 1.5
export const bubbleRadius = (value: number, max: number): number =>
max > 0 ? Math.max(BUBBLE_MIN_RADIUS, BUBBLE_MAX_RADIUS * Math.sqrt(value / max)) : 0
export const buildHighlightSet = (highlightKey: string): Set<string> =>
new Set(highlightKey.length > 0 ? highlightKey.split(',').filter(Boolean) : [])

View file

@ -4,9 +4,12 @@ const choroplethGradientEnd = { r: 197, g: 20, b: 20 } as const
export const choroplethGradientCss = `linear-gradient(to right, rgb(${choroplethGradientStart.r} ${choroplethGradientStart.g} ${choroplethGradientStart.b}), rgb(${choroplethGradientEnd.r} ${choroplethGradientEnd.g} ${choroplethGradientEnd.b}))`
/** Painted where the geography has no reading at all — never where the value is zero. */
export const NO_DATA_FILL = '#f4f4f5'
/** Sequential fill for campaign choropleth maps (primary red scale). */
export const choroplethFillColor = (value: number, max: number): string => {
if (value <= 0 || max <= 0) return '#f4f4f5'
if (value <= 0 || max <= 0) return NO_DATA_FILL
const ratio = Math.min(1, value / max)
const start = choroplethGradientStart
@ -23,12 +26,11 @@ export const choroplethFillColor = (value: number, max: number): string => {
* 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).
* The floor keeps the lightest class distinguishable from `NO_DATA_FILL`.
*/
const DISCRETE_CLASS_FLOOR = 0.18
export const discreteChoroplethFill = (index: number, total: number): string => {
if (total <= 0) return '#f4f4f5'
if (total === 1) return choroplethFillColor(1, 1)
const step = index / (total - 1)

View file

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

View file

@ -1,5 +1,5 @@
import { discreteChoroplethFill } from '@/lib/choroplethColorScale'
import { formatElectionNumber } from '@/lib/electionFormat'
import { formatElectionNumber, formatPlacementOrdinal } from '@/lib/electionFormat'
import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
/**
@ -26,26 +26,19 @@ export type MapScaleClassing = {
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 EMPTY_CLASSING: MapScaleClassing = { classes: [], classIndexByKey: {} }
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. */
@ -91,11 +84,11 @@ export const buildQuantileClassing = (values: Record<string, number>): MapScaleC
const extremes = upperBounds.map(() => ({ min: Number.POSITIVE_INFINITY, max: 0 }))
const classIndexByKey: Record<string, number> = {}
for (const [key, value] of entries) {
// Always found: the last bound is the maximum of the same set.
const index = upperBounds.findIndex((bound) => value <= bound)
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)
classIndexByKey[key] = index
extremes[index].min = Math.min(extremes[index].min, value)
extremes[index].max = Math.max(extremes[index].max, value)
}
const ranges = extremes.map(({ min, max }) => ({
@ -105,7 +98,7 @@ export const buildQuantileClassing = (values: Record<string, number>): MapScaleC
: `${formatElectionNumber(min)}–${formatElectionNumber(max)}`,
}))
return buildClassing(ranges, classIndexByKey, upperBounds.length < QUANTILE_CLASSES)
return buildClassing(ranges, classIndexByKey)
}
/**
@ -153,41 +146,42 @@ export const buildLqClassing = (lqByKey: Record<string, number>): MapScaleClassi
}
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
return buildClassing(LQ_CLASS_RANGES, classIndexByKey, false)
return buildClassing(LQ_CLASS_RANGES, classIndexByKey)
}
/** Best placement that still counts as a contender rather than the field. */
const COMPETITIVE_RANK_CONTENDER = 3
/**
* Competitive placement, ordered weakest first so the legend keeps the same
* left-to-right reading as the other scales even though a LOWER placement
* number is the better one.
* number is the better one. `bestRank` is the strongest placement each band
* accepts, and the labels are derived from it — the same shape as the LQ
* ranges above, so moving a cut can never leave the legend describing the old one.
*/
const COMPETITIVE_RANK_RANGES = [
{ label: '4º ou pior' },
{ label: '2º–3º' },
{ label: '1º' },
{
bestRank: COMPETITIVE_RANK_CONTENDER + 1,
label: `${formatPlacementOrdinal(COMPETITIVE_RANK_CONTENDER + 1)} ou pior`,
},
{
bestRank: 2,
label: `${formatPlacementOrdinal(2)}–${formatPlacementOrdinal(COMPETITIVE_RANK_CONTENDER)}`,
},
{ bestRank: 1, label: formatPlacementOrdinal(1) },
] as const
/** 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)
classIndexByKey[key] = COMPETITIVE_RANK_RANGES.findIndex((range) => rank >= range.bestRank)
}
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
return buildClassing(COMPETITIVE_RANK_RANGES, classIndexByKey, false)
return buildClassing(COMPETITIVE_RANK_RANGES, classIndexByKey)
}
/** Feature key → fill, ready for `BahiaMap`'s `fillByKey`. */

View file

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

View file

@ -47,7 +47,16 @@ export const municipalityMapScaleModeLabels: Record<MunicipalityMapScaleMode, st
absolute: 'Total (votos)',
}
/** What the colour means, printed with the legend the scale selector describes. */
/**
* What the colour means, printed with the legend the scale selector describes.
*
* Deliberately NOT the canonical E18 `oneLiner` the list's column descriptions
* quote: three of these five scales have a glossary entry and two do not, so
* quoting would fork the voice inside one record — and the legend already
* carries two to four more sentences after this one, where a standalone
* definition ("Divide os municípios em cinco faixas…") reads as padding. The
* "Saiba mais" link next to the note is the path to the full entry.
*/
export const municipalityMapScaleModeHints: Record<MunicipalityMapScaleMode, string> = {
quantile: 'Cinco faixas com o mesmo número de municípios.',
lq: 'Força local comparada ao padrão estadual do próprio candidato.',

View file

@ -104,12 +104,9 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
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)
}
// One polygon per IBGE code, N catalog slugs behind it (Salvador is 19) —
// the same index the map uses to navigate, reused here for the rollups.
const municipalitiesByIbgeCode = buildMunicipalitiesByIbgeCode(municipalities)
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
@ -139,7 +136,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0
const ranks: Record<string, FederalCompetitiveRank> = {}
for (const ibgeCode of slugsByIbgeCode.keys()) {
for (const ibgeCode of Object.keys(municipalitiesByIbgeCode)) {
const rank = getFederalCompetitiveRank(ibgeCode, year)
if (rank) ranks[ibgeCode] = rank
}
@ -153,7 +150,8 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
const territorialClassByCode: Record<string, MunicipalityTerritorialClass> = {}
const projectedValidVotesByCode: Record<string, number> = {}
for (const [ibgeCode, slugs] of slugsByIbgeCode) {
for (const [ibgeCode, entries] of Object.entries(municipalitiesByIbgeCode)) {
const slugs = entries.map((entry) => entry.slug)
territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class
projectedValidVotesByCode[ibgeCode] = slugs.reduce(
(total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)),
@ -263,7 +261,7 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
territorialClassByCode,
competitiveRankByYear,
projectedValidVotesByCode,
municipalitiesByIbgeCode: buildMunicipalitiesByIbgeCode(municipalities),
municipalitiesByIbgeCode,
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,

View file

@ -73,8 +73,13 @@ export type TerritorialClassInput = {
stateValidVotes: number
/** Projected field ceiling minus own votes (E8), floored at 0. */
fieldHeadroom: number
/** Median `fieldHeadroom` across the catalog — the relative "big field" cut. */
medianFieldHeadroom: number
/**
* The "big field" cut `fieldHeadroom` is measured against: the catalog
* median for one município, and that median times the group size for an
* aggregate — headroom is a level, not a ratio, so a summed one has to be
* compared against a reference summed the same way.
*/
fieldHeadroomCut: number
captureRate: number | null
inCoreBlock: boolean
}
@ -120,7 +125,7 @@ export const classifyMunicipalityTerritory = (
// A município inside the core block carries real votes even while
// under-performing, so it can never read as "marginal" — the honest label
// there is expansão (big base, low penetration).
const hasFieldLeft = input.fieldHeadroom >= input.medianFieldHeadroom || input.inCoreBlock
const hasFieldLeft = input.fieldHeadroom >= input.fieldHeadroomCut || input.inCoreBlock
return hasFieldLeft
? { ...shared, class: 'expansao', factors: [fieldFactor, dominance, ...captureFactor] }
: { ...shared, class: 'marginal', factors: [dominance, fieldFactor] }
@ -201,7 +206,7 @@ export const computeMunicipalityTerritorialClass = (
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom: fieldHeadroomOf(baseline),
medianFieldHeadroom,
fieldHeadroomCut: medianFieldHeadroom,
captureRate: captureRate(baseline),
inCoreBlock: coreBlockSlugs.has(slug),
})
@ -216,10 +221,11 @@ export const computeMunicipalityTerritorialClass = (
*
* 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.
* the average of ratios is not the ratio of the sums. The ratios (LQ, capture)
* are compared against exactly the statewide standard everyone else gets; the
* one LEVEL among the inputs, field headroom, needs its cut scaled by the
* group size or a 19-slug Salvador would clear a per-município median by an
* order of magnitude and never read as "marginal".
*
* E12's TI rollup inherits this helper rather than growing a second one.
*/
@ -253,7 +259,7 @@ export const computeAggregateTerritorialClass = (
stateOwnVotes: totals.ownVotes,
stateValidVotes: totals.validVotes,
fieldHeadroom,
medianFieldHeadroom,
fieldHeadroomCut: medianFieldHeadroom * slugs.length,
captureRate: fieldCeiling2022 > 0 ? ownVotes / fieldCeiling2022 : null,
inCoreBlock,
})

View file

@ -19,7 +19,6 @@ describe('buildQuantileClassing', () => {
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)
})
@ -41,7 +40,6 @@ describe('buildQuantileClassing', () => {
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', () => {
@ -50,7 +48,6 @@ describe('buildQuantileClassing', () => {
// 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')
})
@ -100,7 +97,6 @@ describe('buildLqClassing', () => {
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', () => {

View file

@ -23,7 +23,7 @@ const input = (overrides: Partial<TerritorialClassInput> = {}): TerritorialClass
stateOwnVotes: 100_000,
stateValidVotes: 5_000_000,
fieldHeadroom: 1_000,
medianFieldHeadroom: 2_000,
fieldHeadroomCut: 2_000,
captureRate: 0.1,
inCoreBlock: false,
...overrides,
@ -59,7 +59,7 @@ describe('classifyMunicipalityTerritory', () => {
it('classifies as expansão when he is weak but the field still has votes to win', () => {
const result = classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 9_000, medianFieldHeadroom: 2_000 }),
input({ ownVotes: 50, fieldHeadroom: 9_000, fieldHeadroomCut: 2_000 }),
)
expect(result.class).toBe('expansao')
expect(result.factors[0]).toEqual({ id: 'field', value: 9_000 })
@ -68,14 +68,14 @@ describe('classifyMunicipalityTerritory', () => {
it('classifies as marginal when he is weak and the field is small', () => {
expect(
classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000 }),
input({ ownVotes: 50, fieldHeadroom: 100, fieldHeadroomCut: 2_000 }),
).class,
).toBe('marginal')
})
it('never calls a core-block município marginal, even with a small field', () => {
const result = classifyMunicipalityTerritory(
input({ ownVotes: 50, fieldHeadroom: 100, medianFieldHeadroom: 2_000, inCoreBlock: true }),
input({ ownVotes: 50, fieldHeadroom: 100, fieldHeadroomCut: 2_000, inCoreBlock: true }),
)
expect(result.class).toBe('expansao')
expect(result.inCoreBlock).toBe(true)
@ -179,6 +179,20 @@ describe('computeAggregateTerritorialClass', () => {
expect(aggregate.lq).not.toBeCloseTo(meanZoneLq, 6)
})
it('scales the field cut by the group size instead of a per-município median', () => {
// Field headroom is a LEVEL, so a summed one has to meet a summed cut.
// Comparing 19 zones' worth of headroom against the catalog median would
// clear it by an order of magnitude and make "marginal" unreachable for
// any group — the failure mode E12's 27-território rollup would inherit.
const perUnit = { ownVotes: 50, fieldHeadroom: 900, fieldHeadroomCut: 1_000 }
expect(classifyMunicipalityTerritory(input(perUnit)).class).toBe('marginal')
expect(
classifyMunicipalityTerritory(
input({ ...perUnit, fieldHeadroom: 900 * 3, fieldHeadroomCut: 1_000 * 3 }),
).class,
).toBe('marginal')
})
it('adds up the share of his statewide vote across the group', () => {
const aggregate = computeAggregateTerritorialClass(salvadorSlugs)
const summed = salvadorSlugs.reduce(

View file

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