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:
parent
29f45e093a
commit
66feecd66b
24 changed files with 366 additions and 177 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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 | |
|
||||
|
|
@ -15,11 +15,11 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
|
|||
| 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 | |
|
||||
| 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) | `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) | |
|
||||
| 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 |
|
||||
|
||||
|
|
|
|||
107
docs/plans/escala-dry-pos-b13.md
Normal file
107
docs/plans/escala-dry-pos-b13.md
Normal 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
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (effectiveScaleMode === 'quantile') {
|
||||
if (!isMunicipalityMapScaleModeAvailable(scaleMode, year) && !comparisonActive) {
|
||||
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.`,
|
||||
`${municipalityMapScaleModeLabels[scaleMode]} só existe nos anos com resultado do TSE, então ${year} volta para esta escala.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (classed && classing) {
|
||||
const painted = Object.keys(classing.classIndexByKey).length
|
||||
|
||||
// One sentence for the coverage, not two: "N com votos" plus "M em
|
||||
// cinza" made the reader add them up to check they matched the scope.
|
||||
parts.push(
|
||||
painted === scopedKeys.length
|
||||
? `Todos os ${formatElectionNumber(painted)} municípios do seu escopo entraram na escala.`
|
||||
: `${formatElectionNumber(painted)} de ${formatElectionNumber(scopedKeys.length)} municípios do seu escopo entraram na escala; os demais ficam em cinza.`,
|
||||
)
|
||||
|
||||
if (effectiveScaleMode === 'quantile' && classing.classes.length < QUANTILE_CLASSES) {
|
||||
parts.push(
|
||||
painted < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT
|
||||
? `São poucos para cinco faixas, então a escala usa ${classing.classes.length}.`
|
||||
: `Os valores se repetem demais para cinco faixas, então a escala usa ${classing.classes.length}.`,
|
||||
)
|
||||
}
|
||||
if (effectiveScaleMode === 'lq') {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
||||
|
|
|
|||
|
|
@ -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) : [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}º`
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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%/)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue