diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index f39d4e27..e41c439e 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,15 @@ alwaysApply: false ## Status +- **B13 entregue em código (2026-07-26):** escala relativa no mapa do Início. O coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71% — três escalas **relativas ao próprio candidato**, em **classes discretas** (nunca rampa contínua): **quantis** (novo default), **padrão próprio (LQ)** e **posição no município**, ao lado de "% dos válidos" (B11) e "Total (votos)". **Auditoria do plano antes de codar** achou três defasagens, corrigidas em [`escala-relativa-mapa.md`](../../../docs/plans/escala-relativa-mapa.md) § Revisão na entrega: caminhos pós-Pass 2 W2 (`components/campaign/map/`), o nome inexistente `computeDfTerritorialClass` (é `computeMunicipalityTerritorialClass`) e a premissa "o mapa acompanha a lista" (desde a remodelagem o mapa só existe no Início) — resolvida como **quantis sobre o escopo de acesso do usuário**, decisão do usuário nesta sessão. Resolvi também a **contradição interna do plano** sobre LQ: os Objetivos pediam diverging em torno de 1 e as Decisões travadas reservavam diverging ao compare — ficou com as Decisões travadas, LQ como 5 classes nomeadas nas âncoras do E10, porque vermelho × azul significa "Solla × adversário" e não pode passar a significar duas coisas. + + **Artefato v3 (decisão cara do usuário: construir).** "Posição no município" não existia nem no artefato nem em query reaproveitável, e computá-la em request seria varrer ~100 mil linhas por ano para um dado imutável. Foi para o artefato commitado (regra 3 da escada de cache, como o E8 na v2): `federalRankByIbgeCode: Record>` — colocação entre **todos** os candidatos com votos > 0 na geografia, 2014/2018/2022, alimentada por `loadFederalVotesByCityZoneAndCandidate` (irmão CLI-only de `loadCampoFederalVotesByCityZone`). Chaveado por `ibgeCode` e não por slug porque é o que o mapa desenha (as 19 zonas de Salvador somam a cidade inteira); rank por slug entra quando dossiê/E11 pedirem. 534 → 623 KB, dentro do orçamento de 700 KB. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022. + + **Peças novas que outros itens herdam.** `computeAggregateTerritorialClass(slugs)` classifica um grupo como um território só — soma os insumos e roda o **mesmo** `classifyMunicipalityTerritory`, nunca a média das classes por slug (LQ é razão, e a média de razões não é a razão das somas): **o E12 deve herdá-lo em vez de escrever um rollup por TI próprio**. `TERRITORIAL_CLASS_ANCHORS` e `AT_STANDARD_LQ` mudaram de casa para `src/lib/territorialClassAnchors.ts` porque o painel do mapa é client component e importar o classificador arrastaria o artefato TSE (~600 KB) para o bundle do navegador — números apenas, com classificador e copy lendo do mesmo lugar, então a recalibração do **E15** continua sendo um diff de uma linha. A matemática das escalas ficou em `src/lib/mapScaleClasses.ts` (arquivo novo; `bahiaMapStyle.ts` já carregava estilo de path), com **degradação declarada para 3 classes abaixo de 10 municípios** — não hipotético com carteira de assessor — e a legenda dizendo a contagem efetiva. + + **UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`. + + Sem migration, sem collection, sem server action; bundle client de `/campanha` **byte a byte igual** (9,46 kB / 265 kB — o painel do mapa já carrega em chunk próprio). Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba". - **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover "`) 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 ? … : 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 `` (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). diff --git a/AGENTS.md b/AGENTS.md index 62acfa22..689ad2b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e. +**Recently resolved (2026-07-26):** **B13** Escala relativa no mapa dos Municípios — the dashboard choropleth was nearly monochrome because 0–100% of valid votes compresses a race whose statewide ceiling is 4.71%. It now offers three scales **relative to the candidate himself**, all as **discrete classes**: **quantis** (the new default), **padrão próprio (LQ)** and **posição no município**, next to the existing "% dos válidos" (B11) and "Total (votos)". Pure math in `src/lib/mapScaleClasses.ts` (new file — `bahiaMapStyle.ts` already owned path styling); `resolvePathStyle` gained a `fillByKey` path that **replaces** the continuous ramp when present, where a missing key is "no data", which is not a value of zero. **LQ is 5 named classes cut on the E10 anchors, not diverging** — the source plan asked for both and contradicted itself; red×blue keeps meaning "Solla × adversário" and stays exclusive to the compare mode. `TERRITORIAL_CLASS_ANCHORS` / `AT_STANDARD_LQ` moved to `src/lib/territorialClassAnchors.ts` because the map panel is a client component and importing the classifier there would drag the ~600 KB TSE artifact into the browser bundle; numbers only, so recalibration (**E15**) stays a one-line diff in one file. Quantiles are computed over the **user's access scope** (435 for coordinator/candidate, the portfolio for an advisor) and **degrade to 3 classes below 10 municípios**, with the legend stating the effective count. "Posição no município" existed nowhere, so it went into the **committed artifact (v3)** rather than a request-time query — see "Election baseline data" above. New in E10: `computeAggregateTerritorialClass(slugs)` classes a GROUP as one territory by summing inputs and running the **same** `classifyMunicipalityTerritory`, never averaging per-slug classes (LQ is a ratio, and the mean of ratios is not the ratio of the sums) — **E12's TI rollup must inherit this helper instead of growing a second one**. The map loader fills `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` and `projectedValidVotesByCode`, all pure over the artifact: **no new query per request**. UI: discrete `MapScaleLegend` with real ranges tied to the selector via `aria-describedby`, a readout that always spells out the reason ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"), and an opt-in proportional-symbol layer (area ∝ projected valid votes, E10 class colour, `interactive: false` so the bubble does not stack a second hover target on its own município) — 417 markers were **measured** before optimizing and did not drop the frame, so the top-K fallback was not built. Two new concepts in `/campanha/conceitos`. No migration, no collection, no server action; `/campanha`'s client bundle is byte-identical. **Known flake (pre-existing, reproduced on a clean tree):** `campaignMunicipalities.e2e.spec.ts` fails intermittently with 2 workers — the `LeaderContactsPanel` consent checkbox clicked before hydration, and a `getByRole` regex that matches both "Conde" and "Condeúba" when the fixture picks the former. Plano: [`docs/plans/escala-relativa-mapa.md`](docs/plans/escala-relativa-mapa.md). + **Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md). **Recently resolved (2026-07-25):** **E10** Classificação territorial relativa — `src/utilities/municipalityTerritorialClass.ts` classifies each of the 435 catalog municipalities as **`reduto | expansao | manutencao | marginal | sem_base`** (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and **replaces the majoritarian-era `Defesa/Ataque/Indecisa/Perdida`** — don't reintroduce it. The classifier is a **pure function over the committed TSE artifact** (no DB, no `campaignGoals`, no `user`, no migration): location quotient against the candidate's own statewide standard (`TERRITORIAL_CLASS_ANCHORS.strongLq` 2 / `weakLq` 0.5), share of his own statewide vote, uncaptured field ceiling (E8's `projectedFieldCeiling`) compared against the **catalog median**, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal") — that block is built by walking A11's already-memoized `computeVoteRankByYear` ordering, not by re-sorting the catalog. Cuts are ILLUSTRATIVE and versioned in one object — calibration is **E15**. `getStatewideFederalTotals(year)` was extracted and memoized into `bahiaElectionAggregates.ts`; `municipalityVoteRank.ts` reuses it instead of summing the state itself. **The UI never renders the label alone** (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a `title` attribute, which reaches neither keyboard nor touch. In the list that text is `sr-only` behind the badge and shown on hover/focus/tap through the **B23 `cellTooltip` seam** (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: `MunicipalityGoalAccountCard` (E8 — the class opens the diagnostics block it summarizes, no new card), the `MunicipalityDossier` cover (self-labeling badge "Classe reduto" + factors; print CSS forces `print-color-adjust: exact` on `[data-slot='badge']`, otherwise the printer flattens priority/trend/class into identical grey), and a new **"Classe" column** in `/campanha/municipios` right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in `DESIGN.md` § Status Badge; `sem_base` deliberately renders as an em dash in the list (matching the neighboring "2022" column) and keeps the flattest pill on the detail card and the dossier, where it sits in a labeled "Classe …" field. **List filtering is the one derived filter in the system:** `state.classes` is NOT part of `buildMunicipalityListWhere` — `municipalityPageData.ts` forces the `limit: 0, pagination: false` path whenever a class is selected (otherwise a 25-row page would drop rows and `totalDocs` would lie) and applies the same predicate to `staffScope.municipalities` before the overview rollup and to all three column facets. URL: repeatable `?class=` (all selected canonicalizes to absent, same as `trend`) and sort key `classe` (ordinal via `territorialClassSortWeight`, `desc` default = reduto first, `sem_base` last either way). Two new `diagnostico` concepts in `/campanha/conceitos` (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks **B13**, which should consume `computeMunicipalityTerritorialClass` as-is rather than reclassify. Plano: [`docs/plans/classificacao-territorial-relativa.md`](docs/plans/classificacao-territorial-relativa.md). @@ -148,7 +150,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k - **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`). - **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate. - **Dobradinhas:** `stateDeputy` entity + `municipality.stateDeputies` / `leadership.stateDeputies` hasMany; UI at `/campanha/dobradinhas`. -- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. +- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason. - **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`. ## Campaign supporters (C2) @@ -185,6 +187,8 @@ Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. Since the Município remodel, the consumers are `municipalityElectoralBaseline.ts` (per-municipality series/turnout), `municipalityCandidateComparison.ts` (multi-candidate table) and `municipalityMapData.ts` (map, incl. the diverging compare mode); ticket roles live in `BASELINE_TICKET_2022`. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope). Two caching layers sit on top (2026-07-23 hardening): the immutable Solla/valid-votes series per municipality ships as a committed artifact — `pnpm build:election-aggregates` → `src/lib/electionAggregates/bahia-federal-baseline.json`, loader `src/lib/bahiaElectionAggregates.ts`, snapshot-tested against `municipalityCatalog` — so the map/dashboard never query these collections for historical years; and the elections-tab loaders (`municipalityElectoralBaseline`, `municipalityCandidateComparison`, `electionCandidateOptions`) are wrapped in `unstable_cache` under the `election-tse` tag (bust via `POST /api/revalidate?tag=election-tse` after re-seeding). +The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTallyByYear` / `majoritarian2022`, and B13 (2026-07-26) added `federalRankByIbgeCode` — the candidate's placement among **every** federal-deputy candidate with votes > 0 in that geography, per year, keyed by IBGE code (417 codes; Salvador's 19 zone slugs sum to the whole city, which is what the map draws) and read through `getFederalCompetitiveRank`. A geography where he had no votes gets no entry, never `rank: 0`. Its input is `loadFederalVotesByCityZoneAndCandidate` in `municipalityElectoralBaseline.ts` — the sibling of `loadCampoFederalVotesByCityZone`, same statewide scan, **CLI/build-time only**. The file is 623 KB against the 700 KB budget asserted in `tests/unit/bahiaElectionAggregates.unit.spec.ts`; anything new that would blow it needs a real measurement, not a bumped constant. Rank exists only by IBGE code — add a per-slug variant when the dossier or E11 actually needs one. + `pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached. ### Local verification and deploy checklist diff --git a/docs/plans/escala-relativa-mapa.md b/docs/plans/escala-relativa-mapa.md index c852de44..25452536 100644 --- a/docs/plans/escala-relativa-mapa.md +++ b/docs/plans/escala-relativa-mapa.md @@ -1,12 +1,22 @@ # B13 — Escala relativa no mapa dos Municípios (quantis/LQ/rank + símbolo proporcional) -Status: rascunho -Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening) +Status: entregue (2026-07-26) +Atualizado em: 2026-07-26 (as-built da entrega; auditoria do plano aplicada) Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", B13; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md)) Impeccable: B — estende `MunicipalityMapPanel`/`BahiaMap` existentes (seletor de escala, camada de símbolos); sem rota nova Appetite: ~2 dias eng; sem migration Responsável: — +## Revisão na entrega (2026-07-26) + +Três correções do plano original, encontradas ao auditá-lo contra o repositório antes de codar: + +1. **Caminhos e nomes defasados.** `BahiaMap.tsx`/`MunicipalityMapPanel.tsx` moraram em `src/components/campaign/` até o Pass 2 W2 e hoje estão em `src/components/campaign/map/`; o classificador do E10 chama-se `computeMunicipalityTerritorialClass`, não `computeDfTerritorialClass`. Os diagramas e a lista de componentes abaixo ficaram como estavam para preservar o registro do que foi planejado — leia-os com esta errata. +2. **"O mapa acompanha a lista" deixou de ser verdade.** Desde a remodelagem dos Municípios o mapa só existe no Início (`/campanha`) e recebe apenas `compare`; `/campanha/municipios` é lista/overview. A questão em aberto "quantis sobre o conjunto filtrado ou o estado inteiro" foi resolvida como **escopo de acesso do usuário** (435 municípios para coordenador/candidato, a carteira para o assessor), que é o análogo do "conjunto filtrado" na única superfície que o mapa tem. +3. **LQ como classes, não diverging.** Os Objetivos pediam "LQ diverging em torno de 1" e as Decisões travadas, três linhas abaixo, reservavam diverging exclusivamente ao compare. Resolvido a favor das decisões travadas: LQ virou **5 classes nomeadas** cortadas nas âncoras que o E10 já usa (`TERRITORIAL_CLASS_ANCHORS.weakLq` 0,5 / `strongLq` 2 mais a banda `AT_STANDARD_LQ` 0,95–1,15), na rampa sequencial do tema. Vermelho × azul continua significando "Solla × adversário" e só aparece no compare. + +Uma decisão cara que o plano não previa: **"Posição no município" não existia em lugar nenhum** — nem no artefato, nem em query reaproveitável (`municipalityCandidateComparison.ts` é por município × candidatos escolhidos). Construí-la em request significaria varrer ~100 mil linhas por ano para um dado que nunca muda, então o ranking foi para o **artefato TSE commitado** (regra 3 da escada de cache), como o E8 fez na v2. O rabbit hole "rank v1 = posição entre os top-N carregados, documentado" desapareceu junto: o rank do artefato é sobre **todos** os candidatos com votos na geografia. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (princípio 5; anti-goal cartograma) / `DESIGN.md` (register `product`) · precedentes B10/B11/B12/B6 (`BahiaMap`, `bahiaMapStyle.ts`, `MapFeatureReadout`). @@ -89,6 +99,22 @@ Componentes: - **Value-by-alpha (opacidade ∝ eleitorado).** Gatilho: teste com o time em reunião real pedir alternativa às bolhas. - **Roll-off como métrica de mapa.** Gatilho: I-A entrar no motor (E11 fase 2) e o time pedir a leitura espacial. +## As-built (2026-07-26) + +**Artefato v3.** `pnpm build:election-aggregates` passou a emitir `federalRankByIbgeCode: Record>` — a colocação do candidato entre **todos** os candidatos a deputado federal com votos > 0 naquela geografia, em 2014/2018/2022. Chaveado por `ibgeCode` (417 códigos) e não por slug porque é o que o mapa desenha: as 19 zonas de Salvador somam a cidade inteira. Geografia sem votos dele não recebe entrada (nunca `rank: 0`). O insumo é `loadFederalVotesByCityZoneAndCandidate` em `municipalityElectoralBaseline.ts` — irmão de `loadCampoFederalVotesByCityZone`, mesma varredura estadual, CLI/build-time only. O arquivo foi de 534 KB para 623 KB, dentro do orçamento de 700 KB que o E8 já tinha fixado. + +**Escalas.** A matemática pura vive em `src/lib/mapScaleClasses.ts` (arquivo novo — `bahiaMapStyle.ts` já carregava estilo de path e teria virado duas responsabilidades): quantis sobre os valores > 0 do escopo renderizado, **degradando para 3 classes com menos de 10 municípios** e devolvendo a contagem efetiva para a legenda não mentir; faixas de LQ nas âncoras do E10; buckets de rank (1º · 2º–3º · 4º+). `resolvePathStyle` ganhou um caminho de fill discreto por chave (`fillByKey`) que **substitui** a rampa contínua quando presente — chave ausente é "sem dado", que não é a mesma coisa que valor zero. + +As âncoras saíram de `municipalityTerritorialClass.ts` para `src/lib/territorialClassAnchors.ts` porque o painel do mapa é client component: importar o classificador lá arrastaria o artefato TSE inteiro para o bundle do navegador. Só números, sem dados nem lógica; classificador e copy leem do mesmo lugar, então recalibrar (E15) continua sendo um diff de uma linha em um arquivo. + +**Bundle.** `computeAggregateTerritorialClass(slugs)` classifica um grupo de slugs como um território só — soma os insumos e roda o **mesmo** `classifyMunicipalityTerritory`, nunca a média das classes por slug (LQ é razão, e a média de razões não é a razão das somas). O E12 herda o helper para o rollup por TI. O loader do mapa preenche `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` e `projectedValidVotesByCode`, todos puros sobre o artefato: **nenhuma query nova em request**. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022, como `percentValid` já fazia. + +**UI.** Default passou a ser **quantis**. A legenda discreta (`MapScaleLegend`) mostra um swatch por classe com a faixa real, do mais fraco para o mais forte, e a nota abaixo dela é ligada ao seletor por `aria-describedby` — inclusive a nota que declara a degradação ("cerca de um quinto dos N municípios com votos no seu escopo"). O readout nunca mostra a classe sozinha: soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Duas entradas novas em `/campanha/conceitos` (Quantis e Posição no município) e um "Saiba mais" inline na nota da legenda; LQ já estava coberto por `dominancia-relativa`. + +**Bolhas.** `L.circleMarker` no centro dos bounds de cada feature, raio ∝ √(válidos projetados) — a **área** carrega o valor. Fill pela classe do E10, `interactive: false` (a bolha fica em cima do próprio município, então deixar o ponteiro atravessar mantém um alvo de hover e um readout, não dois empilhados), desenhadas da menor para a maior para que os prêmios grandes não fiquem cobertos. Toggle desligado por default, com legenda de tamanho + cor que só aparece com a camada. Medido antes de otimizar, como o plano mandava: 417 marcadores não derrubaram o frame, então o corte para top-K **não** foi feito. + +**Sem migration, sem collection, sem server action.** O bundle client de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) — o painel do mapa já carrega em chunk próprio. + ## Referências - `docs/roadmap.md` (Inteligência de campanha, B13) · [plano-mestre](inteligencia-campanha.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index dcbc8b8b..a1ec7b1f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; **débito de teste** registrado: `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main`** também — falhas de hidratação no checkbox de consentimento do painel de contatos e colisão de regex entre "Conde" e "Condeúba" no fixture; [plano](plans/escala-relativa-mapa.md); **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); **B28** e-mail + celular na lista de `/campanha/liderancas` — colunas com copy-on-click + WhatsApp como em `/campanha/assessores` (B19 ✓); edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); **B24** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` — tira o botão "Salvar" que o princípio 4 do `PRODUCT.md` proíbe e fecha o adiado "nota no popover" do B9, [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -56,6 +56,7 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( - **E10 (2026-07-25)** — classificação territorial relativa: `municipalityTerritorialClass.ts`, função **pura sobre o artefato TSE commitado** (sem query, sem `campaignGoals`, sem `user`, sem migration), classificando os 435 municípios em **Reduto · Expansão · Manutenção · Marginal · Sem base** — vocabulário do research, que substitui o `Defesa/Ataque/Indecisa/Perdida` herdado do A5-2. Eixos: LQ contra o padrão estadual do próprio candidato (`strongLq` 2 / `weakLq` 0,5), importância na própria votação, teto do campo não capturado contra a **mediana do catálogo** e pertencimento ao bloco que soma 50% da votação (impede que um município grande com baixa penetração leia como "Marginal"). `getStatewideFederalTotals` foi extraído/memoizado em `bahiaElectionAggregates.ts` (`municipalityVoteRank` passou a reusá-lo). Superfícies: badge + "por quê" no card "Conta da cadeira" (E8), capa do dossiê (E16, com print CSS para o badge não achatar em cinza) e **coluna nova "Classe"** na lista com sort (`?sort=classe`) e filtro multi (`?class=` repetível) — o filtro é derivado, então força o caminho `limit: 0` e se aplica também ao escopo da visão geral e às facetas, senão `totalDocs` mentiria. Dois verbetes novos em `/campanha/conceitos` (E18). Cortes exatos são ilustrativos (calibração = **E15**); NEC/competição ficou fora com gatilho. Plano: [classificacao-territorial-relativa.md](plans/classificacao-territorial-relativa.md). Desbloqueia **B13** e a parte de classes do **E11**. - **B16 (2026-07-25)** — filtros no header da lista de municípios: `MunicipalityHeaderFilter` (funil + Popover ao lado do sort do B15) com multi-seleção OR em município, território, assessores e tendência, busca acento-insensível e "Limpar"; mobile mantém os selects empilhados; header sticky com a página como scroller e empty state que troca só as linhas, preservando visão geral e filtros; coluna "Assessoria" removida (ordenação foi para "Assessores", com/sem assessor virou opção do popover e o alerta "sem responsável" do E9 passou a morar na célula de assessores). Plano: [filtros-no-header-lista-municipios.md](plans/filtros-no-header-lista-municipios.md). - **B20 (2026-07-25)** — Municípios prioritários no Início staff: seção deixa de truncar 6 prioritárias sem critério; amostra de 8 ordenada por déficit de cobertura (E9/`central`) via `pickDashboardPriorityMunicipalities`; `highPriorityCount` no Badge; CTA “Ver todas” → `/campanha/municipios?priority=alta`; markup `list-none`. Plano: [municipios-prioritarios-inicio.md](plans/municipios-prioritarios-inicio.md). +- **B13 (2026-07-26)** — escala relativa no mapa do Início: o coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71%. Três escalas relativas em classes discretas — **quantis** (novo default, degradando para 3 classes com escopo < 10 municípios e declarando isso na legenda), **padrão próprio (LQ)** nas mesmas âncoras do E10 e **posição no município** — mais uma camada opcional de bolhas por válidos projetados, coloridas pela classe do E10. "Posição no município" não existia em lugar nenhum: entrou no **artefato TSE commitado (v3)** como `federalRankByIbgeCode` (colocação entre todos os candidatos com votos na geografia, 2014/2018/2022; 534 → 623 KB, orçamento 700 KB), não em query de request. `computeAggregateTerritorialClass` classifica um grupo de slugs como um território só (o mapa é por `ibgeCode` e Salvador são 19 slugs) somando insumos e rodando o **mesmo** classificador — o E12 herda o helper. As âncoras do E10 saíram para `src/lib/territorialClassAnchors.ts` para não arrastar o artefato ao bundle do cliente; a matemática das escalas vive em `src/lib/mapScaleClasses.ts`. Legenda discreta com faixas reais, readout que nunca mostra a classe sem o porquê, dois conceitos novos em `/campanha/conceitos`. Rank indisponível em 2026 (dado TSE); sem migration; bundle client de `/campanha` inalterado. Plano: [escala-relativa-mapa.md](plans/escala-relativa-mapa.md). ## Próximos — Campanha (`/campanha`) @@ -63,15 +64,15 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md]( O discovery literatura→persona→entrevista ([relatório aprovado](research/relatorio-entrevista-persona-campanha.md); compêndio com ~67 fontes em [docs/research/](research/)) fixou o kernel: a disputa de DF é conta de quociente fragmentada; o gargalo é converter lealdade de campo em voto nominal município a município via rede; % estadual absoluto é anti-métrica. A **sessão real com o Coordenador Geral (2026-07-23 — [CUSTOMER.md](CUSTOMER.md))** confirmou as apostas centrais (fogo amigo intra-PT como ameaça nº 1; canal = ZAP sem registro datado; "coluna da vergonha" validada — Salvador cobrado 10×) e calibrou as âncoras: a leitura relativa da mesa é **% da própria votação** (concentração da captura própria — critério rígido de prioridade dele), não % do eleitorado local; a restrição dominante é **"perna"/estrutura**, não dinheiro; piso projetado **150 mil** (2022: 129k). O produto deve entregar **inteligência, não planilha chique**: metas derivadas, leitura relativa, fila de decisão priorizada, sugestões dado→decisão com humano no loop, registro ex-ante auditável. Plano-mestre: [inteligencia-campanha.md](plans/inteligencia-campanha.md) (incl. gaps de dados G1–G11 e desenho canônico da fila). -| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de | -| ------- | -------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- | -| ~~E10~~ | ~~Classificação territorial relativa~~ | [detalhes](plans/classificacao-territorial-relativa.md) | **✓ (entregue 2026-07-25)** Reduto/Expansão/Manutenção/Marginal por LQ + importância + teto do campo; coluna, sort e filtro na lista | B | ~1,25d | 3 | E8 ✓ | -| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido — as **classes já existem** (`computeMunicipalityTerritorialClass`, E10 ✓): consumir como está, sem reclassificar | B | ~2d | 3 | E8 ✓, E10 ✓ | -| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 | -| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 | -| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ | -| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 | -| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 | +| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de | +| ------- | -------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | -------------------- | +| ~~E10~~ | ~~Classificação territorial relativa~~ | [detalhes](plans/classificacao-territorial-relativa.md) | **✓ (entregue 2026-07-25)** Reduto/Expansão/Manutenção/Marginal por LQ + importância + teto do campo; coluna, sort e filtro na lista | B | ~1,25d | 3 | E8 ✓ | +| ~~B13~~ | ~~Escala relativa no mapa~~ | [detalhes](plans/escala-relativa-mapa.md) | **✓ (entregue 2026-07-26)** Quantis (default) + LQ nas âncoras do E10 + posição no município (artefato v3); bolhas por válidos projetados coloridas pela classe | B | ~3d | 3 | E8 ✓, E10 ✓ | +| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 | +| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 | +| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ | +| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 | +| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 | ### Demais itens abertos @@ -188,7 +189,7 @@ flowchart TD C12i["C12 ✓ Registro-fundação"] E16i["E16 ✓ Dossiê do município"] E10i["E10 ✓ Classificação relativa"] - B13i["B13 Escala relativa no mapa"] + B13i["B13 ✓ Escala relativa no mapa"] E14i["E14 Níveis N0–N4"] E11i["E11 Motor de sugestões v1"] E12i["E12 Camada TI"] diff --git a/scripts/build-election-aggregates.mjs b/scripts/build-election-aggregates.mjs index bf6e0c56..3a52c31f 100644 --- a/scripts/build-election-aggregates.mjs +++ b/scripts/build-election-aggregates.mjs @@ -12,6 +12,10 @@ * #13) votes+tally — cut by the same `municipalityCatalog` geography used at * runtime (Salvador split into zone municipalities, every other municipality * whole) removes all historical-year DB work from the request/build path. + * The same reasoning covers B13's competitive placement + * (`federalRankByIbgeCode`): ranking him against every other candidate means + * reading the whole statewide slice, which is exactly what must not happen + * per request. * * Why not compute during `pnpm build`: Vercel builds must not depend on the * production database CONTENT (TSE seeds are local-only by policy), and the @@ -47,8 +51,10 @@ const { municipalityElectionGeography } = const { loadCampoFederalVotesByCityZone, loadCandidateVotesByCityZone, + loadFederalVotesByCityZoneAndCandidate, loadOfficeTallyByCityZone, loadValidVotesByCityZone, + sumCandidateVotesForGeography, sumOfficeTallyForGeography, sumVotesForGeography, } = await import('../src/utilities/municipalityElectoralBaseline.ts') @@ -166,6 +172,63 @@ console.log( `[build:election-aggregates] 2022 majoritarian: ${presidentTotal} votes for president #${BASELINE_TICKET_2022.president.candidateNumber}.`, ) +// B13 "posição no município": where the candidate placed among every federal +// deputy voted inside each municipality. Keyed by IBGE code rather than by +// catalog slug because the competitive question only has an answer for a whole +// city — Salvador's 19 zone municipalities are one placement, and the map +// paints one polygon for them anyway. +const geographiesByIbgeCode = new Map() +for (const entry of municipalityCatalog) { + const geographies = geographiesByIbgeCode.get(entry.ibgeCode) ?? [] + geographies.push(municipalityElectionGeography(entry)) + geographiesByIbgeCode.set(entry.ibgeCode, geographies) +} + +const federalRankByIbgeCode = {} +for (const year of HISTORICAL_SERIES_YEARS) { + const votesByCityZoneAndCandidate = await loadFederalVotesByCityZoneAndCandidate( + payload, + cliReader, + { year }, + ) + + for (const [ibgeCode, geographies] of geographiesByIbgeCode) { + const byCandidate = new Map() + for (const geography of geographies) { + for (const [number, votes] of sumCandidateVotesForGeography( + votesByCityZoneAndCandidate, + geography, + )) { + byCandidate.set(number, (byCandidate.get(number) ?? 0) + votes) + } + } + + const ownVotes = byCandidate.get(candidateNumber) ?? 0 + // No votes here means no placement — a last place would read as a fact + // when it is really absence of data. + if (ownVotes <= 0) continue + + let ahead = 0 + let candidates = 0 + for (const votes of byCandidate.values()) { + if (votes <= 0) continue + candidates += 1 + if (votes > ownVotes) ahead += 1 + } + + federalRankByIbgeCode[ibgeCode] ??= {} + // Competition rank: tied candidates share a placement. + federalRankByIbgeCode[ibgeCode][String(year)] = { rank: ahead + 1, candidates } + } + + const ranked = Object.values(federalRankByIbgeCode).filter( + (byYear) => byYear[String(year)] !== undefined, + ).length + console.log( + `[build:election-aggregates] ${year}: placement computed for ${ranked}/${geographiesByIbgeCode.size} IBGE municipalities.`, + ) +} + const artifact = { provenance: 'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.', @@ -173,6 +236,7 @@ const artifact = { candidateName: BASELINE_TICKET_2022.candidate.name, years: [...HISTORICAL_SERIES_YEARS], municipalities, + federalRankByIbgeCode, } await mkdir(OUTPUT_DIR, { recursive: true }) diff --git a/src/components/campaign/map/BahiaMap.tsx b/src/components/campaign/map/BahiaMap.tsx index 3612e4f6..5c75afd2 100644 --- a/src/components/campaign/map/BahiaMap.tsx +++ b/src/components/campaign/map/BahiaMap.tsx @@ -21,6 +21,7 @@ import { keyPropertyForMode, resolvePathStyle, type BahiaMapFillMode, + type ChoroplethFills, type ChoroplethValues, type LayerStyleContext, } from '@/lib/bahiaMapStyle' @@ -31,6 +32,19 @@ const BAHIA_BOUNDS: L.LatLngBoundsExpression = [ [-8.5, -37.0], ] +/** + * Proportional symbols scale by the SQUARE ROOT of the magnitude, so the area + * of the circle carries the value — sizing the radius directly would make a + * município with twice the votes look four times as big. Flannery's + * perceptual correction is deliberately skipped: it inflates the large end on + * purpose, and here the exact number is one hover away in the readout. + */ +export const BUBBLE_MAX_RADIUS = 16 +/** Small enough that a crowded east reads as texture, not as a second choropleth. */ +const BUBBLE_MIN_RADIUS = 1.5 +/** Used when the caller gives no fill for a key — grey reads as "no class". */ +const BUBBLE_NEUTRAL_FILL = '#78716c' + export type BahiaMapMode = 'municipality' | 'territory' export type BahiaMapFeatureInfo = { @@ -45,6 +59,20 @@ type BahiaMapProps = { fillMode?: BahiaMapFillMode /** When set, overrides auto max from `values` (e.g. fixed 1 for 0–100% shares). */ scaleMax?: number + /** + * Discrete class fills by feature key (B13). When provided they replace the + * continuous ramp entirely, and a key that is absent renders as "no data". + * Must be referentially stable across renders, or every render repaints + * every path. + */ + fillByKey?: ChoroplethFills + /** + * B13 — proportional symbols: feature key → magnitude, drawn as a circle at + * the feature's centre with radius ∝ √value. Absent keys get no bubble. + */ + bubbleValues?: ChoroplethValues + /** Bubble fill by feature key; keys without one fall back to the neutral fill. */ + bubbleFillByKey?: ChoroplethFills highlightKeys?: string[] /** Fit viewport to these keys without highlighting them. */ fitToKeys?: string[] @@ -99,6 +127,9 @@ export const BahiaMap = ({ values, fillMode = 'sequential', scaleMax, + fillByKey, + bubbleValues, + bubbleFillByKey, highlightKeys = [], fitToKeys = [], interactiveKeys = [], @@ -127,6 +158,7 @@ export const BahiaMap = ({ values, fillMode, scaleMax, + fillByKey, highlightKey: '', selectedKey, }) @@ -146,6 +178,7 @@ export const BahiaMap = ({ values, fillMode, scaleMax, + fillByKey, highlightKey, selectedKey, } @@ -398,6 +431,7 @@ export const BahiaMap = ({ values, fillMode, scaleMax, + fillByKey, highlightKey, selectedKey, hoveredKey: previousContext?.hoveredKey ?? null, @@ -408,7 +442,50 @@ export const BahiaMap = ({ restyleAllPaths() // `selectedKey` changes use the dedicated 2-path effect below (not full O(n) restyle). // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedKey excluded on purpose - }, [values, fillMode, scaleMax, highlightKey, mode]) + }, [values, fillMode, scaleMax, fillByKey, highlightKey, mode]) + + useEffect(() => { + const map = mapRef.current + if (!map || !bubbleValues || status !== 'ready') return + + let maxValue = 0 + for (const value of Object.values(bubbleValues)) { + if (value > maxValue) maxValue = value + } + if (maxValue <= 0) return + + // Smallest first so the big prizes end up drawn on top of the crowd + // instead of being covered by whichever key the object happened to list last. + const ordered = Object.entries(bubbleValues) + .filter(([, value]) => value > 0) + .sort(([, left], [, right]) => left - right) + + const markers: L.CircleMarker[] = [] + for (const [key, value] of ordered) { + const path = pathByKeyRef.current.get(key) + if (!(path instanceof L.Polygon)) continue + + markers.push( + L.circleMarker(path.getBounds().getCenter(), { + radius: Math.max(BUBBLE_MIN_RADIUS, BUBBLE_MAX_RADIUS * Math.sqrt(value / maxValue)), + fillColor: bubbleFillByKey?.[key] ?? BUBBLE_NEUTRAL_FILL, + fillOpacity: 0.8, + color: '#ffffff', + weight: 0.75, + // Deliberately not interactive: every bubble sits on top of its own + // município, so letting the pointer fall through keeps ONE hover + // target and one readout instead of two stacked ones. + interactive: false, + }), + ) + } + + const group = L.layerGroup(markers).addTo(map) + + return () => { + group.remove() + } + }, [bubbleValues, bubbleFillByKey, status]) useEffect(() => { const context = styleContextRef.current diff --git a/src/components/campaign/map/ChoroplethLegend.tsx b/src/components/campaign/map/ChoroplethLegend.tsx index 8d8dd56a..417454cb 100644 --- a/src/components/campaign/map/ChoroplethLegend.tsx +++ b/src/components/campaign/map/ChoroplethLegend.tsx @@ -5,12 +5,17 @@ type ChoroplethLegendProps = { max: number metricLabel: string formatMax?: (max: number) => string + /** What the colour means, in the caller's words. Shares the id with the scale selector. */ + noteId?: string + note?: string | null } export const ChoroplethLegend = ({ max, metricLabel, formatMax = formatElectionNumber, + noteId, + note, }: ChoroplethLegendProps) => (
@@ -25,8 +30,8 @@ export const ChoroplethLegend = ({ {formatMax(max)}
-

- Escala: intensidade da cor indica {metricLabel} no território. +

+ {note ?? `Escala: intensidade da cor indica ${metricLabel} no território.`}

) diff --git a/src/components/campaign/map/MapFeatureReadout.tsx b/src/components/campaign/map/MapFeatureReadout.tsx index 1d78cc00..dea9f8ca 100644 --- a/src/components/campaign/map/MapFeatureReadout.tsx +++ b/src/components/campaign/map/MapFeatureReadout.tsx @@ -12,6 +12,14 @@ type MapFeatureReadoutProps = { rawMetricValue: number | undefined metricLabel: string scaleMode: MunicipalityMapScaleMode + /** + * What the colour means for THIS município, spelled out — "4ª de 5 faixas", + * "1,8× o padrão estadual do candidato", "12º entre 663 candidatos". A class + * without its reason is a verdict the reader cannot check. + */ + relativeReading: string | null + /** What the bubble encodes for THIS município — only while the layer is on. */ + bubbleReading: string | null comparisonActive: boolean navigation: MunicipalityMapNavigation | null } @@ -41,6 +49,8 @@ export const MapFeatureReadout = ({ rawMetricValue, metricLabel, scaleMode, + relativeReading, + bubbleReading, comparisonActive, navigation, }: MapFeatureReadoutProps) => { @@ -53,6 +63,10 @@ export const MapFeatureReadout = ({ } const formattedValue = formatMetricValue(metricValue, scaleMode, comparisonActive) + // Every mode except the percentage prints the metric next to the number, so + // announcing it again would read it twice. + const showMetricLabel = + !comparisonActive && metricValue !== undefined && scaleMode !== 'percentValid' const showPercentSecondary = !comparisonActive && scaleMode === 'percentValid' && @@ -68,23 +82,27 @@ export const MapFeatureReadout = ({

{feature.name}

- {metricLabel}: + {showMetricLabel ? null : {metricLabel}: } {formattedValue} - {!comparisonActive && metricValue !== undefined && scaleMode === 'absolute' ? ( - {metricLabel} - ) : null} + {showMetricLabel ? {metricLabel} : null} {!comparisonActive && metricValue !== undefined && scaleMode === 'percentValid' ? ( dos válidos ) : null} {comparisonActive && metricValue !== undefined ? ( (diferença) ) : null} + {relativeReading ? ( + · {relativeReading} + ) : null}

{showPercentSecondary ? (

{formatElectionNumber(rawMetricValue)} votos

) : null} + {/* The bubble's own line: mixing "votes he got" and "votes at stake" + into one sentence is how a reader ends up quoting the wrong one. */} + {bubbleReading ?

{bubbleReading}

: null} {navigation?.kind === 'zones' ? (

Toque de novo no mapa ou role até os Municípios por zona abaixo. diff --git a/src/components/campaign/map/MapScaleLegend.tsx b/src/components/campaign/map/MapScaleLegend.tsx new file mode 100644 index 00000000..c169e2e6 --- /dev/null +++ b/src/components/campaign/map/MapScaleLegend.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link' + +import { campaignConceptHref, type CampaignConceptId } from '@/lib/campaignIntelligenceConcepts' +import type { MapScaleClassing } from '@/lib/mapScaleClasses' + +type MapScaleLegendProps = { + classing: MapScaleClassing + /** What the swatches measure — "votos de Jorge Solla em 2022". */ + metricLabel: string + /** What the colour means plus any caveat (small scope, missing data). */ + note: string + /** Ties the note to the scale selector through `aria-describedby`. */ + noteId: string + /** Glossary entry for this scale, when it has one (E18). */ + conceptID?: CampaignConceptId +} + +/** + * Discrete counterpart of `ChoroplethLegend`: a swatch per class with its real + * range, weakest on the left. A gradient bar cannot answer "which band is this + * município in", which is the only question a classed map is asked. + */ +export const MapScaleLegend = ({ + classing, + metricLabel, + note, + noteId, + conceptID, +}: MapScaleLegendProps) => ( +

+
    + {classing.classes.map((entry) => ( +
  • +
  • + ))} +
+

+ {note} + {conceptID ? ( + <> + {' '} + + Saiba mais + + + ) : null} +

+
+) diff --git a/src/components/campaign/map/MunicipalityMapPanel.tsx b/src/components/campaign/map/MunicipalityMapPanel.tsx index b4ff3d25..837084c0 100644 --- a/src/components/campaign/map/MunicipalityMapPanel.tsx +++ b/src/components/campaign/map/MunicipalityMapPanel.tsx @@ -4,19 +4,32 @@ import Link from 'next/link' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useCallback, useMemo, useRef, useState, useTransition } from 'react' -import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap' +import { + BahiaMap, + BUBBLE_MAX_RADIUS, + type BahiaMapFeatureInfo, +} from '@/components/campaign/map/BahiaMap' import { ChoroplethLegend } from '@/components/campaign/map/ChoroplethLegend' import { MapFeatureReadout } from '@/components/campaign/map/MapFeatureReadout' +import { MapScaleLegend } from '@/components/campaign/map/MapScaleLegend' import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' import { VOTE_ESTIMATE_SCENARIO_MAP_HINT, VoteEstimateScenarioField, } from '@/components/campaign/votePledge/VoteEstimateScenarioField' +import { Checkbox } from '@/components/ui/Checkbox' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { computeChoroplethMax } from '@/lib/bahiaMapStyle' +import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts' import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethColorScale' import { formatElectionNumber } from '@/lib/electionFormat' +import { + buildCompetitiveRankClassing, + buildLqClassing, + buildQuantileClassing, + fillsForClassing, +} from '@/lib/mapScaleClasses' import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels, @@ -24,8 +37,16 @@ import { } from '@/lib/voteEstimate' import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions' import { + formatDominanceAgainstOwnStandard, + territorialClassLabels, + territorialClassMapFill, +} from '@/utilities/municipalityLabels' +import { + DEFAULT_MUNICIPALITY_MAP_SCALE_MODE, + isMunicipalityMapScaleModeAvailable, MUNICIPALITY_MAP_SCALE_MODES, MUNICIPALITY_MAP_YEARS, + municipalityMapScaleModeHints, municipalityMapScaleModeLabels, municipalityMapYearLabels, type MunicipalityMapBundle, @@ -33,6 +54,74 @@ import { type MunicipalityMapYear, } from '@/utilities/municipalityMapContract' import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation' +import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' + +/** The legend's note doubles as the scale selector's description. */ +const SCALE_NOTE_ID = 'municipality-map-scale-note' + +/** + * Scales that have their own glossary entry (E18). LQ is documented as + * "dominância relativa" — the same ratio under the name the list already uses. + */ +const SCALE_CONCEPT_ID: Partial> = { + quantile: 'quantis-do-mapa', + lq: 'dominancia-relativa', + competitiveRank: 'posicao-no-municipio', +} + +/** Reference sizes in the bubble key: the largest bubble and a quarter of it. */ +const BUBBLE_KEY_FRACTIONS = [0.25, 1] as const + +/** + * Two encodings need decoding when the layer is on — how big the prize is + * (area) and how he stands there (colour) — so the key carries both, with + * real circles rather than a sentence about sizes nobody can eyeball. + */ +const MapBubbleKey = ({ largestValue }: { largestValue: number }) => ( +
+
+
    + {BUBBLE_KEY_FRACTIONS.map((fraction) => { + const diameter = 2 * BUBBLE_MAX_RADIUS * Math.sqrt(fraction) + return ( +
  • +
  • + ) + })} +
+
    + {Object.entries(territorialClassLabels).map(([territorialClass, label]) => ( +
  • +
  • + ))} +
+
+

+ Tamanho = votos válidos projetados para 2026, o que está em jogo ali. Cor = classe do + município, a leitura da lista. +

+
+) type MunicipalityMapPanelProps = { bundle: MunicipalityMapBundle @@ -49,7 +138,10 @@ export const MunicipalityMapPanel = ({ const pathname = usePathname() const searchParams = useSearchParams() const [year, setYear] = useState(defaultYear) - const [scaleMode, setScaleMode] = useState('percentValid') + const [scaleMode, setScaleMode] = useState( + DEFAULT_MUNICIPALITY_MAP_SCALE_MODE, + ) + const [bubblesEnabled, setBubblesEnabled] = useState(false) const scenarioContext = useMunicipalityEstimateScenarioOptional() const [localEstimateScenario, setLocalEstimateScenario] = useState( DEFAULT_VOTE_ESTIMATE_SCENARIO, @@ -59,7 +151,19 @@ export const MunicipalityMapPanel = ({ const comparison = bundle.comparison const comparisonActive = comparison !== null && year !== 2026 - const percentScaleActive = !comparisonActive && scaleMode === 'percentValid' + + // Comparing forces the diverging absolute scale, and the competitive + // placement has no 2026 reading (it is a TSE result). Rather than let the + // select show a mode the map isn't painting, fall back and say so. + const effectiveScaleMode: MunicipalityMapScaleMode = comparisonActive + ? 'absolute' + : isMunicipalityMapScaleModeAvailable(scaleMode, year) + ? scaleMode + : DEFAULT_MUNICIPALITY_MAP_SCALE_MODE + const percentScaleActive = effectiveScaleMode === 'percentValid' + // The compare map already spends colour on two candidates; a third encoding + // on top of it would be unreadable, so the bubbles step aside there. + const showBubbles = bubblesEnabled && !comparisonActive const rawValues = useMemo(() => { if (comparisonActive && comparison) { @@ -93,6 +197,83 @@ export const MunicipalityMapPanel = ({ [bundle.municipalitiesByIbgeCode], ) + /** LQ per município against his statewide standard — the same ratio E10 classes on. */ + const lqByCode = useMemo(() => { + const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0 + if (statewideShare <= 0) return {} + + const lq: Record = {} + for (const [code, votes] of Object.entries(rawValues)) { + const validVotes = validVotesForYear[code] ?? 0 + if (votes <= 0 || validVotes <= 0) continue + lq[code] = votes / validVotes / statewideShare + } + return lq + }, [bundle.statewideShareByYear, rawValues, validVotesForYear, year]) + + const competitiveRankForYear = useMemo( + () => bundle.competitiveRankByYear[String(year)] ?? {}, + [bundle.competitiveRankByYear, year], + ) + + const classing = useMemo(() => { + switch (effectiveScaleMode) { + case 'quantile': + return buildQuantileClassing(rawValues) + case 'lq': + return buildLqClassing(lqByCode) + case 'competitiveRank': + return buildCompetitiveRankClassing( + Object.fromEntries( + Object.entries(competitiveRankForYear).map(([code, placement]) => [ + code, + placement.rank, + ]), + ), + ) + default: + return null + } + }, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues]) + + const fillByKey = useMemo(() => (classing ? fillsForClassing(classing) : undefined), [classing]) + + /** + * What the colour means, said ONCE and next to the swatches that need + * decoding — putting it under the selector instead made the control column + * taller than the title beside it, and repeated the sentence the legend was + * already carrying. + */ + const legendNote = useMemo(() => { + const parts = [municipalityMapScaleModeHints[effectiveScaleMode]] + + if (classing) { + const painted = Object.keys(classing.classIndexByKey).length + const missing = scopedKeys.length - painted + + if (effectiveScaleMode === 'quantile') { + parts.push( + classing.reduced + ? `Só ${formatElectionNumber(painted)} municípios do seu escopo têm votos — poucos para cinco, então a escala usa ${classing.classes.length}.` + : `Aqui são ${formatElectionNumber(painted)} municípios com votos no seu escopo.`, + ) + } + if (effectiveScaleMode === 'lq') { + parts.push('1× é a média estadual dele, não a dos municípios em exibição.') + } + if (effectiveScaleMode === 'competitiveRank') { + parts.push('Vale para a cidade inteira: em Salvador, as 19 zonas dividem a mesma posição.') + } + if (missing > 0) { + parts.push( + `${formatElectionNumber(missing)} sem dado ${missing === 1 ? 'ficou' : 'ficaram'} em cinza.`, + ) + } + } + + return parts.join(' ') + }, [classing, effectiveScaleMode, scopedKeys.length]) + const metricLabel = useMemo(() => { if (comparisonActive) { return `diferença de votos em ${year}` @@ -108,6 +289,76 @@ export const MunicipalityMapPanel = ({ return `votos de ${bundle.candidateName} em ${year}` }, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year]) + /** + * The relative sentence for one município — the class alone ("Q4") is a + * verdict without evidence, so the readout always spells out what the colour + * means for the município under the cursor. + */ + /** + * Votes at stake, not votes won: the bubble asks "how big is the prize + * here", which is a different question from the colour's "how is he doing + * here" — reading them together is the point of the layer. + */ + const bubbleValues = useMemo( + () => (showBubbles ? bundle.projectedValidVotesByCode : undefined), + [bundle.projectedValidVotesByCode, showBubbles], + ) + + const bubbleFillByKey = useMemo(() => { + if (!showBubbles) return undefined + const fills: Record = {} + for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) { + fills[code] = territorialClassMapFill[territorialClass] + } + return fills + }, [bundle.territorialClassByCode, showBubbles]) + + const largestBubbleValue = useMemo(() => { + if (!bubbleValues) return 0 + return Object.values(bubbleValues).reduce((max, value) => Math.max(max, value), 0) + }, [bubbleValues]) + + const bubbleReadingFor = useCallback( + (key: string): string | null => { + if (!showBubbles) return null + + const projected = bundle.projectedValidVotesByCode[key] ?? 0 + if (projected <= 0) return null + + const territorialClass = bundle.territorialClassByCode[key] + const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null + return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}` + }, + [bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles], + ) + + const relativeReadingFor = useCallback( + (key: string): string | null => { + if (!classing) return null + + const classIndex = classing.classIndexByKey[key] + switch (effectiveScaleMode) { + case 'quantile': { + if (classIndex === undefined) return null + const range = classing.classes[classIndex]?.label + return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}` + } + case 'lq': { + const lq = lqByCode[key] + return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq) + } + case 'competitiveRank': { + const placement = competitiveRankForYear[key] + if (!placement) return null + return `${placement.rank}º entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui` + } + default: + return null + } + }, + [classing, competitiveRankForYear, effectiveScaleMode, lqByCode], + ) + // Optimistic on the control, honest pending on the map: the comparison data // is a server round trip, so the select flips at once and the map region // dims until the refreshed bundle arrives. @@ -140,12 +391,12 @@ export const MunicipalityMapPanel = ({

Mapa dos Municípios

+ {/* What the map is made of stays here; what the COLOUR means is + said once, next to the scale selector that decides it. */}

{comparisonActive && comparison ? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.` - : percentScaleActive - ? `Cor pela participação nos votos válidos — em 2026, estimativas sobre válidos de 2022.` - : `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`} + : `Votação de ${bundle.candidateName} por município — em 2026, pelas estimativas da campanha.`}

@@ -165,15 +416,18 @@ export const MunicipalityMapPanel = ({ {!comparisonActive ? ( - + Escala setScaleMode(event.target.value as MunicipalityMapScaleMode)} className="min-h-11 w-full" + aria-describedby={SCALE_NOTE_ID} > - {MUNICIPALITY_MAP_SCALE_MODES.map((mode) => ( + {MUNICIPALITY_MAP_SCALE_MODES.filter((mode) => + isMunicipalityMapScaleModeAvailable(mode, year), + ).map((mode) => ( {municipalityMapScaleModeLabels[mode]} @@ -241,16 +495,48 @@ export const MunicipalityMapPanel = ({ {comparison.candidateName} na frente. Comparação usa diferença absoluta.

+ ) : classing && classing.classes.length > 0 ? ( + ) : displayMax > 0 ? ( '100%' : undefined} + note={legendNote} + noteId={SCALE_NOTE_ID} /> ) : (

Sem dados para este ano no seu escopo.

)} + {/* A second encoding, so it is opt-in and its key only appears with it. */} + {!comparisonActive ? ( +
+ + {showBubbles ? ( + largestBubbleValue > 0 ? ( + + ) : ( +

+ Sem projeção de votos válidos para os municípios do seu escopo. +

+ ) + ) : null} +
+ ) : null} + {comparison && year === 2026 ? (

A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para @@ -262,9 +548,14 @@ export const MunicipalityMapPanel = ({ displayValues={displayValues} rawValues={rawValues} displayMax={displayMax} + fillByKey={fillByKey} + bubbleValues={bubbleValues} + bubbleFillByKey={bubbleFillByKey} scopedKeys={scopedKeys} metricLabel={metricLabel} - scaleMode={comparisonActive ? 'absolute' : scaleMode} + scaleMode={effectiveScaleMode} + relativeReadingFor={relativeReadingFor} + bubbleReadingFor={bubbleReadingFor} comparisonActive={comparisonActive} municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode} ariaLabel={ @@ -320,9 +611,14 @@ const MunicipalityMapSelection = ({ displayValues, rawValues, displayMax, + fillByKey, + bubbleValues, + bubbleFillByKey, scopedKeys, metricLabel, scaleMode, + relativeReadingFor, + bubbleReadingFor, comparisonActive, municipalitiesByIbgeCode, ariaLabel, @@ -330,9 +626,14 @@ const MunicipalityMapSelection = ({ displayValues: Record rawValues: Record displayMax: number + fillByKey: Record | undefined + bubbleValues: Record | undefined + bubbleFillByKey: Record | undefined scopedKeys: string[] metricLabel: string scaleMode: MunicipalityMapScaleMode + relativeReadingFor: (key: string) => string | null + bubbleReadingFor: (key: string) => string | null comparisonActive: boolean municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode'] ariaLabel: string @@ -388,6 +689,9 @@ const MunicipalityMapSelection = ({ mode="municipality" values={displayValues} scaleMax={displayMax > 0 ? displayMax : undefined} + fillByKey={fillByKey} + bubbleValues={bubbleValues} + bubbleFillByKey={bubbleFillByKey} fillMode={comparisonActive ? 'diverging' : 'sequential'} fitToKeys={scopedKeys} interactiveKeys={scopedKeys} @@ -403,6 +707,8 @@ const MunicipalityMapSelection = ({ rawMetricValue={selectedRawMetricValue} metricLabel={metricLabel} scaleMode={scaleMode} + relativeReading={selectedFeature ? relativeReadingFor(selectedFeature.key) : null} + bubbleReading={selectedFeature ? bubbleReadingFor(selectedFeature.key) : null} comparisonActive={comparisonActive} navigation={selectedNavigation} /> diff --git a/src/lib/bahiaElectionAggregates.ts b/src/lib/bahiaElectionAggregates.ts index 1c1c4b8b..ab2592cf 100644 --- a/src/lib/bahiaElectionAggregates.ts +++ b/src/lib/bahiaElectionAggregates.ts @@ -16,6 +16,12 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json' * this artifact; a future consumer that needs it extends `majoritarian2022` * into a `majoritarianByYear` map. * + * v3 (B13 "posição no município"): adds `federalRankByIbgeCode` — where the + * candidate placed among every federal-deputy candidate voted inside that + * municipality. Keyed by IBGE code, not by catalog slug, because it is the + * map's key and because the competitive question only has an answer for a + * whole city: Salvador's 19 zone municipalities share one placement. + * * Generated by `pnpm build:election-aggregates` from the locally seeded TSE * data — never computed at build or request time (see the script header for * the rationale). A snapshot test pins artifact keys ↔ catalog slugs. @@ -50,12 +56,22 @@ export type MunicipalityFederalBaseline = { } } +/** Where the candidate placed among the federal-deputy candidates voted in one municipality. */ +export type FederalCompetitiveRank = { + /** 1 = most voted. Ties share a placement (competition rank). */ + rank: number + /** Candidates with at least one vote there — the honest denominator for "12º de …". */ + candidates: number +} + type FederalBaselineArtifact = { provenance: string candidateNumber: number candidateName: string years: number[] municipalities: Record + /** IBGE codarea → year (as string) → placement. Absent where he took no votes. */ + federalRankByIbgeCode: Record> } const artifact = artifactJson as FederalBaselineArtifact @@ -79,6 +95,15 @@ const EMPTY_BASELINE: MunicipalityFederalBaseline = { export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline => artifact.municipalities[slug] ?? EMPTY_BASELINE +/** + * Competitive placement inside one IBGE municipality for one year, or `null` + * where the candidate took no votes there (never a misleading last place). + */ +export const getFederalCompetitiveRank = ( + ibgeCode: string, + year: number, +): FederalCompetitiveRank | null => artifact.federalRankByIbgeCode[ibgeCode]?.[String(year)] ?? null + /** Statewide sums of the artifact for one year — the denominators of every relative reading. */ type StatewideFederalTotals = { /** Candidate's own nominal votes across every catalog municipality. */ diff --git a/src/lib/bahiaMapStyle.ts b/src/lib/bahiaMapStyle.ts index ff7bca2e..0dfce5fc 100644 --- a/src/lib/bahiaMapStyle.ts +++ b/src/lib/bahiaMapStyle.ts @@ -13,20 +13,32 @@ export type BahiaMapFillMode = 'sequential' | 'diverging' export type FeatureKeyProperty = 'codarea' | 'code' +/** + * Feature key → fill, for the discrete relative scales (B13). When present it + * REPLACES the continuous ramp: a key absent from the record has no class and + * renders as "no data", which is not the same as a value of zero. + */ +export type ChoroplethFills = Record + export type LayerStyleContext = { values: ChoroplethValues fillMode: BahiaMapFillMode max: number + fillByKey?: ChoroplethFills highlightSet: Set selectedKey: string | null hoveredKey: string | null } +const NO_DATA_FILL = '#f4f4f5' + type FeatureStyleInput = { metric: number highlighted: boolean fillMode: BahiaMapFillMode max: number + classFill?: string + classed: boolean } const getFeatureStyle = ({ @@ -34,12 +46,17 @@ const getFeatureStyle = ({ highlighted, fillMode, max, + classFill, + classed, }: FeatureStyleInput): PathOptions => ({ weight: highlighted ? 2 : 1, color: highlighted ? '#c51414' : '#a8a29e', - fillColor: - fillMode === 'diverging' ? divergingFillColor(metric, max) : choroplethFillColor(metric, max), - fillOpacity: metric !== 0 ? 0.78 : 0.35, + fillColor: classed + ? (classFill ?? NO_DATA_FILL) + : fillMode === 'diverging' + ? divergingFillColor(metric, max) + : choroplethFillColor(metric, max), + fillOpacity: (classed ? classFill !== undefined : metric !== 0) ? 0.78 : 0.35, }) export const computeChoroplethMax = ( @@ -65,12 +82,15 @@ export const featureKeyFromProperties = ( ): string | undefined => properties?.[keyProperty] export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => { + const classed = context.fillByKey !== undefined + if (!key) { return getFeatureStyle({ metric: 0, highlighted: false, fillMode: context.fillMode, max: context.max, + classed, }) } @@ -83,6 +103,8 @@ export const resolvePathStyle = (context: LayerStyleContext, key: string): PathO highlighted, fillMode: context.fillMode, max: context.max, + classFill: context.fillByKey?.[key], + classed, }) } @@ -90,6 +112,7 @@ export const buildLayerStyleContext = ({ values, fillMode, scaleMax, + fillByKey, highlightKey, selectedKey, hoveredKey = null, @@ -97,6 +120,7 @@ export const buildLayerStyleContext = ({ values: ChoroplethValues fillMode: BahiaMapFillMode scaleMax?: number + fillByKey?: ChoroplethFills highlightKey: string selectedKey: string | null hoveredKey?: string | null @@ -104,6 +128,7 @@ export const buildLayerStyleContext = ({ values, fillMode, max: computeChoroplethMax(values, fillMode, scaleMax), + fillByKey, highlightSet: buildHighlightSet(highlightKey), selectedKey, hoveredKey, diff --git a/src/lib/campaignIntelligenceConcepts.ts b/src/lib/campaignIntelligenceConcepts.ts index e6adc29d..02ebb727 100644 --- a/src/lib/campaignIntelligenceConcepts.ts +++ b/src/lib/campaignIntelligenceConcepts.ts @@ -10,9 +10,11 @@ * actually computes; when a formula changes, this text changes with it. * * v1 covers the E8 "conta da cadeira" numbers; E10 added dominância relativa - * and a classe territorial. Each later slice of the intelligence program (B13 - * escala do mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its - * own entries as part of its own delivery. + * and a classe territorial; B13 added the two map scales that are not just a + * re-rendering of an existing number (quantis and posição no município — LQ is + * already documented as dominância relativa). Each later slice of the + * intelligence program (E11 sugestões, E12 TI, E13 giros, E14 níveis) appends + * its own entries as part of its own delivery. */ export type CampaignConceptId = @@ -23,6 +25,8 @@ export type CampaignConceptId = | 'roll-off' | 'dominancia-relativa' | 'classe-territorial' + | 'quantis-do-mapa' + | 'posicao-no-municipio' | 'meta' | 'cobertura-da-meta' @@ -146,6 +150,34 @@ export const campaignIntelligenceConcepts: ReadonlyArray { return `rgb(${r}, ${g}, ${b})` } +/** + * The same sequential ramp sampled at `total` evenly spaced steps — the fill + * for class `index` (0 = weakest). Discrete classes are what make a relative + * scale readable at a glance: a continuous ramp over a federal-deputy race + * paints almost the whole state the same shade (B11's lesson). + * + * The floor keeps the lightest class distinguishable from "no data" (#f4f4f5). + */ +const DISCRETE_CLASS_FLOOR = 0.18 + +export const discreteChoroplethFill = (index: number, total: number): string => { + if (total <= 0) return '#f4f4f5' + if (total === 1) return choroplethFillColor(1, 1) + + const step = index / (total - 1) + return choroplethFillColor(DISCRETE_CLASS_FLOOR + (1 - DISCRETE_CLASS_FLOOR) * step, 1) +} + export const choroplethMaxValue = (values: Record): number => { const entries = Object.values(values).filter((value) => value > 0) return entries.length > 0 ? Math.max(...entries) : 0 diff --git a/src/lib/electionAggregates/bahia-federal-baseline.json b/src/lib/electionAggregates/bahia-federal-baseline.json index cf0c87c5..447ea880 100644 --- a/src/lib/electionAggregates/bahia-federal-baseline.json +++ b/src/lib/electionAggregates/bahia-federal-baseline.json @@ -23063,5 +23063,5569 @@ } } } + }, + "federalRankByIbgeCode": { + "2900108": { + "2014": { + "rank": 4, + "candidates": 75 + }, + "2018": { + "rank": 20, + "candidates": 111 + }, + "2022": { + "rank": 8, + "candidates": 123 + } + }, + "2900207": { + "2014": { + "rank": 18, + "candidates": 81 + }, + "2018": { + "rank": 18, + "candidates": 95 + }, + "2022": { + "rank": 16, + "candidates": 129 + } + }, + "2900306": { + "2014": { + "rank": 4, + "candidates": 118 + }, + "2018": { + "rank": 11, + "candidates": 158 + }, + "2022": { + "rank": 30, + "candidates": 193 + } + }, + "2900355": { + "2014": { + "rank": 22, + "candidates": 92 + }, + "2018": { + "rank": 11, + "candidates": 116 + }, + "2022": { + "rank": 26, + "candidates": 155 + } + }, + "2900405": { + "2014": { + "rank": 7, + "candidates": 109 + }, + "2018": { + "rank": 8, + "candidates": 142 + }, + "2022": { + "rank": 12, + "candidates": 205 + } + }, + "2900504": { + "2014": { + "rank": 6, + "candidates": 64 + }, + "2018": { + "rank": 3, + "candidates": 101 + }, + "2022": { + "rank": 7, + "candidates": 122 + } + }, + "2900603": { + "2014": { + "rank": 9, + "candidates": 73 + }, + "2018": { + "rank": 8, + "candidates": 79 + }, + "2022": { + "rank": 16, + "candidates": 92 + } + }, + "2900702": { + "2014": { + "rank": 9, + "candidates": 190 + }, + "2018": { + "rank": 15, + "candidates": 328 + }, + "2022": { + "rank": 16, + "candidates": 470 + } + }, + "2900801": { + "2014": { + "rank": 11, + "candidates": 109 + }, + "2018": { + "rank": 6, + "candidates": 153 + }, + "2022": { + "rank": 10, + "candidates": 203 + } + }, + "2900900": { + "2014": { + "rank": 21, + "candidates": 80 + }, + "2018": { + "rank": 17, + "candidates": 84 + }, + "2022": { + "rank": 38, + "candidates": 112 + } + }, + "2901007": { + "2014": { + "rank": 1, + "candidates": 144 + }, + "2018": { + "rank": 1, + "candidates": 211 + }, + "2022": { + "rank": 1, + "candidates": 266 + } + }, + "2901106": { + "2018": { + "rank": 15, + "candidates": 209 + }, + "2022": { + "rank": 29, + "candidates": 317 + } + }, + "2901155": { + "2014": { + "rank": 20, + "candidates": 94 + }, + "2018": { + "rank": 27, + "candidates": 121 + }, + "2022": { + "rank": 36, + "candidates": 179 + } + }, + "2901205": { + "2014": { + "rank": 8, + "candidates": 108 + }, + "2018": { + "rank": 22, + "candidates": 150 + }, + "2022": { + "rank": 28, + "candidates": 207 + } + }, + "2901304": { + "2014": { + "rank": 4, + "candidates": 102 + }, + "2018": { + "rank": 3, + "candidates": 136 + }, + "2022": { + "rank": 5, + "candidates": 186 + } + }, + "2901353": { + "2014": { + "rank": 28, + "candidates": 101 + }, + "2018": { + "rank": 4, + "candidates": 120 + }, + "2022": { + "rank": 4, + "candidates": 157 + } + }, + "2901403": { + "2014": { + "rank": 8, + "candidates": 100 + }, + "2018": { + "rank": 6, + "candidates": 120 + }, + "2022": { + "rank": 35, + "candidates": 159 + } + }, + "2901502": { + "2022": { + "rank": 13, + "candidates": 183 + } + }, + "2901601": { + "2014": { + "rank": 17, + "candidates": 89 + }, + "2018": { + "rank": 3, + "candidates": 102 + }, + "2022": { + "rank": 10, + "candidates": 153 + } + }, + "2901700": { + "2014": { + "rank": 24, + "candidates": 108 + }, + "2018": { + "rank": 10, + "candidates": 135 + }, + "2022": { + "rank": 15, + "candidates": 199 + } + }, + "2901809": { + "2014": { + "rank": 28, + "candidates": 73 + }, + "2018": { + "rank": 17, + "candidates": 126 + }, + "2022": { + "rank": 29, + "candidates": 137 + } + }, + "2901908": { + "2014": { + "rank": 7, + "candidates": 103 + }, + "2018": { + "rank": 18, + "candidates": 153 + }, + "2022": { + "rank": 19, + "candidates": 160 + } + }, + "2901957": { + "2014": { + "rank": 7, + "candidates": 80 + }, + "2018": { + "rank": 15, + "candidates": 87 + }, + "2022": { + "rank": 27, + "candidates": 106 + } + }, + "2902005": { + "2014": { + "rank": 24, + "candidates": 90 + }, + "2018": { + "rank": 28, + "candidates": 141 + }, + "2022": { + "rank": 21, + "candidates": 147 + } + }, + "2902054": { + "2018": { + "rank": 21, + "candidates": 137 + }, + "2022": { + "rank": 20, + "candidates": 174 + } + }, + "2902104": { + "2014": { + "rank": 21, + "candidates": 136 + }, + "2018": { + "rank": 21, + "candidates": 201 + }, + "2022": { + "rank": 35, + "candidates": 256 + } + }, + "2902203": { + "2014": { + "rank": 5, + "candidates": 103 + }, + "2018": { + "rank": 19, + "candidates": 130 + }, + "2022": { + "rank": 25, + "candidates": 174 + } + }, + "2902252": { + "2014": { + "rank": 22, + "candidates": 71 + }, + "2018": { + "rank": 19, + "candidates": 117 + }, + "2022": { + "rank": 40, + "candidates": 141 + } + }, + "2902302": { + "2014": { + "rank": 8, + "candidates": 96 + }, + "2018": { + "rank": 14, + "candidates": 129 + }, + "2022": { + "rank": 14, + "candidates": 185 + } + }, + "2902401": { + "2014": { + "rank": 8, + "candidates": 83 + }, + "2018": { + "rank": 7, + "candidates": 140 + }, + "2022": { + "rank": 3, + "candidates": 195 + } + }, + "2902500": { + "2018": { + "rank": 14, + "candidates": 108 + }, + "2022": { + "rank": 40, + "candidates": 151 + } + }, + "2902609": { + "2014": { + "rank": 12, + "candidates": 117 + }, + "2018": { + "rank": 14, + "candidates": 164 + }, + "2022": { + "rank": 12, + "candidates": 222 + } + }, + "2902658": { + "2014": { + "rank": 24, + "candidates": 89 + }, + "2018": { + "rank": 21, + "candidates": 113 + }, + "2022": { + "rank": 7, + "candidates": 112 + } + }, + "2902708": { + "2014": { + "rank": 25, + "candidates": 117 + }, + "2018": { + "rank": 26, + "candidates": 171 + }, + "2022": { + "rank": 16, + "candidates": 248 + } + }, + "2902807": { + "2014": { + "rank": 5, + "candidates": 105 + }, + "2018": { + "rank": 6, + "candidates": 144 + }, + "2022": { + "rank": 11, + "candidates": 184 + } + }, + "2902906": { + "2014": { + "rank": 5, + "candidates": 127 + }, + "2018": { + "rank": 15, + "candidates": 179 + }, + "2022": { + "rank": 18, + "candidates": 236 + } + }, + "2903003": { + "2014": { + "rank": 7, + "candidates": 101 + }, + "2018": { + "rank": 13, + "candidates": 128 + }, + "2022": { + "rank": 7, + "candidates": 173 + } + }, + "2903102": { + "2018": { + "rank": 5, + "candidates": 100 + }, + "2022": { + "rank": 4, + "candidates": 102 + } + }, + "2903201": { + "2014": { + "rank": 12, + "candidates": 187 + }, + "2018": { + "rank": 11, + "candidates": 274 + }, + "2022": { + "rank": 25, + "candidates": 401 + } + }, + "2903235": { + "2014": { + "rank": 14, + "candidates": 85 + }, + "2018": { + "rank": 21, + "candidates": 106 + }, + "2022": { + "rank": 7, + "candidates": 147 + } + }, + "2903276": { + "2014": { + "rank": 3, + "candidates": 98 + }, + "2018": { + "rank": 3, + "candidates": 142 + }, + "2022": { + "rank": 3, + "candidates": 199 + } + }, + "2903300": { + "2014": { + "rank": 17, + "candidates": 85 + }, + "2018": { + "rank": 22, + "candidates": 119 + }, + "2022": { + "rank": 34, + "candidates": 148 + } + }, + "2903409": { + "2014": { + "rank": 23, + "candidates": 106 + }, + "2018": { + "rank": 23, + "candidates": 168 + }, + "2022": { + "rank": 27, + "candidates": 224 + } + }, + "2903508": { + "2014": { + "rank": 6, + "candidates": 98 + }, + "2018": { + "rank": 13, + "candidates": 128 + }, + "2022": { + "rank": 18, + "candidates": 177 + } + }, + "2903607": { + "2014": { + "rank": 26, + "candidates": 105 + }, + "2018": { + "rank": 2, + "candidates": 141 + }, + "2022": { + "rank": 28, + "candidates": 200 + } + }, + "2903706": { + "2014": { + "rank": 9, + "candidates": 91 + }, + "2018": { + "rank": 16, + "candidates": 127 + }, + "2022": { + "rank": 28, + "candidates": 174 + } + }, + "2903805": { + "2014": { + "rank": 13, + "candidates": 103 + }, + "2018": { + "rank": 21, + "candidates": 139 + }, + "2022": { + "rank": 12, + "candidates": 180 + } + }, + "2903904": { + "2014": { + "rank": 3, + "candidates": 133 + }, + "2018": { + "rank": 4, + "candidates": 211 + }, + "2022": { + "rank": 4, + "candidates": 280 + } + }, + "2903953": { + "2014": { + "rank": 12, + "candidates": 80 + }, + "2018": { + "rank": 9, + "candidates": 104 + }, + "2022": { + "rank": 8, + "candidates": 144 + } + }, + "2904001": { + "2014": { + "rank": 10, + "candidates": 81 + }, + "2018": { + "rank": 12, + "candidates": 115 + }, + "2022": { + "rank": 11, + "candidates": 154 + } + }, + "2904050": { + "2014": { + "rank": 14, + "candidates": 81 + }, + "2018": { + "rank": 12, + "candidates": 120 + }, + "2022": { + "rank": 15, + "candidates": 148 + } + }, + "2904100": { + "2014": { + "rank": 7, + "candidates": 92 + }, + "2018": { + "rank": 7, + "candidates": 124 + }, + "2022": { + "rank": 7, + "candidates": 165 + } + }, + "2904209": { + "2018": { + "rank": 13, + "candidates": 108 + }, + "2022": { + "rank": 6, + "candidates": 129 + } + }, + "2904308": { + "2014": { + "rank": 16, + "candidates": 91 + }, + "2018": { + "rank": 1, + "candidates": 128 + }, + "2022": { + "rank": 1, + "candidates": 183 + } + }, + "2904407": { + "2014": { + "rank": 14, + "candidates": 63 + }, + "2018": { + "rank": 28, + "candidates": 92 + }, + "2022": { + "rank": 37, + "candidates": 108 + } + }, + "2904506": { + "2014": { + "rank": 15, + "candidates": 82 + }, + "2018": { + "rank": 7, + "candidates": 94 + }, + "2022": { + "rank": 18, + "candidates": 147 + } + }, + "2904605": { + "2014": { + "rank": 12, + "candidates": 152 + }, + "2018": { + "rank": 25, + "candidates": 240 + }, + "2022": { + "rank": 24, + "candidates": 309 + } + }, + "2904704": { + "2014": { + "rank": 24, + "candidates": 117 + }, + "2018": { + "rank": 36, + "candidates": 177 + }, + "2022": { + "rank": 20, + "candidates": 203 + } + }, + "2904753": { + "2014": { + "rank": 23, + "candidates": 76 + }, + "2018": { + "rank": 24, + "candidates": 97 + }, + "2022": { + "rank": 31, + "candidates": 151 + } + }, + "2904803": { + "2018": { + "rank": 42, + "candidates": 94 + }, + "2022": { + "rank": 27, + "candidates": 111 + } + }, + "2904852": { + "2014": { + "rank": 13, + "candidates": 103 + }, + "2018": { + "rank": 11, + "candidates": 144 + }, + "2022": { + "rank": 7, + "candidates": 210 + } + }, + "2904902": { + "2014": { + "rank": 8, + "candidates": 166 + }, + "2018": { + "rank": 13, + "candidates": 210 + }, + "2022": { + "rank": 14, + "candidates": 346 + } + }, + "2905008": { + "2014": { + "rank": 9, + "candidates": 104 + }, + "2018": { + "rank": 24, + "candidates": 150 + }, + "2022": { + "rank": 33, + "candidates": 175 + } + }, + "2905107": { + "2014": { + "rank": 25, + "candidates": 91 + }, + "2018": { + "rank": 10, + "candidates": 102 + }, + "2022": { + "rank": 46, + "candidates": 157 + } + }, + "2905156": { + "2018": { + "rank": 20, + "candidates": 89 + }, + "2022": { + "rank": 34, + "candidates": 125 + } + }, + "2905206": { + "2014": { + "rank": 16, + "candidates": 146 + }, + "2018": { + "rank": 15, + "candidates": 211 + }, + "2022": { + "rank": 23, + "candidates": 295 + } + }, + "2905305": { + "2014": { + "rank": 11, + "candidates": 90 + }, + "2018": { + "rank": 21, + "candidates": 124 + }, + "2022": { + "rank": 24, + "candidates": 178 + } + }, + "2905404": { + "2014": { + "rank": 15, + "candidates": 109 + }, + "2018": { + "rank": 39, + "candidates": 166 + }, + "2022": { + "rank": 35, + "candidates": 273 + } + }, + "2905503": { + "2014": { + "rank": 9, + "candidates": 79 + }, + "2018": { + "rank": 12, + "candidates": 90 + }, + "2022": { + "rank": 32, + "candidates": 140 + } + }, + "2905602": { + "2014": { + "rank": 14, + "candidates": 119 + }, + "2018": { + "rank": 4, + "candidates": 188 + }, + "2022": { + "rank": 10, + "candidates": 242 + } + }, + "2905701": { + "2014": { + "rank": 15, + "candidates": 250 + }, + "2018": { + "rank": 26, + "candidates": 376 + }, + "2022": { + "rank": 30, + "candidates": 582 + } + }, + "2905800": { + "2014": { + "rank": 22, + "candidates": 134 + }, + "2018": { + "rank": 24, + "candidates": 201 + }, + "2022": { + "rank": 24, + "candidates": 275 + } + }, + "2905909": { + "2014": { + "rank": 22, + "candidates": 85 + }, + "2018": { + "rank": 15, + "candidates": 96 + }, + "2022": { + "rank": 30, + "candidates": 174 + } + }, + "2906006": { + "2014": { + "rank": 24, + "candidates": 137 + }, + "2018": { + "rank": 28, + "candidates": 196 + }, + "2022": { + "rank": 30, + "candidates": 243 + } + }, + "2906105": { + "2014": { + "rank": 10, + "candidates": 58 + }, + "2018": { + "rank": 11, + "candidates": 86 + }, + "2022": { + "rank": 28, + "candidates": 122 + } + }, + "2906204": { + "2014": { + "rank": 12, + "candidates": 113 + }, + "2018": { + "rank": 20, + "candidates": 127 + }, + "2022": { + "rank": 31, + "candidates": 187 + } + }, + "2906303": { + "2014": { + "rank": 8, + "candidates": 138 + }, + "2018": { + "rank": 33, + "candidates": 205 + }, + "2022": { + "rank": 46, + "candidates": 284 + } + }, + "2906402": { + "2014": { + "rank": 20, + "candidates": 104 + }, + "2018": { + "rank": 22, + "candidates": 130 + }, + "2022": { + "rank": 24, + "candidates": 198 + } + }, + "2906501": { + "2014": { + "rank": 20, + "candidates": 186 + }, + "2018": { + "rank": 16, + "candidates": 276 + }, + "2022": { + "rank": 18, + "candidates": 443 + } + }, + "2906600": { + "2014": { + "rank": 11, + "candidates": 70 + }, + "2018": { + "rank": 13, + "candidates": 96 + }, + "2022": { + "rank": 21, + "candidates": 130 + } + }, + "2906709": { + "2018": { + "rank": 26, + "candidates": 146 + }, + "2022": { + "rank": 34, + "candidates": 193 + } + }, + "2906808": { + "2014": { + "rank": 33, + "candidates": 116 + }, + "2018": { + "rank": 36, + "candidates": 153 + }, + "2022": { + "rank": 32, + "candidates": 229 + } + }, + "2906824": { + "2018": { + "rank": 33, + "candidates": 126 + }, + "2022": { + "rank": 35, + "candidates": 169 + } + }, + "2906857": { + "2018": { + "rank": 18, + "candidates": 140 + }, + "2022": { + "rank": 28, + "candidates": 165 + } + }, + "2906873": { + "2014": { + "rank": 12, + "candidates": 129 + }, + "2018": { + "rank": 18, + "candidates": 195 + }, + "2022": { + "rank": 37, + "candidates": 253 + } + }, + "2906899": { + "2014": { + "rank": 8, + "candidates": 70 + }, + "2018": { + "rank": 10, + "candidates": 96 + }, + "2022": { + "rank": 15, + "candidates": 136 + } + }, + "2906907": { + "2018": { + "rank": 15, + "candidates": 147 + }, + "2022": { + "rank": 6, + "candidates": 220 + } + }, + "2907004": { + "2014": { + "rank": 7, + "candidates": 89 + }, + "2018": { + "rank": 17, + "candidates": 98 + }, + "2022": { + "rank": 15, + "candidates": 167 + } + }, + "2907103": { + "2014": { + "rank": 14, + "candidates": 104 + }, + "2018": { + "rank": 33, + "candidates": 131 + }, + "2022": { + "rank": 14, + "candidates": 180 + } + }, + "2907202": { + "2014": { + "rank": 35, + "candidates": 148 + }, + "2018": { + "rank": 29, + "candidates": 200 + }, + "2022": { + "rank": 36, + "candidates": 281 + } + }, + "2907301": { + "2014": { + "rank": 4, + "candidates": 124 + }, + "2018": { + "rank": 4, + "candidates": 188 + }, + "2022": { + "rank": 4, + "candidates": 293 + } + }, + "2907400": { + "2014": { + "rank": 11, + "candidates": 57 + }, + "2018": { + "rank": 15, + "candidates": 66 + } + }, + "2907509": { + "2014": { + "rank": 19, + "candidates": 170 + }, + "2018": { + "rank": 16, + "candidates": 265 + }, + "2022": { + "rank": 14, + "candidates": 353 + } + }, + "2907558": { + "2014": { + "rank": 5, + "candidates": 65 + }, + "2018": { + "rank": 4, + "candidates": 96 + }, + "2022": { + "rank": 11, + "candidates": 109 + } + }, + "2907608": { + "2014": { + "rank": 7, + "candidates": 97 + }, + "2018": { + "rank": 32, + "candidates": 130 + }, + "2022": { + "rank": 22, + "candidates": 197 + } + }, + "2907707": { + "2014": { + "rank": 15, + "candidates": 77 + }, + "2018": { + "rank": 12, + "candidates": 87 + }, + "2022": { + "rank": 16, + "candidates": 131 + } + }, + "2907806": { + "2014": { + "rank": 25, + "candidates": 129 + }, + "2018": { + "rank": 19, + "candidates": 177 + }, + "2022": { + "rank": 21, + "candidates": 228 + } + }, + "2907905": { + "2014": { + "rank": 10, + "candidates": 116 + }, + "2018": { + "rank": 17, + "candidates": 137 + }, + "2022": { + "rank": 22, + "candidates": 187 + } + }, + "2908002": { + "2014": { + "rank": 15, + "candidates": 128 + }, + "2018": { + "rank": 26, + "candidates": 172 + }, + "2022": { + "rank": 31, + "candidates": 213 + } + }, + "2908101": { + "2014": { + "rank": 33, + "candidates": 76 + }, + "2018": { + "rank": 36, + "candidates": 95 + }, + "2022": { + "rank": 32, + "candidates": 136 + } + }, + "2908200": { + "2014": { + "rank": 10, + "candidates": 146 + }, + "2018": { + "rank": 18, + "candidates": 181 + }, + "2022": { + "rank": 13, + "candidates": 238 + } + }, + "2908309": { + "2014": { + "rank": 17, + "candidates": 128 + }, + "2018": { + "rank": 16, + "candidates": 168 + }, + "2022": { + "rank": 6, + "candidates": 235 + } + }, + "2908408": { + "2014": { + "rank": 8, + "candidates": 161 + }, + "2018": { + "rank": 20, + "candidates": 250 + }, + "2022": { + "rank": 27, + "candidates": 340 + } + }, + "2908507": { + "2014": { + "rank": 16, + "candidates": 153 + }, + "2018": { + "rank": 16, + "candidates": 229 + }, + "2022": { + "rank": 29, + "candidates": 339 + } + }, + "2908606": { + "2014": { + "rank": 10, + "candidates": 119 + }, + "2018": { + "rank": 20, + "candidates": 182 + }, + "2022": { + "rank": 26, + "candidates": 244 + } + }, + "2908705": { + "2014": { + "rank": 3, + "candidates": 91 + }, + "2018": { + "rank": 8, + "candidates": 147 + }, + "2022": { + "rank": 4, + "candidates": 169 + } + }, + "2908804": { + "2018": { + "rank": 20, + "candidates": 80 + }, + "2022": { + "rank": 30, + "candidates": 111 + } + }, + "2908903": { + "2014": { + "rank": 3, + "candidates": 142 + }, + "2018": { + "rank": 13, + "candidates": 205 + }, + "2022": { + "rank": 11, + "candidates": 310 + } + }, + "2909000": { + "2014": { + "rank": 10, + "candidates": 56 + }, + "2018": { + "rank": 29, + "candidates": 96 + }, + "2022": { + "rank": 15, + "candidates": 106 + } + }, + "2909109": { + "2014": { + "rank": 23, + "candidates": 67 + }, + "2018": { + "rank": 16, + "candidates": 97 + }, + "2022": { + "rank": 15, + "candidates": 131 + } + }, + "2909208": { + "2018": { + "rank": 20, + "candidates": 103 + }, + "2022": { + "rank": 9, + "candidates": 134 + } + }, + "2909307": { + "2014": { + "rank": 28, + "candidates": 115 + }, + "2018": { + "rank": 4, + "candidates": 177 + }, + "2022": { + "rank": 4, + "candidates": 222 + } + }, + "2909406": { + "2014": { + "rank": 23, + "candidates": 85 + }, + "2018": { + "rank": 30, + "candidates": 103 + }, + "2022": { + "rank": 34, + "candidates": 138 + } + }, + "2909505": { + "2018": { + "rank": 3, + "candidates": 78 + }, + "2022": { + "rank": 4, + "candidates": 109 + } + }, + "2909604": { + "2014": { + "rank": 11, + "candidates": 93 + }, + "2018": { + "rank": 27, + "candidates": 111 + }, + "2022": { + "rank": 35, + "candidates": 161 + } + }, + "2909703": { + "2018": { + "rank": 28, + "candidates": 113 + }, + "2022": { + "rank": 28, + "candidates": 157 + } + }, + "2909802": { + "2014": { + "rank": 17, + "candidates": 176 + }, + "2018": { + "rank": 20, + "candidates": 247 + }, + "2022": { + "rank": 9, + "candidates": 365 + } + }, + "2909901": { + "2014": { + "rank": 21, + "candidates": 111 + }, + "2018": { + "rank": 38, + "candidates": 137 + }, + "2022": { + "rank": 45, + "candidates": 200 + } + }, + "2910008": { + "2014": { + "rank": 12, + "candidates": 77 + }, + "2018": { + "rank": 25, + "candidates": 115 + }, + "2022": { + "rank": 27, + "candidates": 140 + } + }, + "2910057": { + "2014": { + "rank": 13, + "candidates": 184 + }, + "2018": { + "rank": 14, + "candidates": 284 + }, + "2022": { + "rank": 13, + "candidates": 437 + } + }, + "2910107": { + "2014": { + "rank": 16, + "candidates": 86 + }, + "2018": { + "rank": 17, + "candidates": 100 + }, + "2022": { + "rank": 3, + "candidates": 143 + } + }, + "2910206": { + "2018": { + "rank": 28, + "candidates": 90 + }, + "2022": { + "rank": 1, + "candidates": 141 + } + }, + "2910305": { + "2014": { + "rank": 10, + "candidates": 94 + }, + "2018": { + "rank": 2, + "candidates": 120 + }, + "2022": { + "rank": 3, + "candidates": 157 + } + }, + "2910404": { + "2014": { + "rank": 6, + "candidates": 92 + }, + "2018": { + "rank": 17, + "candidates": 132 + }, + "2022": { + "rank": 31, + "candidates": 171 + } + }, + "2910503": { + "2014": { + "rank": 18, + "candidates": 148 + }, + "2018": { + "rank": 20, + "candidates": 230 + }, + "2022": { + "rank": 14, + "candidates": 335 + } + }, + "2910602": { + "2014": { + "rank": 16, + "candidates": 144 + }, + "2018": { + "rank": 26, + "candidates": 196 + }, + "2022": { + "rank": 31, + "candidates": 264 + } + }, + "2910701": { + "2014": { + "rank": 29, + "candidates": 134 + }, + "2018": { + "rank": 17, + "candidates": 203 + }, + "2022": { + "rank": 30, + "candidates": 287 + } + }, + "2910727": { + "2014": { + "rank": 18, + "candidates": 196 + }, + "2018": { + "rank": 23, + "candidates": 320 + }, + "2022": { + "rank": 23, + "candidates": 414 + } + }, + "2910750": { + "2014": { + "rank": 35, + "candidates": 103 + }, + "2018": { + "rank": 20, + "candidates": 137 + }, + "2022": { + "rank": 42, + "candidates": 173 + } + }, + "2910776": { + "2014": { + "rank": 13, + "candidates": 49 + }, + "2018": { + "rank": 11, + "candidates": 54 + }, + "2022": { + "rank": 31, + "candidates": 77 + } + }, + "2910800": { + "2014": { + "rank": 11, + "candidates": 263 + }, + "2018": { + "rank": 11, + "candidates": 421 + }, + "2022": { + "rank": 20, + "candidates": 597 + } + }, + "2910859": { + "2014": { + "rank": 16, + "candidates": 100 + }, + "2018": { + "rank": 2, + "candidates": 134 + }, + "2022": { + "rank": 18, + "candidates": 172 + } + }, + "2910909": { + "2014": { + "rank": 14, + "candidates": 69 + }, + "2018": { + "rank": 51, + "candidates": 101 + }, + "2022": { + "rank": 59, + "candidates": 122 + } + }, + "2911006": { + "2014": { + "rank": 9, + "candidates": 82 + }, + "2018": { + "rank": 27, + "candidates": 113 + }, + "2022": { + "rank": 22, + "candidates": 149 + } + }, + "2911105": { + "2014": { + "rank": 12, + "candidates": 91 + }, + "2018": { + "rank": 34, + "candidates": 138 + }, + "2022": { + "rank": 36, + "candidates": 183 + } + }, + "2911204": { + "2014": { + "rank": 11, + "candidates": 137 + }, + "2018": { + "rank": 22, + "candidates": 183 + }, + "2022": { + "rank": 13, + "candidates": 286 + } + }, + "2911253": { + "2014": { + "rank": 20, + "candidates": 82 + }, + "2018": { + "rank": 16, + "candidates": 99 + }, + "2022": { + "rank": 29, + "candidates": 118 + } + }, + "2911303": { + "2018": { + "rank": 25, + "candidates": 91 + }, + "2022": { + "rank": 33, + "candidates": 133 + } + }, + "2911402": { + "2014": { + "rank": 15, + "candidates": 71 + }, + "2018": { + "rank": 12, + "candidates": 111 + }, + "2022": { + "rank": 16, + "candidates": 145 + } + }, + "2911501": { + "2014": { + "rank": 12, + "candidates": 65 + }, + "2018": { + "rank": 21, + "candidates": 97 + }, + "2022": { + "rank": 14, + "candidates": 119 + } + }, + "2911600": { + "2014": { + "rank": 1, + "candidates": 128 + }, + "2018": { + "rank": 1, + "candidates": 194 + }, + "2022": { + "rank": 1, + "candidates": 236 + } + }, + "2911659": { + "2014": { + "rank": 19, + "candidates": 60 + }, + "2018": { + "rank": 20, + "candidates": 79 + }, + "2022": { + "rank": 14, + "candidates": 100 + } + }, + "2911709": { + "2014": { + "rank": 5, + "candidates": 168 + }, + "2018": { + "rank": 9, + "candidates": 227 + }, + "2022": { + "rank": 10, + "candidates": 295 + } + }, + "2911808": { + "2014": { + "rank": 18, + "candidates": 108 + }, + "2018": { + "rank": 23, + "candidates": 141 + }, + "2022": { + "rank": 23, + "candidates": 191 + } + }, + "2911857": { + "2018": { + "rank": 19, + "candidates": 125 + }, + "2022": { + "rank": 15, + "candidates": 137 + } + }, + "2911907": { + "2014": { + "rank": 21, + "candidates": 118 + }, + "2018": { + "rank": 12, + "candidates": 186 + }, + "2022": { + "rank": 2, + "candidates": 212 + } + }, + "2912004": { + "2014": { + "rank": 13, + "candidates": 73 + }, + "2018": { + "rank": 19, + "candidates": 103 + }, + "2022": { + "rank": 37, + "candidates": 128 + } + }, + "2912103": { + "2014": { + "rank": 17, + "candidates": 136 + }, + "2018": { + "rank": 24, + "candidates": 188 + }, + "2022": { + "rank": 25, + "candidates": 257 + } + }, + "2912202": { + "2014": { + "rank": 20, + "candidates": 105 + }, + "2018": { + "rank": 16, + "candidates": 147 + }, + "2022": { + "rank": 23, + "candidates": 193 + } + }, + "2912301": { + "2014": { + "rank": 11, + "candidates": 93 + }, + "2018": { + "rank": 39, + "candidates": 134 + }, + "2022": { + "rank": 22, + "candidates": 178 + } + }, + "2912400": { + "2014": { + "rank": 15, + "candidates": 94 + }, + "2018": { + "rank": 28, + "candidates": 122 + }, + "2022": { + "rank": 19, + "candidates": 188 + } + }, + "2912509": { + "2014": { + "rank": 10, + "candidates": 80 + }, + "2018": { + "rank": 33, + "candidates": 96 + }, + "2022": { + "rank": 29, + "candidates": 128 + } + }, + "2912608": { + "2014": { + "rank": 19, + "candidates": 55 + }, + "2018": { + "rank": 34, + "candidates": 66 + }, + "2022": { + "rank": 11, + "candidates": 97 + } + }, + "2912707": { + "2014": { + "rank": 26, + "candidates": 99 + }, + "2018": { + "rank": 15, + "candidates": 160 + }, + "2022": { + "rank": 20, + "candidates": 198 + } + }, + "2912806": { + "2014": { + "rank": 6, + "candidates": 70 + }, + "2018": { + "rank": 20, + "candidates": 97 + }, + "2022": { + "rank": 9, + "candidates": 148 + } + }, + "2912905": { + "2014": { + "rank": 17, + "candidates": 101 + }, + "2018": { + "rank": 5, + "candidates": 163 + }, + "2022": { + "rank": 17, + "candidates": 214 + } + }, + "2913002": { + "2018": { + "rank": 22, + "candidates": 107 + }, + "2022": { + "rank": 13, + "candidates": 148 + } + }, + "2913101": { + "2018": { + "rank": 21, + "candidates": 129 + }, + "2022": { + "rank": 23, + "candidates": 185 + } + }, + "2913200": { + "2014": { + "rank": 9, + "candidates": 115 + }, + "2018": { + "rank": 17, + "candidates": 152 + }, + "2022": { + "rank": 23, + "candidates": 218 + } + }, + "2913309": { + "2018": { + "rank": 29, + "candidates": 128 + }, + "2022": { + "rank": 36, + "candidates": 156 + } + }, + "2913408": { + "2018": { + "rank": 13, + "candidates": 129 + }, + "2022": { + "rank": 29, + "candidates": 154 + } + }, + "2913457": { + "2018": { + "rank": 15, + "candidates": 128 + }, + "2022": { + "rank": 13, + "candidates": 201 + } + }, + "2913507": { + "2014": { + "rank": 3, + "candidates": 109 + }, + "2018": { + "rank": 22, + "candidates": 148 + }, + "2022": { + "rank": 32, + "candidates": 204 + } + }, + "2913606": { + "2014": { + "rank": 13, + "candidates": 224 + }, + "2018": { + "rank": 11, + "candidates": 354 + }, + "2022": { + "rank": 12, + "candidates": 472 + } + }, + "2913705": { + "2014": { + "rank": 8, + "candidates": 136 + }, + "2018": { + "rank": 8, + "candidates": 197 + }, + "2022": { + "rank": 8, + "candidates": 294 + } + }, + "2913804": { + "2014": { + "rank": 32, + "candidates": 111 + }, + "2018": { + "rank": 8, + "candidates": 161 + }, + "2022": { + "rank": 17, + "candidates": 215 + } + }, + "2913903": { + "2014": { + "rank": 6, + "candidates": 149 + }, + "2018": { + "rank": 4, + "candidates": 234 + }, + "2022": { + "rank": 4, + "candidates": 255 + } + }, + "2914000": { + "2014": { + "rank": 18, + "candidates": 157 + }, + "2018": { + "rank": 19, + "candidates": 243 + }, + "2022": { + "rank": 13, + "candidates": 335 + } + }, + "2914109": { + "2014": { + "rank": 13, + "candidates": 64 + }, + "2018": { + "rank": 13, + "candidates": 82 + }, + "2022": { + "rank": 25, + "candidates": 119 + } + }, + "2914208": { + "2014": { + "rank": 9, + "candidates": 64 + }, + "2018": { + "rank": 8, + "candidates": 87 + }, + "2022": { + "rank": 11, + "candidates": 112 + } + }, + "2914307": { + "2018": { + "rank": 35, + "candidates": 115 + }, + "2022": { + "rank": 9, + "candidates": 143 + } + }, + "2914406": { + "2018": { + "rank": 5, + "candidates": 133 + }, + "2022": { + "rank": 6, + "candidates": 184 + } + }, + "2914505": { + "2014": { + "rank": 1, + "candidates": 152 + }, + "2018": { + "rank": 1, + "candidates": 208 + }, + "2022": { + "rank": 13, + "candidates": 306 + } + }, + "2914604": { + "2014": { + "rank": 11, + "candidates": 159 + }, + "2018": { + "rank": 13, + "candidates": 216 + }, + "2022": { + "rank": 13, + "candidates": 346 + } + }, + "2914653": { + "2014": { + "rank": 17, + "candidates": 116 + }, + "2018": { + "rank": 20, + "candidates": 172 + }, + "2022": { + "rank": 21, + "candidates": 249 + } + }, + "2914703": { + "2014": { + "rank": 6, + "candidates": 161 + }, + "2018": { + "rank": 5, + "candidates": 241 + }, + "2022": { + "rank": 3, + "candidates": 342 + } + }, + "2914802": { + "2014": { + "rank": 19, + "candidates": 230 + }, + "2018": { + "rank": 13, + "candidates": 350 + }, + "2022": { + "rank": 11, + "candidates": 475 + } + }, + "2914901": { + "2018": { + "rank": 17, + "candidates": 170 + }, + "2022": { + "rank": 4, + "candidates": 257 + } + }, + "2915007": { + "2018": { + "rank": 12, + "candidates": 116 + }, + "2022": { + "rank": 23, + "candidates": 158 + } + }, + "2915106": { + "2014": { + "rank": 8, + "candidates": 84 + }, + "2018": { + "rank": 6, + "candidates": 126 + }, + "2022": { + "rank": 23, + "candidates": 151 + } + }, + "2915205": { + "2014": { + "rank": 20, + "candidates": 98 + }, + "2018": { + "rank": 13, + "candidates": 127 + }, + "2022": { + "rank": 17, + "candidates": 169 + } + }, + "2915304": { + "2014": { + "rank": 24, + "candidates": 72 + }, + "2018": { + "rank": 18, + "candidates": 102 + }, + "2022": { + "rank": 23, + "candidates": 125 + } + }, + "2915353": { + "2014": { + "rank": 24, + "candidates": 76 + }, + "2018": { + "rank": 27, + "candidates": 105 + }, + "2022": { + "rank": 25, + "candidates": 163 + } + }, + "2915403": { + "2014": { + "rank": 29, + "candidates": 74 + }, + "2018": { + "rank": 32, + "candidates": 88 + }, + "2022": { + "rank": 26, + "candidates": 111 + } + }, + "2915502": { + "2014": { + "rank": 5, + "candidates": 133 + }, + "2018": { + "rank": 14, + "candidates": 181 + }, + "2022": { + "rank": 21, + "candidates": 240 + } + }, + "2915601": { + "2014": { + "rank": 13, + "candidates": 156 + }, + "2018": { + "rank": 19, + "candidates": 215 + }, + "2022": { + "rank": 10, + "candidates": 297 + } + }, + "2915700": { + "2014": { + "rank": 22, + "candidates": 70 + }, + "2018": { + "rank": 15, + "candidates": 107 + }, + "2022": { + "rank": 25, + "candidates": 130 + } + }, + "2915809": { + "2014": { + "rank": 13, + "candidates": 104 + }, + "2018": { + "rank": 10, + "candidates": 160 + }, + "2022": { + "rank": 37, + "candidates": 194 + } + }, + "2915908": { + "2014": { + "rank": 24, + "candidates": 74 + }, + "2018": { + "rank": 26, + "candidates": 116 + }, + "2022": { + "rank": 6, + "candidates": 174 + } + }, + "2916005": { + "2014": { + "rank": 9, + "candidates": 104 + }, + "2018": { + "rank": 17, + "candidates": 135 + }, + "2022": { + "rank": 19, + "candidates": 178 + } + }, + "2916104": { + "2014": { + "rank": 12, + "candidates": 145 + }, + "2018": { + "rank": 23, + "candidates": 218 + }, + "2022": { + "rank": 11, + "candidates": 309 + } + }, + "2916203": { + "2014": { + "rank": 18, + "candidates": 102 + }, + "2018": { + "rank": 18, + "candidates": 142 + }, + "2022": { + "rank": 40, + "candidates": 178 + } + }, + "2916302": { + "2014": { + "rank": 30, + "candidates": 92 + }, + "2018": { + "rank": 24, + "candidates": 118 + }, + "2022": { + "rank": 16, + "candidates": 174 + } + }, + "2916401": { + "2014": { + "rank": 22, + "candidates": 161 + }, + "2018": { + "rank": 2, + "candidates": 268 + }, + "2022": { + "rank": 34, + "candidates": 342 + } + }, + "2916500": { + "2014": { + "rank": 38, + "candidates": 118 + }, + "2018": { + "rank": 28, + "candidates": 158 + }, + "2022": { + "rank": 18, + "candidates": 181 + } + }, + "2916609": { + "2014": { + "rank": 34, + "candidates": 81 + }, + "2018": { + "rank": 21, + "candidates": 103 + }, + "2022": { + "rank": 31, + "candidates": 140 + } + }, + "2916708": { + "2014": { + "rank": 4, + "candidates": 73 + }, + "2018": { + "rank": 5, + "candidates": 101 + }, + "2022": { + "rank": 6, + "candidates": 134 + } + }, + "2916807": { + "2018": { + "rank": 5, + "candidates": 157 + }, + "2022": { + "rank": 32, + "candidates": 187 + } + }, + "2916856": { + "2014": { + "rank": 7, + "candidates": 94 + }, + "2018": { + "rank": 14, + "candidates": 125 + }, + "2022": { + "rank": 13, + "candidates": 195 + } + }, + "2916906": { + "2014": { + "rank": 1, + "candidates": 83 + }, + "2018": { + "rank": 1, + "candidates": 122 + }, + "2022": { + "rank": 1, + "candidates": 157 + } + }, + "2917003": { + "2014": { + "rank": 11, + "candidates": 132 + }, + "2018": { + "rank": 18, + "candidates": 180 + }, + "2022": { + "rank": 33, + "candidates": 234 + } + }, + "2917102": { + "2014": { + "rank": 23, + "candidates": 119 + }, + "2018": { + "rank": 15, + "candidates": 166 + }, + "2022": { + "rank": 38, + "candidates": 230 + } + }, + "2917201": { + "2014": { + "rank": 10, + "candidates": 95 + }, + "2018": { + "rank": 24, + "candidates": 138 + }, + "2022": { + "rank": 30, + "candidates": 161 + } + }, + "2917300": { + "2014": { + "rank": 24, + "candidates": 111 + }, + "2018": { + "rank": 27, + "candidates": 172 + }, + "2022": { + "rank": 33, + "candidates": 252 + } + }, + "2917334": { + "2018": { + "rank": 18, + "candidates": 86 + }, + "2022": { + "rank": 19, + "candidates": 91 + } + }, + "2917359": { + "2014": { + "rank": 19, + "candidates": 59 + }, + "2018": { + "rank": 11, + "candidates": 98 + }, + "2022": { + "rank": 12, + "candidates": 114 + } + }, + "2917409": { + "2014": { + "rank": 10, + "candidates": 76 + }, + "2018": { + "rank": 23, + "candidates": 111 + }, + "2022": { + "rank": 18, + "candidates": 149 + } + }, + "2917508": { + "2014": { + "rank": 19, + "candidates": 170 + }, + "2018": { + "rank": 2, + "candidates": 257 + }, + "2022": { + "rank": 31, + "candidates": 359 + } + }, + "2917607": { + "2014": { + "rank": 5, + "candidates": 137 + }, + "2018": { + "rank": 6, + "candidates": 198 + }, + "2022": { + "rank": 7, + "candidates": 285 + } + }, + "2917706": { + "2014": { + "rank": 31, + "candidates": 124 + }, + "2018": { + "rank": 10, + "candidates": 155 + }, + "2022": { + "rank": 28, + "candidates": 229 + } + }, + "2917805": { + "2014": { + "rank": 5, + "candidates": 116 + }, + "2018": { + "rank": 17, + "candidates": 152 + }, + "2022": { + "rank": 8, + "candidates": 250 + } + }, + "2917904": { + "2014": { + "rank": 14, + "candidates": 89 + }, + "2018": { + "rank": 27, + "candidates": 112 + }, + "2022": { + "rank": 46, + "candidates": 143 + } + }, + "2918001": { + "2014": { + "rank": 3, + "candidates": 181 + }, + "2018": { + "rank": 3, + "candidates": 320 + }, + "2022": { + "rank": 12, + "candidates": 406 + } + }, + "2918100": { + "2014": { + "rank": 23, + "candidates": 122 + }, + "2018": { + "rank": 26, + "candidates": 161 + }, + "2022": { + "rank": 20, + "candidates": 207 + } + }, + "2918209": { + "2018": { + "rank": 7, + "candidates": 128 + }, + "2022": { + "rank": 7, + "candidates": 190 + } + }, + "2918308": { + "2018": { + "rank": 3, + "candidates": 130 + }, + "2022": { + "rank": 9, + "candidates": 185 + } + }, + "2918357": { + "2014": { + "rank": 15, + "candidates": 104 + }, + "2018": { + "rank": 25, + "candidates": 140 + }, + "2022": { + "rank": 33, + "candidates": 190 + } + }, + "2918407": { + "2014": { + "rank": 30, + "candidates": 197 + }, + "2018": { + "rank": 30, + "candidates": 331 + }, + "2022": { + "rank": 27, + "candidates": 445 + } + }, + "2918456": { + "2014": { + "rank": 9, + "candidates": 62 + }, + "2018": { + "rank": 13, + "candidates": 84 + }, + "2022": { + "rank": 14, + "candidates": 128 + } + }, + "2918506": { + "2014": { + "rank": 26, + "candidates": 90 + }, + "2018": { + "rank": 12, + "candidates": 120 + }, + "2022": { + "rank": 19, + "candidates": 156 + } + }, + "2918555": { + "2018": { + "rank": 15, + "candidates": 92 + }, + "2022": { + "rank": 16, + "candidates": 127 + } + }, + "2918605": { + "2014": { + "rank": 14, + "candidates": 70 + }, + "2018": { + "rank": 17, + "candidates": 98 + }, + "2022": { + "rank": 28, + "candidates": 102 + } + }, + "2918704": { + "2018": { + "rank": 6, + "candidates": 84 + }, + "2022": { + "rank": 21, + "candidates": 89 + } + }, + "2918753": { + "2014": { + "rank": 23, + "candidates": 76 + }, + "2018": { + "rank": 22, + "candidates": 113 + }, + "2022": { + "rank": 21, + "candidates": 129 + } + }, + "2918803": { + "2014": { + "rank": 13, + "candidates": 118 + }, + "2018": { + "rank": 20, + "candidates": 170 + }, + "2022": { + "rank": 4, + "candidates": 222 + } + }, + "2918902": { + "2014": { + "rank": 15, + "candidates": 59 + }, + "2018": { + "rank": 11, + "candidates": 77 + }, + "2022": { + "rank": 6, + "candidates": 101 + } + }, + "2919009": { + "2014": { + "rank": 30, + "candidates": 70 + }, + "2018": { + "rank": 22, + "candidates": 70 + }, + "2022": { + "rank": 9, + "candidates": 93 + } + }, + "2919058": { + "2014": { + "rank": 11, + "candidates": 77 + }, + "2018": { + "rank": 10, + "candidates": 91 + }, + "2022": { + "rank": 8, + "candidates": 109 + } + }, + "2919108": { + "2014": { + "rank": 7, + "candidates": 94 + }, + "2018": { + "rank": 24, + "candidates": 113 + }, + "2022": { + "rank": 12, + "candidates": 172 + } + }, + "2919157": { + "2014": { + "rank": 19, + "candidates": 112 + }, + "2018": { + "rank": 20, + "candidates": 135 + }, + "2022": { + "rank": 29, + "candidates": 192 + } + }, + "2919207": { + "2014": { + "rank": 23, + "candidates": 253 + }, + "2018": { + "rank": 18, + "candidates": 388 + }, + "2022": { + "rank": 14, + "candidates": 579 + } + }, + "2919306": { + "2018": { + "rank": 16, + "candidates": 124 + }, + "2022": { + "rank": 7, + "candidates": 151 + } + }, + "2919405": { + "2018": { + "rank": 12, + "candidates": 112 + }, + "2022": { + "rank": 18, + "candidates": 118 + } + }, + "2919504": { + "2014": { + "rank": 16, + "candidates": 141 + }, + "2018": { + "rank": 8, + "candidates": 203 + }, + "2022": { + "rank": 14, + "candidates": 258 + } + }, + "2919553": { + "2014": { + "rank": 43, + "candidates": 159 + }, + "2018": { + "rank": 32, + "candidates": 248 + }, + "2022": { + "rank": 41, + "candidates": 376 + } + }, + "2919603": { + "2014": { + "rank": 13, + "candidates": 97 + }, + "2018": { + "rank": 18, + "candidates": 123 + }, + "2022": { + "rank": 13, + "candidates": 154 + } + }, + "2919702": { + "2014": { + "rank": 19, + "candidates": 93 + }, + "2018": { + "rank": 28, + "candidates": 137 + }, + "2022": { + "rank": 35, + "candidates": 180 + } + }, + "2919801": { + "2014": { + "rank": 7, + "candidates": 129 + }, + "2018": { + "rank": 9, + "candidates": 165 + }, + "2022": { + "rank": 11, + "candidates": 212 + } + }, + "2919900": { + "2014": { + "rank": 19, + "candidates": 77 + }, + "2018": { + "rank": 18, + "candidates": 79 + }, + "2022": { + "rank": 21, + "candidates": 105 + } + }, + "2919926": { + "2014": { + "rank": 11, + "candidates": 139 + }, + "2018": { + "rank": 25, + "candidates": 195 + }, + "2022": { + "rank": 10, + "candidates": 307 + } + }, + "2919959": { + "2018": { + "rank": 16, + "candidates": 77 + }, + "2022": { + "rank": 7, + "candidates": 109 + } + }, + "2920007": { + "2014": { + "rank": 6, + "candidates": 72 + }, + "2018": { + "rank": 17, + "candidates": 98 + }, + "2022": { + "rank": 32, + "candidates": 134 + } + }, + "2920106": { + "2014": { + "rank": 27, + "candidates": 115 + }, + "2018": { + "rank": 18, + "candidates": 157 + }, + "2022": { + "rank": 21, + "candidates": 185 + } + }, + "2920205": { + "2014": { + "rank": 16, + "candidates": 72 + }, + "2018": { + "rank": 11, + "candidates": 114 + }, + "2022": { + "rank": 10, + "candidates": 127 + } + }, + "2920304": { + "2014": { + "rank": 22, + "candidates": 71 + }, + "2018": { + "rank": 21, + "candidates": 87 + }, + "2022": { + "rank": 69, + "candidates": 103 + } + }, + "2920403": { + "2014": { + "rank": 5, + "candidates": 97 + }, + "2018": { + "rank": 5, + "candidates": 131 + }, + "2022": { + "rank": 22, + "candidates": 152 + } + }, + "2920452": { + "2014": { + "rank": 11, + "candidates": 59 + }, + "2018": { + "rank": 13, + "candidates": 92 + }, + "2022": { + "rank": 4, + "candidates": 117 + } + }, + "2920502": { + "2014": { + "rank": 15, + "candidates": 125 + }, + "2018": { + "rank": 20, + "candidates": 154 + }, + "2022": { + "rank": 17, + "candidates": 241 + } + }, + "2920601": { + "2018": { + "rank": 35, + "candidates": 207 + }, + "2022": { + "rank": 15, + "candidates": 325 + } + }, + "2920700": { + "2018": { + "rank": 5, + "candidates": 159 + }, + "2022": { + "rank": 4, + "candidates": 227 + } + }, + "2920809": { + "2014": { + "rank": 8, + "candidates": 94 + }, + "2018": { + "rank": 8, + "candidates": 109 + }, + "2022": { + "rank": 8, + "candidates": 144 + } + }, + "2920908": { + "2014": { + "rank": 26, + "candidates": 83 + }, + "2018": { + "rank": 6, + "candidates": 141 + }, + "2022": { + "rank": 7, + "candidates": 160 + } + }, + "2921005": { + "2014": { + "rank": 20, + "candidates": 153 + }, + "2018": { + "rank": 13, + "candidates": 280 + }, + "2022": { + "rank": 17, + "candidates": 389 + } + }, + "2921054": { + "2014": { + "rank": 5, + "candidates": 66 + }, + "2018": { + "rank": 5, + "candidates": 103 + }, + "2022": { + "rank": 16, + "candidates": 127 + } + }, + "2921104": { + "2014": { + "rank": 22, + "candidates": 89 + }, + "2018": { + "rank": 15, + "candidates": 136 + }, + "2022": { + "rank": 21, + "candidates": 194 + } + }, + "2921203": { + "2014": { + "rank": 13, + "candidates": 131 + }, + "2018": { + "rank": 12, + "candidates": 178 + }, + "2022": { + "rank": 22, + "candidates": 239 + } + }, + "2921302": { + "2014": { + "rank": 3, + "candidates": 100 + }, + "2018": { + "rank": 10, + "candidates": 120 + }, + "2022": { + "rank": 6, + "candidates": 162 + } + }, + "2921401": { + "2014": { + "rank": 13, + "candidates": 87 + }, + "2018": { + "rank": 14, + "candidates": 126 + }, + "2022": { + "rank": 42, + "candidates": 162 + } + }, + "2921450": { + "2014": { + "rank": 12, + "candidates": 80 + }, + "2018": { + "rank": 9, + "candidates": 85 + }, + "2022": { + "rank": 32, + "candidates": 116 + } + }, + "2921500": { + "2014": { + "rank": 28, + "candidates": 135 + }, + "2018": { + "rank": 18, + "candidates": 204 + }, + "2022": { + "rank": 16, + "candidates": 262 + } + }, + "2921609": { + "2014": { + "rank": 17, + "candidates": 66 + }, + "2018": { + "rank": 27, + "candidates": 92 + }, + "2022": { + "rank": 33, + "candidates": 134 + } + }, + "2921708": { + "2014": { + "rank": 26, + "candidates": 123 + }, + "2018": { + "rank": 27, + "candidates": 169 + }, + "2022": { + "rank": 30, + "candidates": 248 + } + }, + "2921807": { + "2014": { + "rank": 6, + "candidates": 59 + }, + "2018": { + "rank": 23, + "candidates": 91 + }, + "2022": { + "rank": 11, + "candidates": 127 + } + }, + "2921906": { + "2014": { + "rank": 11, + "candidates": 102 + }, + "2018": { + "rank": 5, + "candidates": 120 + }, + "2022": { + "rank": 22, + "candidates": 179 + } + }, + "2922003": { + "2014": { + "rank": 9, + "candidates": 133 + }, + "2018": { + "rank": 10, + "candidates": 184 + }, + "2022": { + "rank": 31, + "candidates": 283 + } + }, + "2922052": { + "2014": { + "rank": 1, + "candidates": 65 + }, + "2018": { + "rank": 2, + "candidates": 101 + }, + "2022": { + "rank": 6, + "candidates": 119 + } + }, + "2922102": { + "2014": { + "rank": 21, + "candidates": 111 + }, + "2018": { + "rank": 37, + "candidates": 162 + }, + "2022": { + "rank": 27, + "candidates": 200 + } + }, + "2922201": { + "2018": { + "rank": 18, + "candidates": 124 + }, + "2022": { + "rank": 19, + "candidates": 193 + } + }, + "2922250": { + "2018": { + "rank": 32, + "candidates": 97 + }, + "2022": { + "rank": 38, + "candidates": 144 + } + }, + "2922300": { + "2014": { + "rank": 16, + "candidates": 148 + }, + "2018": { + "rank": 10, + "candidates": 200 + }, + "2022": { + "rank": 10, + "candidates": 312 + } + }, + "2922409": { + "2014": { + "rank": 9, + "candidates": 122 + }, + "2018": { + "rank": 5, + "candidates": 174 + }, + "2022": { + "rank": 2, + "candidates": 219 + } + }, + "2922508": { + "2014": { + "rank": 5, + "candidates": 135 + }, + "2018": { + "rank": 9, + "candidates": 188 + }, + "2022": { + "rank": 6, + "candidates": 300 + } + }, + "2922607": { + "2014": { + "rank": 23, + "candidates": 90 + }, + "2018": { + "rank": 12, + "candidates": 122 + }, + "2022": { + "rank": 12, + "candidates": 183 + } + }, + "2922656": { + "2014": { + "rank": 23, + "candidates": 80 + }, + "2018": { + "rank": 29, + "candidates": 107 + }, + "2022": { + "rank": 32, + "candidates": 155 + } + }, + "2922706": { + "2014": { + "rank": 4, + "candidates": 99 + }, + "2018": { + "rank": 11, + "candidates": 131 + }, + "2022": { + "rank": 14, + "candidates": 166 + } + }, + "2922730": { + "2018": { + "rank": 3, + "candidates": 127 + }, + "2022": { + "rank": 7, + "candidates": 163 + } + }, + "2922755": { + "2014": { + "rank": 24, + "candidates": 82 + }, + "2018": { + "rank": 32, + "candidates": 100 + }, + "2022": { + "rank": 32, + "candidates": 129 + } + }, + "2922805": { + "2014": { + "rank": 19, + "candidates": 84 + }, + "2018": { + "rank": 4, + "candidates": 84 + }, + "2022": { + "rank": 4, + "candidates": 138 + } + }, + "2922854": { + "2014": { + "rank": 9, + "candidates": 71 + }, + "2018": { + "rank": 17, + "candidates": 97 + }, + "2022": { + "rank": 18, + "candidates": 139 + } + }, + "2922904": { + "2014": { + "rank": 30, + "candidates": 110 + }, + "2018": { + "rank": 21, + "candidates": 162 + }, + "2022": { + "rank": 28, + "candidates": 206 + } + }, + "2923001": { + "2014": { + "rank": 6, + "candidates": 123 + }, + "2018": { + "rank": 12, + "candidates": 175 + }, + "2022": { + "rank": 29, + "candidates": 243 + } + }, + "2923035": { + "2018": { + "rank": 26, + "candidates": 97 + }, + "2022": { + "rank": 33, + "candidates": 122 + } + }, + "2923050": { + "2014": { + "rank": 19, + "candidates": 68 + }, + "2018": { + "rank": 18, + "candidates": 87 + }, + "2022": { + "rank": 22, + "candidates": 98 + } + }, + "2923100": { + "2014": { + "rank": 28, + "candidates": 116 + }, + "2018": { + "rank": 36, + "candidates": 170 + }, + "2022": { + "rank": 27, + "candidates": 199 + } + }, + "2923209": { + "2014": { + "rank": 14, + "candidates": 106 + }, + "2018": { + "rank": 15, + "candidates": 130 + }, + "2022": { + "rank": 20, + "candidates": 195 + } + }, + "2923308": { + "2014": { + "rank": 7, + "candidates": 95 + }, + "2018": { + "rank": 14, + "candidates": 114 + }, + "2022": { + "rank": 18, + "candidates": 159 + } + }, + "2923357": { + "2014": { + "rank": 12, + "candidates": 91 + }, + "2018": { + "rank": 9, + "candidates": 128 + }, + "2022": { + "rank": 8, + "candidates": 181 + } + }, + "2923407": { + "2014": { + "rank": 12, + "candidates": 81 + }, + "2018": { + "rank": 12, + "candidates": 141 + }, + "2022": { + "rank": 13, + "candidates": 168 + } + }, + "2923506": { + "2014": { + "rank": 9, + "candidates": 98 + }, + "2018": { + "rank": 6, + "candidates": 120 + }, + "2022": { + "rank": 16, + "candidates": 171 + } + }, + "2923605": { + "2014": { + "rank": 4, + "candidates": 115 + }, + "2018": { + "rank": 23, + "candidates": 137 + }, + "2022": { + "rank": 23, + "candidates": 186 + } + }, + "2923704": { + "2014": { + "rank": 12, + "candidates": 109 + }, + "2018": { + "rank": 16, + "candidates": 128 + }, + "2022": { + "rank": 32, + "candidates": 160 + } + }, + "2923803": { + "2014": { + "rank": 15, + "candidates": 115 + }, + "2018": { + "rank": 17, + "candidates": 178 + }, + "2022": { + "rank": 19, + "candidates": 217 + } + }, + "2923902": { + "2014": { + "rank": 10, + "candidates": 82 + }, + "2018": { + "rank": 18, + "candidates": 123 + }, + "2022": { + "rank": 19, + "candidates": 156 + } + }, + "2924009": { + "2014": { + "rank": 17, + "candidates": 166 + }, + "2018": { + "rank": 15, + "candidates": 264 + }, + "2022": { + "rank": 18, + "candidates": 338 + } + }, + "2924058": { + "2014": { + "rank": 26, + "candidates": 94 + }, + "2018": { + "rank": 15, + "candidates": 142 + }, + "2022": { + "rank": 38, + "candidates": 186 + } + }, + "2924108": { + "2018": { + "rank": 11, + "candidates": 124 + }, + "2022": { + "rank": 42, + "candidates": 166 + } + }, + "2924207": { + "2014": { + "rank": 24, + "candidates": 65 + }, + "2018": { + "rank": 10, + "candidates": 102 + }, + "2022": { + "rank": 35, + "candidates": 119 + } + }, + "2924306": { + "2014": { + "rank": 14, + "candidates": 94 + }, + "2018": { + "rank": 21, + "candidates": 139 + }, + "2022": { + "rank": 5, + "candidates": 198 + } + }, + "2924405": { + "2014": { + "rank": 17, + "candidates": 101 + }, + "2018": { + "rank": 15, + "candidates": 131 + }, + "2022": { + "rank": 25, + "candidates": 190 + } + }, + "2924504": { + "2014": { + "rank": 13, + "candidates": 79 + }, + "2018": { + "rank": 14, + "candidates": 113 + }, + "2022": { + "rank": 24, + "candidates": 140 + } + }, + "2924603": { + "2018": { + "rank": 13, + "candidates": 134 + }, + "2022": { + "rank": 44, + "candidates": 184 + } + }, + "2924652": { + "2014": { + "rank": 20, + "candidates": 83 + }, + "2018": { + "rank": 17, + "candidates": 112 + }, + "2022": { + "rank": 14, + "candidates": 122 + } + }, + "2924678": { + "2018": { + "rank": 38, + "candidates": 123 + }, + "2022": { + "rank": 30, + "candidates": 139 + } + }, + "2924702": { + "2018": { + "rank": 37, + "candidates": 113 + }, + "2022": { + "rank": 35, + "candidates": 117 + } + }, + "2924801": { + "2014": { + "rank": 23, + "candidates": 116 + }, + "2018": { + "rank": 24, + "candidates": 158 + }, + "2022": { + "rank": 27, + "candidates": 206 + } + }, + "2924900": { + "2014": { + "rank": 10, + "candidates": 78 + }, + "2018": { + "rank": 11, + "candidates": 93 + }, + "2022": { + "rank": 16, + "candidates": 114 + } + }, + "2925006": { + "2014": { + "rank": 4, + "candidates": 112 + }, + "2018": { + "rank": 26, + "candidates": 141 + }, + "2022": { + "rank": 24, + "candidates": 193 + } + }, + "2925105": { + "2014": { + "rank": 6, + "candidates": 138 + }, + "2018": { + "rank": 18, + "candidates": 200 + }, + "2022": { + "rank": 23, + "candidates": 291 + } + }, + "2925204": { + "2014": { + "rank": 3, + "candidates": 156 + }, + "2018": { + "rank": 16, + "candidates": 230 + }, + "2022": { + "rank": 18, + "candidates": 343 + } + }, + "2925253": { + "2018": { + "rank": 11, + "candidates": 125 + }, + "2022": { + "rank": 17, + "candidates": 140 + } + }, + "2925303": { + "2014": { + "rank": 20, + "candidates": 207 + }, + "2018": { + "rank": 15, + "candidates": 321 + }, + "2022": { + "rank": 21, + "candidates": 457 + } + }, + "2925402": { + "2018": { + "rank": 40, + "candidates": 125 + }, + "2022": { + "rank": 43, + "candidates": 168 + } + }, + "2925501": { + "2014": { + "rank": 21, + "candidates": 107 + }, + "2018": { + "rank": 21, + "candidates": 178 + }, + "2022": { + "rank": 27, + "candidates": 241 + } + }, + "2925600": { + "2018": { + "rank": 19, + "candidates": 142 + }, + "2022": { + "rank": 26, + "candidates": 168 + } + }, + "2925709": { + "2014": { + "rank": 21, + "candidates": 78 + }, + "2018": { + "rank": 21, + "candidates": 110 + }, + "2022": { + "rank": 15, + "candidates": 141 + } + }, + "2925758": { + "2018": { + "rank": 24, + "candidates": 164 + }, + "2022": { + "rank": 20, + "candidates": 240 + } + }, + "2925808": { + "2014": { + "rank": 29, + "candidates": 127 + }, + "2018": { + "rank": 23, + "candidates": 177 + }, + "2022": { + "rank": 13, + "candidates": 220 + } + }, + "2925907": { + "2014": { + "rank": 22, + "candidates": 110 + }, + "2018": { + "rank": 16, + "candidates": 141 + }, + "2022": { + "rank": 21, + "candidates": 199 + } + }, + "2925931": { + "2014": { + "rank": 8, + "candidates": 86 + }, + "2018": { + "rank": 19, + "candidates": 105 + }, + "2022": { + "rank": 22, + "candidates": 134 + } + }, + "2925956": { + "2014": { + "rank": 6, + "candidates": 111 + }, + "2018": { + "rank": 12, + "candidates": 163 + }, + "2022": { + "rank": 8, + "candidates": 242 + } + }, + "2926004": { + "2014": { + "rank": 17, + "candidates": 119 + }, + "2018": { + "rank": 7, + "candidates": 164 + }, + "2022": { + "rank": 19, + "candidates": 215 + } + }, + "2926103": { + "2018": { + "rank": 6, + "candidates": 137 + }, + "2022": { + "rank": 10, + "candidates": 185 + } + }, + "2926202": { + "2014": { + "rank": 16, + "candidates": 108 + }, + "2018": { + "rank": 11, + "candidates": 135 + }, + "2022": { + "rank": 34, + "candidates": 200 + } + }, + "2926301": { + "2014": { + "rank": 16, + "candidates": 152 + }, + "2018": { + "rank": 21, + "candidates": 212 + }, + "2022": { + "rank": 19, + "candidates": 288 + } + }, + "2926400": { + "2014": { + "rank": 16, + "candidates": 97 + }, + "2018": { + "rank": 15, + "candidates": 153 + }, + "2022": { + "rank": 8, + "candidates": 193 + } + }, + "2926509": { + "2014": { + "rank": 16, + "candidates": 91 + }, + "2018": { + "rank": 15, + "candidates": 116 + }, + "2022": { + "rank": 42, + "candidates": 158 + } + }, + "2926608": { + "2014": { + "rank": 7, + "candidates": 152 + }, + "2018": { + "rank": 6, + "candidates": 223 + }, + "2022": { + "rank": 9, + "candidates": 228 + } + }, + "2926657": { + "2014": { + "rank": 12, + "candidates": 81 + }, + "2018": { + "rank": 17, + "candidates": 104 + }, + "2022": { + "rank": 25, + "candidates": 139 + } + }, + "2926707": { + "2014": { + "rank": 16, + "candidates": 95 + }, + "2018": { + "rank": 8, + "candidates": 132 + }, + "2022": { + "rank": 12, + "candidates": 159 + } + }, + "2926806": { + "2014": { + "rank": 7, + "candidates": 84 + }, + "2018": { + "rank": 14, + "candidates": 111 + }, + "2022": { + "rank": 12, + "candidates": 154 + } + }, + "2926905": { + "2014": { + "rank": 7, + "candidates": 75 + }, + "2018": { + "rank": 11, + "candidates": 85 + }, + "2022": { + "rank": 6, + "candidates": 112 + } + }, + "2927002": { + "2014": { + "rank": 23, + "candidates": 121 + }, + "2018": { + "rank": 19, + "candidates": 173 + }, + "2022": { + "rank": 28, + "candidates": 257 + } + }, + "2927101": { + "2014": { + "rank": 9, + "candidates": 64 + }, + "2018": { + "rank": 8, + "candidates": 81 + }, + "2022": { + "rank": 41, + "candidates": 90 + } + }, + "2927200": { + "2014": { + "rank": 25, + "candidates": 144 + }, + "2018": { + "rank": 26, + "candidates": 187 + }, + "2022": { + "rank": 25, + "candidates": 257 + } + }, + "2927309": { + "2014": { + "rank": 18, + "candidates": 106 + }, + "2018": { + "rank": 15, + "candidates": 155 + }, + "2022": { + "rank": 7, + "candidates": 284 + } + }, + "2927408": { + "2014": { + "rank": 9, + "candidates": 287 + }, + "2018": { + "rank": 10, + "candidates": 445 + }, + "2022": { + "rank": 12, + "candidates": 663 + } + }, + "2927507": { + "2014": { + "rank": 19, + "candidates": 135 + }, + "2018": { + "rank": 1, + "candidates": 189 + }, + "2022": { + "rank": 3, + "candidates": 291 + } + }, + "2927606": { + "2018": { + "rank": 15, + "candidates": 98 + }, + "2022": { + "rank": 19, + "candidates": 153 + } + }, + "2927705": { + "2018": { + "rank": 23, + "candidates": 194 + }, + "2022": { + "rank": 25, + "candidates": 255 + } + }, + "2927804": { + "2014": { + "rank": 43, + "candidates": 67 + }, + "2018": { + "rank": 39, + "candidates": 92 + }, + "2022": { + "rank": 26, + "candidates": 122 + } + }, + "2927903": { + "2018": { + "rank": 1, + "candidates": 112 + }, + "2022": { + "rank": 1, + "candidates": 146 + } + }, + "2928000": { + "2014": { + "rank": 54, + "candidates": 143 + }, + "2018": { + "rank": 30, + "candidates": 204 + }, + "2022": { + "rank": 27, + "candidates": 289 + } + }, + "2928059": { + "2014": { + "rank": 16, + "candidates": 90 + }, + "2018": { + "rank": 22, + "candidates": 110 + }, + "2022": { + "rank": 27, + "candidates": 165 + } + }, + "2928109": { + "2014": { + "rank": 20, + "candidates": 131 + }, + "2018": { + "rank": 18, + "candidates": 162 + }, + "2022": { + "rank": 12, + "candidates": 249 + } + }, + "2928208": { + "2014": { + "rank": 23, + "candidates": 88 + }, + "2018": { + "rank": 27, + "candidates": 132 + }, + "2022": { + "rank": 25, + "candidates": 197 + } + }, + "2928307": { + "2014": { + "rank": 13, + "candidates": 106 + }, + "2018": { + "rank": 11, + "candidates": 134 + }, + "2022": { + "rank": 17, + "candidates": 203 + } + }, + "2928406": { + "2014": { + "rank": 11, + "candidates": 110 + }, + "2018": { + "rank": 21, + "candidates": 147 + }, + "2022": { + "rank": 24, + "candidates": 201 + } + }, + "2928505": { + "2014": { + "rank": 12, + "candidates": 103 + }, + "2018": { + "rank": 13, + "candidates": 137 + }, + "2022": { + "rank": 8, + "candidates": 179 + } + }, + "2928604": { + "2014": { + "rank": 13, + "candidates": 195 + }, + "2018": { + "rank": 11, + "candidates": 270 + }, + "2022": { + "rank": 10, + "candidates": 417 + } + }, + "2928703": { + "2014": { + "rank": 4, + "candidates": 201 + }, + "2018": { + "rank": 17, + "candidates": 301 + }, + "2022": { + "rank": 19, + "candidates": 435 + } + }, + "2928802": { + "2014": { + "rank": 26, + "candidates": 149 + }, + "2018": { + "rank": 21, + "candidates": 240 + }, + "2022": { + "rank": 11, + "candidates": 327 + } + }, + "2928901": { + "2014": { + "rank": 23, + "candidates": 113 + }, + "2018": { + "rank": 20, + "candidates": 152 + }, + "2022": { + "rank": 21, + "candidates": 204 + } + }, + "2928950": { + "2014": { + "rank": 5, + "candidates": 82 + }, + "2018": { + "rank": 15, + "candidates": 102 + }, + "2022": { + "rank": 37, + "candidates": 123 + } + }, + "2929008": { + "2014": { + "rank": 15, + "candidates": 114 + }, + "2018": { + "rank": 13, + "candidates": 154 + }, + "2022": { + "rank": 19, + "candidates": 230 + } + }, + "2929057": { + "2014": { + "rank": 31, + "candidates": 83 + }, + "2018": { + "rank": 5, + "candidates": 124 + }, + "2022": { + "rank": 5, + "candidates": 177 + } + }, + "2929107": { + "2014": { + "rank": 8, + "candidates": 138 + }, + "2018": { + "rank": 14, + "candidates": 161 + }, + "2022": { + "rank": 4, + "candidates": 251 + } + }, + "2929206": { + "2014": { + "rank": 1, + "candidates": 174 + }, + "2018": { + "rank": 2, + "candidates": 227 + }, + "2022": { + "rank": 6, + "candidates": 368 + } + }, + "2929255": { + "2014": { + "rank": 16, + "candidates": 96 + }, + "2018": { + "rank": 19, + "candidates": 125 + }, + "2022": { + "rank": 26, + "candidates": 195 + } + }, + "2929305": { + "2014": { + "rank": 5, + "candidates": 166 + }, + "2018": { + "rank": 5, + "candidates": 229 + }, + "2022": { + "rank": 8, + "candidates": 363 + } + }, + "2929354": { + "2014": { + "rank": 17, + "candidates": 78 + }, + "2018": { + "rank": 12, + "candidates": 107 + }, + "2022": { + "rank": 15, + "candidates": 123 + } + }, + "2929370": { + "2014": { + "rank": 7, + "candidates": 88 + }, + "2018": { + "rank": 11, + "candidates": 113 + }, + "2022": { + "rank": 14, + "candidates": 160 + } + }, + "2929404": { + "2014": { + "rank": 12, + "candidates": 101 + }, + "2018": { + "rank": 14, + "candidates": 120 + }, + "2022": { + "rank": 4, + "candidates": 159 + } + }, + "2929503": { + "2014": { + "rank": 3, + "candidates": 173 + }, + "2018": { + "rank": 8, + "candidates": 231 + }, + "2022": { + "rank": 16, + "candidates": 356 + } + }, + "2929602": { + "2018": { + "rank": 15, + "candidates": 159 + }, + "2022": { + "rank": 4, + "candidates": 237 + } + }, + "2929701": { + "2014": { + "rank": 32, + "candidates": 106 + }, + "2018": { + "rank": 13, + "candidates": 132 + }, + "2022": { + "rank": 28, + "candidates": 170 + } + }, + "2929750": { + "2014": { + "rank": 16, + "candidates": 111 + }, + "2018": { + "rank": 20, + "candidates": 148 + }, + "2022": { + "rank": 25, + "candidates": 257 + } + }, + "2929800": { + "2014": { + "rank": 16, + "candidates": 89 + }, + "2018": { + "rank": 15, + "candidates": 117 + }, + "2022": { + "rank": 32, + "candidates": 144 + } + }, + "2929909": { + "2014": { + "rank": 3, + "candidates": 135 + }, + "2018": { + "rank": 3, + "candidates": 200 + }, + "2022": { + "rank": 2, + "candidates": 282 + } + }, + "2930006": { + "2014": { + "rank": 7, + "candidates": 55 + }, + "2018": { + "rank": 21, + "candidates": 70 + }, + "2022": { + "rank": 18, + "candidates": 88 + } + }, + "2930105": { + "2014": { + "rank": 12, + "candidates": 176 + }, + "2018": { + "rank": 20, + "candidates": 252 + }, + "2022": { + "rank": 21, + "candidates": 356 + } + }, + "2930154": { + "2014": { + "rank": 6, + "candidates": 101 + }, + "2018": { + "rank": 10, + "candidates": 151 + }, + "2022": { + "rank": 18, + "candidates": 193 + } + }, + "2930204": { + "2014": { + "rank": 28, + "candidates": 113 + }, + "2018": { + "rank": 24, + "candidates": 154 + }, + "2022": { + "rank": 29, + "candidates": 219 + } + }, + "2930303": { + "2014": { + "rank": 14, + "candidates": 84 + }, + "2018": { + "rank": 28, + "candidates": 107 + }, + "2022": { + "rank": 33, + "candidates": 152 + } + }, + "2930402": { + "2022": { + "rank": 12, + "candidates": 254 + } + }, + "2930501": { + "2014": { + "rank": 19, + "candidates": 182 + }, + "2018": { + "rank": 19, + "candidates": 279 + }, + "2022": { + "rank": 25, + "candidates": 395 + } + }, + "2930600": { + "2018": { + "rank": 17, + "candidates": 122 + }, + "2022": { + "rank": 26, + "candidates": 148 + } + }, + "2930709": { + "2014": { + "rank": 21, + "candidates": 223 + }, + "2018": { + "rank": 21, + "candidates": 334 + }, + "2022": { + "rank": 12, + "candidates": 505 + } + }, + "2930758": { + "2014": { + "rank": 14, + "candidates": 71 + }, + "2018": { + "rank": 27, + "candidates": 107 + }, + "2022": { + "rank": 12, + "candidates": 123 + } + }, + "2930766": { + "2014": { + "rank": 34, + "candidates": 88 + }, + "2018": { + "rank": 15, + "candidates": 89 + }, + "2022": { + "rank": 20, + "candidates": 139 + } + }, + "2930774": { + "2014": { + "rank": 30, + "candidates": 119 + }, + "2018": { + "rank": 18, + "candidates": 146 + }, + "2022": { + "rank": 20, + "candidates": 198 + } + }, + "2930808": { + "2014": { + "rank": 16, + "candidates": 71 + }, + "2018": { + "rank": 9, + "candidates": 95 + }, + "2022": { + "rank": 10, + "candidates": 147 + } + }, + "2930907": { + "2014": { + "rank": 13, + "candidates": 78 + }, + "2018": { + "rank": 12, + "candidates": 99 + }, + "2022": { + "rank": 35, + "candidates": 131 + } + }, + "2931004": { + "2018": { + "rank": 33, + "candidates": 144 + }, + "2022": { + "rank": 44, + "candidates": 188 + } + }, + "2931053": { + "2018": { + "rank": 27, + "candidates": 102 + }, + "2022": { + "rank": 26, + "candidates": 131 + } + }, + "2931103": { + "2018": { + "rank": 7, + "candidates": 143 + }, + "2022": { + "rank": 23, + "candidates": 166 + } + }, + "2931202": { + "2014": { + "rank": 18, + "candidates": 111 + }, + "2018": { + "rank": 18, + "candidates": 144 + }, + "2022": { + "rank": 29, + "candidates": 226 + } + }, + "2931301": { + "2014": { + "rank": 26, + "candidates": 91 + }, + "2018": { + "rank": 31, + "candidates": 124 + }, + "2022": { + "rank": 37, + "candidates": 169 + } + }, + "2931350": { + "2014": { + "rank": 9, + "candidates": 197 + }, + "2018": { + "rank": 7, + "candidates": 284 + }, + "2022": { + "rank": 25, + "candidates": 433 + } + }, + "2931400": { + "2018": { + "rank": 10, + "candidates": 123 + }, + "2022": { + "rank": 12, + "candidates": 200 + } + }, + "2931509": { + "2014": { + "rank": 7, + "candidates": 116 + }, + "2018": { + "rank": 10, + "candidates": 167 + }, + "2022": { + "rank": 15, + "candidates": 209 + } + }, + "2931608": { + "2018": { + "rank": 27, + "candidates": 120 + }, + "2022": { + "rank": 29, + "candidates": 186 + } + }, + "2931707": { + "2018": { + "rank": 19, + "candidates": 154 + }, + "2022": { + "rank": 6, + "candidates": 254 + } + }, + "2931806": { + "2014": { + "rank": 7, + "candidates": 98 + }, + "2018": { + "rank": 17, + "candidates": 143 + }, + "2022": { + "rank": 25, + "candidates": 166 + } + }, + "2931905": { + "2014": { + "rank": 6, + "candidates": 150 + }, + "2018": { + "rank": 14, + "candidates": 212 + }, + "2022": { + "rank": 33, + "candidates": 246 + } + }, + "2932002": { + "2014": { + "rank": 24, + "candidates": 108 + }, + "2018": { + "rank": 31, + "candidates": 145 + }, + "2022": { + "rank": 28, + "candidates": 225 + } + }, + "2932101": { + "2014": { + "rank": 8, + "candidates": 114 + }, + "2018": { + "rank": 9, + "candidates": 158 + }, + "2022": { + "rank": 3, + "candidates": 238 + } + }, + "2932200": { + "2014": { + "rank": 7, + "candidates": 116 + }, + "2018": { + "rank": 2, + "candidates": 189 + }, + "2022": { + "rank": 3, + "candidates": 219 + } + }, + "2932309": { + "2014": { + "rank": 15, + "candidates": 116 + }, + "2018": { + "rank": 15, + "candidates": 157 + }, + "2022": { + "rank": 16, + "candidates": 208 + } + }, + "2932408": { + "2014": { + "rank": 7, + "candidates": 93 + }, + "2018": { + "rank": 21, + "candidates": 138 + }, + "2022": { + "rank": 28, + "candidates": 178 + } + }, + "2932457": { + "2014": { + "rank": 34, + "candidates": 86 + }, + "2018": { + "rank": 14, + "candidates": 117 + }, + "2022": { + "rank": 22, + "candidates": 154 + } + }, + "2932507": { + "2014": { + "rank": 7, + "candidates": 111 + }, + "2018": { + "rank": 25, + "candidates": 179 + }, + "2022": { + "rank": 27, + "candidates": 206 + } + }, + "2932606": { + "2014": { + "rank": 12, + "candidates": 78 + }, + "2018": { + "rank": 23, + "candidates": 120 + }, + "2022": { + "rank": 20, + "candidates": 127 + } + }, + "2932705": { + "2014": { + "rank": 16, + "candidates": 124 + }, + "2018": { + "rank": 16, + "candidates": 203 + }, + "2022": { + "rank": 16, + "candidates": 259 + } + }, + "2932804": { + "2014": { + "rank": 20, + "candidates": 110 + }, + "2018": { + "rank": 5, + "candidates": 145 + }, + "2022": { + "rank": 12, + "candidates": 188 + } + }, + "2932903": { + "2014": { + "rank": 11, + "candidates": 184 + }, + "2018": { + "rank": 22, + "candidates": 279 + }, + "2022": { + "rank": 16, + "candidates": 420 + } + }, + "2933000": { + "2014": { + "rank": 10, + "candidates": 137 + }, + "2018": { + "rank": 8, + "candidates": 170 + }, + "2022": { + "rank": 28, + "candidates": 236 + } + }, + "2933059": { + "2014": { + "rank": 18, + "candidates": 87 + }, + "2018": { + "rank": 26, + "candidates": 128 + }, + "2022": { + "rank": 14, + "candidates": 173 + } + }, + "2933109": { + "2018": { + "rank": 17, + "candidates": 106 + }, + "2022": { + "rank": 21, + "candidates": 128 + } + }, + "2933158": { + "2014": { + "rank": 14, + "candidates": 82 + }, + "2018": { + "rank": 9, + "candidates": 125 + }, + "2022": { + "rank": 22, + "candidates": 155 + } + }, + "2933174": { + "2014": { + "rank": 10, + "candidates": 92 + }, + "2018": { + "rank": 23, + "candidates": 127 + }, + "2022": { + "rank": 18, + "candidates": 166 + } + }, + "2933208": { + "2014": { + "rank": 17, + "candidates": 175 + }, + "2018": { + "rank": 21, + "candidates": 273 + }, + "2022": { + "rank": 4, + "candidates": 420 + } + }, + "2933257": { + "2014": { + "rank": 7, + "candidates": 73 + }, + "2018": { + "rank": 4, + "candidates": 79 + }, + "2022": { + "rank": 11, + "candidates": 114 + } + }, + "2933307": { + "2014": { + "rank": 4, + "candidates": 242 + }, + "2018": { + "rank": 4, + "candidates": 380 + }, + "2022": { + "rank": 6, + "candidates": 536 + } + }, + "2933406": { + "2014": { + "rank": 39, + "candidates": 73 + }, + "2018": { + "rank": 16, + "candidates": 111 + }, + "2022": { + "rank": 10, + "candidates": 143 + } + }, + "2933455": { + "2014": { + "rank": 17, + "candidates": 89 + }, + "2018": { + "rank": 10, + "candidates": 105 + }, + "2022": { + "rank": 9, + "candidates": 133 + } + }, + "2933505": { + "2018": { + "rank": 17, + "candidates": 151 + }, + "2022": { + "rank": 9, + "candidates": 210 + } + }, + "2933604": { + "2014": { + "rank": 30, + "candidates": 123 + }, + "2018": { + "rank": 32, + "candidates": 196 + }, + "2022": { + "rank": 21, + "candidates": 274 + } + } } } diff --git a/src/lib/mapScaleClasses.ts b/src/lib/mapScaleClasses.ts new file mode 100644 index 00000000..965c7698 --- /dev/null +++ b/src/lib/mapScaleClasses.ts @@ -0,0 +1,201 @@ +import { discreteChoroplethFill } from '@/lib/choroplethColorScale' +import { formatElectionNumber } from '@/lib/electionFormat' +import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors' + +/** + * B13 — relative map scales: the pure statistics behind the choropleth's + * discrete classes, with no Leaflet and no data access, so the map and its + * legend read the same source and a unit test can pin every cut. + * + * Why relative at all: "% dos válidos" (B11) is honest and useless here — a + * federal-deputy candidate tops out around 5% of a município's valid votes, + * so a 0–100% ramp paints Bahia one colour. Every scale below is measured + * against the candidate himself (his own distribution, his own statewide + * standard, his own placement), which is what discriminates. + */ + +type MapScaleClass = { + /** Fill painted on the polygon and swatched in the legend. */ + fill: string + /** pt-BR range label — "13–48", "2× ou mais", "2º–3º". */ + label: string +} + +export type MapScaleClassing = { + /** Weakest first, so every legend reads left-to-right the same way. */ + classes: MapScaleClass[] + /** Feature key → class index. Keys without a class are absent, never 0. */ + classIndexByKey: Record + /** + * True when the set was too small to cut into the full number of classes — + * the legend says so instead of implying a five-way split that isn't there. + */ + reduced: boolean +} + +const EMPTY_CLASSING: MapScaleClassing = { classes: [], classIndexByKey: {}, reduced: false } + +const buildClassing = ( + ranges: ReadonlyArray<{ label: string }>, + classIndexByKey: Record, + reduced: boolean, +): MapScaleClassing => ({ + classes: ranges.map((range, index) => ({ + label: range.label, + fill: discreteChoroplethFill(index, ranges.length), + })), + classIndexByKey, + reduced, +}) + +/** Full quantile split; below this many features the split degrades. */ +export const QUANTILE_CLASSES = 5 + +/** + * An advisor's portfolio can be a handful of municípios. Cutting eight values + * into five "quantiles" invents precision, so the split drops to three. + */ +export const QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT = 10 +const QUANTILE_REDUCED_CLASSES = 3 + +/** + * Quantile classes over the positive values of the rendered set (Brewer & + * Pickle: quantiles discriminate by construction and stay comparable across + * years, unlike Jenks, whose breaks move with the data). + * + * Labels carry the real extremes of each class rather than the computed + * break, so no gap is ever implied between "até 12" and "13–48". + */ +export const buildQuantileClassing = (values: Record): MapScaleClassing => { + const entries = Object.entries(values).filter(([, value]) => value > 0) + if (entries.length === 0) return EMPTY_CLASSING + + const sorted = entries.map(([, value]) => value).sort((left, right) => left - right) + const distinct = new Set(sorted).size + const desired = + sorted.length < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT + ? QUANTILE_REDUCED_CLASSES + : QUANTILE_CLASSES + const count = Math.min(desired, distinct) + + // Upper bound of each quantile, de-duplicated: heavy ties (many municípios + // on the same vote count) can collapse two quantiles into one bound, and a + // repeated bound would render as an empty class in the legend. + const upperBounds: number[] = [] + for (let index = 1; index <= count; index += 1) { + const position = Math.ceil((index * sorted.length) / count) - 1 + const bound = sorted[position] + if (upperBounds.at(-1) !== bound) upperBounds.push(bound) + } + + const extremes = upperBounds.map(() => ({ min: Number.POSITIVE_INFINITY, max: 0 })) + const classIndexByKey: Record = {} + for (const [key, value] of entries) { + const index = upperBounds.findIndex((bound) => value <= bound) + const resolved = index === -1 ? upperBounds.length - 1 : index + classIndexByKey[key] = resolved + extremes[resolved].min = Math.min(extremes[resolved].min, value) + extremes[resolved].max = Math.max(extremes[resolved].max, value) + } + + const ranges = extremes.map(({ min, max }) => ({ + label: + min === max + ? formatElectionNumber(min) + : `${formatElectionNumber(min)}–${formatElectionNumber(max)}`, + })) + + return buildClassing(ranges, classIndexByKey, upperBounds.length < QUANTILE_CLASSES) +} + +/** + * Two decimals, not one: the "no padrão" band starts at 0,95× and rounding + * that to "1,0×" would print the same number on two adjacent legend swatches. + */ +const lqMultipleFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }) + +const formatLqMultiple = (value: number): string => `${lqMultipleFormatter.format(value)}×` + +/** + * Location-quotient classes cut on the SAME anchors as E10's territorial + * class, so the map and the list's "Classe" column can never disagree about + * where "reduto" starts. + * + * Deliberately NOT the diverging red↔blue scale: in this product that palette + * means "Solla × adversário" (the compare mode), and reusing it for "above × + * below his own standard" would make two different questions look identical. + */ +const LQ_CLASS_RANGES = [ + { + upperBound: TERRITORIAL_CLASS_ANCHORS.weakLq, + label: `menos de ${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.weakLq)}`, + }, + { + upperBound: AT_STANDARD_LQ.min, + label: `${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.weakLq)}–${formatLqMultiple(AT_STANDARD_LQ.min)}`, + }, + { upperBound: AT_STANDARD_LQ.max, label: 'no padrão (≈1×)' }, + { + upperBound: TERRITORIAL_CLASS_ANCHORS.strongLq, + label: `${formatLqMultiple(AT_STANDARD_LQ.max)}–${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.strongLq)}`, + }, + { + upperBound: Number.POSITIVE_INFINITY, + label: `${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.strongLq)} ou mais`, + }, +] as const + +export const buildLqClassing = (lqByKey: Record): MapScaleClassing => { + const classIndexByKey: Record = {} + for (const [key, lq] of Object.entries(lqByKey)) { + if (!(lq > 0)) continue + classIndexByKey[key] = LQ_CLASS_RANGES.findIndex((range) => lq < range.upperBound) + } + + if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING + return buildClassing(LQ_CLASS_RANGES, classIndexByKey, false) +} + +/** + * Competitive placement, ordered weakest first so the legend keeps the same + * left-to-right reading as the other scales even though a LOWER placement + * number is the better one. + */ +const COMPETITIVE_RANK_RANGES = [ + { label: '4º ou pior' }, + { label: '2º–3º' }, + { label: '1º' }, +] as const + +/** Worst placement still inside each class, read from the strongest end. */ +const COMPETITIVE_RANK_TOP = 1 +const COMPETITIVE_RANK_CONTENDER = 3 + +const competitiveRankClassIndex = (rank: number): number => { + if (rank <= COMPETITIVE_RANK_TOP) return 2 + if (rank <= COMPETITIVE_RANK_CONTENDER) return 1 + return 0 +} + +export const buildCompetitiveRankClassing = ( + rankByKey: Record, +): MapScaleClassing => { + const classIndexByKey: Record = {} + for (const [key, rank] of Object.entries(rankByKey)) { + if (!(rank >= 1)) continue + classIndexByKey[key] = competitiveRankClassIndex(rank) + } + + if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING + return buildClassing(COMPETITIVE_RANK_RANGES, classIndexByKey, false) +} + +/** Feature key → fill, ready for `BahiaMap`'s `fillByKey`. */ +export const fillsForClassing = (classing: MapScaleClassing): Record => { + const fills: Record = {} + for (const [key, index] of Object.entries(classing.classIndexByKey)) { + const fill = classing.classes[index]?.fill + if (fill) fills[key] = fill + } + return fills +} diff --git a/src/lib/territorialClassAnchors.ts b/src/lib/territorialClassAnchors.ts new file mode 100644 index 00000000..fbc131b3 --- /dev/null +++ b/src/lib/territorialClassAnchors.ts @@ -0,0 +1,27 @@ +/** + * The cut points behind E10's territorial class, in one client-safe module. + * + * They live in `lib/` rather than next to the classifier because the map + * (B13) paints an LQ scale on the SAME cuts the list's "Classe" column uses, + * and the map panel is a client component: importing the classifier there + * would drag the committed TSE artifact (~600 KB of JSON) into the browser + * bundle. Numbers only, no data, no logic — the classifier + * (`municipalityTerritorialClass.ts`) and the copy + * (`municipalityLabels.ts`) both read from here, so a recalibration is still + * a one-line diff in one file. + * + * Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape + * (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact + * numbers can only come from a backtest against 2014–2022 — that is **E15**. + */ +export const TERRITORIAL_CLASS_ANCHORS = { + /** LQ at or above this = performing at least 2× his own statewide standard. */ + strongLq: 2, + /** LQ below this = performing at less than half his own standard. */ + weakLq: 0.5, + /** Municípios that together hold this share of his statewide vote are the "core block". */ + coreCumulativeShare: 0.5, +} as const + +/** Band where the LQ multiple rounds to "1×", which says nothing — name it instead. */ +export const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const diff --git a/src/utilities/municipalityElectoralBaseline.ts b/src/utilities/municipalityElectoralBaseline.ts index fafc9e40..918b31ff 100644 --- a/src/utilities/municipalityElectoralBaseline.ts +++ b/src/utilities/municipalityElectoralBaseline.ts @@ -316,6 +316,72 @@ export const loadCampoFederalVotesByCityZone = async ( return votes } +/** + * cityCode:zone → (candidateNumber → nominal deputado-federal T1 votes) for + * one year — the whole statewide slice, ungrouped by candidate, so a caller + * can fold it over any municipality geography and rank candidates against + * each other (B13's "posição no município"). + * + * CLI/build-time use only, same as `loadCampoFederalVotesByCityZone`: this + * scans ~100k rows and must never run on a request path — the ranking it + * feeds is precomputed into the committed artifact. + */ +export const loadFederalVotesByCityZoneAndCandidate = async ( + payload: Payload, + user: ElectionReader, + { year }: { year: number }, +): Promise>> => { + assertCanReadElectionData(user) + + const result = await payload.find({ + collection: 'electionCandidateVote', + where: { + and: [ + { year: { equals: year } }, + { office: { equals: FEDERAL_DEPUTY_OFFICE } }, + { turn: { equals: '1' } }, + { voteType: { equals: 'nominal' } }, + ], + }, + depth: 0, + limit: 0, + pagination: false, + select: { cityCode: true, zoneNumber: true, candidateNumber: true, votes: true }, + overrideAccess: true, + }) + + const byCityZone = new Map>() + for (const row of result.docs) { + const key = `${row.cityCode}:${row.zoneNumber}` + let byCandidate = byCityZone.get(key) + if (!byCandidate) { + byCandidate = new Map() + byCityZone.set(key, byCandidate) + } + byCandidate.set( + row.candidateNumber, + (byCandidate.get(row.candidateNumber) ?? 0) + (row.votes ?? 0), + ) + } + return byCityZone +} + +/** Folds `loadFederalVotesByCityZoneAndCandidate` over one municipality's cityCode×zones. */ +export const sumCandidateVotesForGeography = ( + votesByCityZoneAndCandidate: Map>, + geography: MunicipalityElectionGeography, +): Map => { + const total = new Map() + for (const zone of geography.zones) { + const byCandidate = votesByCityZoneAndCandidate.get(`${geography.cityCode}:${zone}`) + if (!byCandidate) continue + for (const [candidateNumber, votes] of byCandidate) { + total.set(candidateNumber, (total.get(candidateNumber) ?? 0) + votes) + } + } + return total +} + /** Turnout/valid/blank/null tally cell — same shape the committed artifact stores per year. */ export type OfficeTallyCell = FederalBaselineTallyCell diff --git a/src/utilities/municipalityLabels.ts b/src/utilities/municipalityLabels.ts index 981184ac..a02b2ba0 100644 --- a/src/utilities/municipalityLabels.ts +++ b/src/utilities/municipalityLabels.ts @@ -12,6 +12,7 @@ import { oneDecimalFormatter, } from '@/lib/electionFormat' import { municipalityCatalog } from '@/lib/municipalityCatalog' +import { AT_STANDARD_LQ } from '@/lib/territorialClassAnchors' import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate' import type { CampaignUser, Municipality } from '@/payload-types' import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage' @@ -83,8 +84,24 @@ export const territorialClassBadgeVariant = { sem_base: 'ghost', } as const -/** Band where the multiple rounds to "1×", which says nothing — name it instead. */ -const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const +/** + * The same ladder as fills for the map's proportional symbols (B13). Literal + * hex rather than the CSS tokens above because Leaflet writes the SVG `fill` + * ATTRIBUTE, where `var(--estimate-confirmed)` does not resolve — these mirror + * the token values in `styles.css` (see `DESIGN.md` § Status Badge) and must + * be changed together with them. + * + * Saturated over a basemap: the badge tints are tuned for a white row and + * would disappear on top of tiles, so each step is the readable sibling of + * its token, not the token itself. + */ +export const territorialClassMapFill: Record = { + reduto: '#16a34a', + expansao: '#d97706', + manutencao: '#78716c', + marginal: '#a8a29e', + sem_base: '#d6d3d1', +} /** * The "por quê" behind a class, in the mesa's own phrasing — "aqui a votação @@ -92,15 +109,19 @@ const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const * survives the table; "LQ 0,6" is not. The possessive names the candidate * ("do candidato"), never "seu", which an assessor reads as their own. */ +export const formatDominanceAgainstOwnStandard = (lq: number): string => { + if (lq >= AT_STANDARD_LQ.min && lq < AT_STANDARD_LQ.max) { + return 'no padrão estadual do candidato' + } + return lq >= 1 + ? `${oneDecimalFormatter.format(lq)}× o padrão estadual do candidato` + : `${formatRatioAsPercentLabel(1 - lq)} abaixo do padrão estadual do candidato` +} + const formatTerritorialClassFactor = (factor: TerritorialFactor): string => { switch (factor.id) { case 'dominance': - if (factor.value >= AT_STANDARD_LQ.min && factor.value < AT_STANDARD_LQ.max) { - return 'no padrão estadual do candidato' - } - return factor.value >= 1 - ? `${oneDecimalFormatter.format(factor.value)}× o padrão estadual do candidato` - : `${formatRatioAsPercentLabel(1 - factor.value)} abaixo do padrão estadual do candidato` + return formatDominanceAgainstOwnStandard(factor.value) case 'ownShare': return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato` case 'field': diff --git a/src/utilities/municipalityMapContract.ts b/src/utilities/municipalityMapContract.ts index e3d0ba4e..5ce6b3b4 100644 --- a/src/utilities/municipalityMapContract.ts +++ b/src/utilities/municipalityMapContract.ts @@ -1,6 +1,8 @@ +import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates' import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults' import type { VoteEstimateScenario } from '@/lib/voteEstimate' import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' +import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' /** * Client-safe contract of the municipality map: the bundle shape the server @@ -13,18 +15,54 @@ import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNaviga export const MUNICIPALITY_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const export type MunicipalityMapYear = (typeof MUNICIPALITY_MAP_YEARS)[number] -export type MunicipalityMapScaleMode = 'absolute' | 'percentValid' +/** + * B13: the first three modes are RELATIVE to the candidate himself and are + * what actually discriminate on a federal-deputy map — his best município + * gives him ~5% of its valid votes, so `percentValid` and `absolute` paint + * Bahia in one shade plus Salvador. The two absolute modes stay because they + * answer "how many votes, really", which the relative ones deliberately hide. + */ +export type MunicipalityMapScaleMode = + | 'quantile' + | 'lq' + | 'competitiveRank' + | 'percentValid' + | 'absolute' export const MUNICIPALITY_MAP_SCALE_MODES = [ + 'quantile', + 'lq', + 'competitiveRank', 'percentValid', 'absolute', ] as const satisfies readonly MunicipalityMapScaleMode[] +export const DEFAULT_MUNICIPALITY_MAP_SCALE_MODE: MunicipalityMapScaleMode = 'quantile' + export const municipalityMapScaleModeLabels: Record = { - absolute: 'Total (votos)', + quantile: 'Quantis (5 faixas)', + lq: 'Padrão próprio (LQ)', + competitiveRank: 'Posição no município', percentValid: '% dos válidos', + absolute: 'Total (votos)', } +/** What the colour means, printed with the legend the scale selector describes. */ +export const municipalityMapScaleModeHints: Record = { + quantile: 'Cinco faixas com o mesmo número de municípios.', + lq: 'Força local comparada ao padrão estadual do próprio candidato.', + competitiveRank: 'Colocação dele entre os candidatos a deputado federal votados ali.', + percentValid: + 'Participação nos votos válidos — honesta, mas comprime quase tudo numa faixa estreita.', + absolute: 'Votos em números absolutos — favorece os municípios grandes.', +} + +/** Scales that need TSE results and therefore have no 2026 reading. */ +export const isMunicipalityMapScaleModeAvailable = ( + mode: MunicipalityMapScaleMode, + year: MunicipalityMapYear, +): boolean => mode !== 'competitiveRank' || year !== 2026 + export const municipalityMapYearLabels: Record = { 2014: '2014 (TSE)', 2018: '2018 (TSE)', @@ -53,6 +91,19 @@ export type MunicipalityMapBundle = { values2026ByScenario: Record> /** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */ validVotesByYear: Record> + /** + * year (as string) → his own STATEWIDE share of valid votes: the "1×" the LQ + * scale divides by. Statewide, never the share of the municipalities on + * screen, so an advisor's map and the list's "Classe" column measure against + * the same standard. 2026 reuses the 2022 standard, like `validVotesByYear`. + */ + statewideShareByYear: Record + /** codarea → E10 territorial class, aggregated over the slugs in scope. */ + territorialClassByCode: Record + /** codarea → his placement among the federal-deputy candidates voted there. */ + competitiveRankByYear: Record> + /** codarea → projected 2026 valid votes (E8) — the size of the proportional symbol. */ + projectedValidVotesByCode: Record /** IBGE codarea → accessible municipality slugs for map click navigation. */ municipalitiesByIbgeCode: MunicipalitiesByIbgeCode /** Zone municipalities in scope (Salvador) with per-year values. */ diff --git a/src/utilities/municipalityMapData.ts b/src/utilities/municipalityMapData.ts index 29cbd1fb..c4807820 100644 --- a/src/utilities/municipalityMapData.ts +++ b/src/utilities/municipalityMapData.ts @@ -2,7 +2,12 @@ import 'server-only' import type { Payload } from 'payload' -import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates' +import { + getFederalCompetitiveRank, + getMunicipalityFederalBaseline, + getStatewideFederalTotals, + type FederalCompetitiveRank, +} from '@/lib/bahiaElectionAggregates' import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults' import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' import { @@ -32,6 +37,11 @@ import type { MunicipalityMapComparison, } from '@/utilities/municipalityMapContract' import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation' +import { projectedValidVotes } from '@/utilities/municipalityPotential' +import { + computeAggregateTerritorialClass, + type MunicipalityTerritorialClass, +} from '@/utilities/municipalityTerritorialClass' import { emptyMunicipalityPledgeAggregate, resolveMunicipalityStaffVoteTotal, @@ -91,7 +101,15 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( const valuesByYear: Record> = {} const validVotesByYear: Record> = {} + const statewideShareByYear: Record = {} + const competitiveRankByYear: Record> = {} const zoneVotesBySlug = new Map>() + const slugsByIbgeCode = new Map() + for (const municipality of municipalities) { + const slugs = slugsByIbgeCode.get(municipality.ibgeCode) ?? [] + slugs.push(municipality.slug) + slugsByIbgeCode.set(municipality.ibgeCode, slugs) + } // Historical years come from the committed artifact (immutable TSE data, // pre-aggregated per municipality) — zero database work. @@ -112,8 +130,36 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( } valuesByYear[String(year)] = values validVotesByYear[String(year)] = validValues + + // The LQ denominator is his STATEWIDE standard, not the average of what is + // on screen: an advisor looking at 14 municípios must read the same "2×" + // that the list's "Classe" column shows. + const statewide = getStatewideFederalTotals(year) + statewideShareByYear[String(year)] = + statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0 + + const ranks: Record = {} + for (const ibgeCode of slugsByIbgeCode.keys()) { + const rank = getFederalCompetitiveRank(ibgeCode, year) + if (rank) ranks[ibgeCode] = rank + } + competitiveRankByYear[String(year)] = ranks } validVotesByYear['2026'] = validVotesByYear['2022'] ?? {} + // 2026 has no statewide truth to measure against — the estimates only exist + // where the mesa filled them in — so it keeps 2022 as the standard, the same + // substitution `validVotesByYear` already makes. + statewideShareByYear['2026'] = statewideShareByYear['2022'] ?? 0 + + const territorialClassByCode: Record = {} + const projectedValidVotesByCode: Record = {} + for (const [ibgeCode, slugs] of slugsByIbgeCode) { + territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class + projectedValidVotesByCode[ibgeCode] = slugs.reduce( + (total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)), + 0, + ) + } const pledgeValuesByScenario = Object.fromEntries( VOTE_ESTIMATE_SCENARIOS.map((scenario) => [scenario, {} as Record]), @@ -213,6 +259,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async ( valuesByYear, values2026ByScenario: pledgeValuesByScenario, validVotesByYear, + statewideShareByYear, + territorialClassByCode, + competitiveRankByYear, + projectedValidVotesByCode, municipalitiesByIbgeCode: buildMunicipalitiesByIbgeCode(municipalities), zoneBreakdown, candidateName: BASELINE_TICKET_2022.candidate.name, diff --git a/src/utilities/municipalityTerritorialClass.ts b/src/utilities/municipalityTerritorialClass.ts index b0632277..033a7054 100644 --- a/src/utilities/municipalityTerritorialClass.ts +++ b/src/utilities/municipalityTerritorialClass.ts @@ -6,7 +6,13 @@ import { } from '@/lib/bahiaElectionAggregates' import { ELECTION_YEAR_2022 } from '@/lib/electionResults' import { computeVoteRankByYear } from '@/lib/municipalityVoteRank' -import { captureRate, ownVotes2022, projectedFieldCeiling } from '@/utilities/municipalityPotential' +import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors' +import { + captureRate, + fieldCeiling, + ownVotes2022, + projectedFieldCeiling, +} from '@/utilities/municipalityPotential' /** * E10 "classificação territorial relativa" — the operational class of one @@ -36,22 +42,6 @@ export const TERRITORIAL_CLASSES = [ export type MunicipalityTerritorialClass = (typeof TERRITORIAL_CLASSES)[number] -/** - * Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape - * (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact - * numbers can only come from a backtest against 2014–2022 — that is **E15**. - * They live here, named and versioned, so recalibration is a one-line diff - * with a test to prove what moved. - */ -export const TERRITORIAL_CLASS_ANCHORS = { - /** LQ at or above this = performing at least 2× his own statewide standard. */ - strongLq: 2, - /** LQ below this = performing at less than half his own standard. */ - weakLq: 0.5, - /** Municípios that together hold this share of his statewide vote are the "core block". */ - coreCumulativeShare: 0.5, -} as const - /** The axes behind a class — the UI always shows the "por quê", never the label alone. */ type TerritorialFactorId = 'dominance' | 'ownShare' | 'field' | 'capture' @@ -220,6 +210,55 @@ export const computeMunicipalityTerritorialClass = ( return classification } +/** + * The class of a GROUP of catalog slugs read as one territory — the map paints + * one polygon per IBGE municipality, and Salvador is 19 catalog slugs. + * + * It sums the inputs and runs the SAME `classifyMunicipalityTerritory`; it + * never averages or votes on the per-slug classes, because LQ is a ratio and + * the average of ratios is not the ratio of the sums. The catalog-wide + * references (median field headroom, core block) stay exactly as they are: + * the aggregate is compared against the same statewide standard as everyone + * else. + * + * E12's TI rollup inherits this helper rather than growing a second one. + */ +export const computeAggregateTerritorialClass = ( + slugs: ReadonlyArray, +): MunicipalityTerritorialClassification => { + if (slugs.length === 1) return computeMunicipalityTerritorialClass(slugs[0]) + if (slugs.length === 0) return UNCLASSIFIED + + const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR) + const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext() + + let ownVotes = 0 + let validVotes = 0 + let fieldHeadroom = 0 + let fieldCeiling2022 = 0 + let inCoreBlock = false + + for (const slug of slugs) { + const baseline = getMunicipalityFederalBaseline(slug) + ownVotes += ownVotes2022(baseline) + validVotes += baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0 + fieldHeadroom += fieldHeadroomOf(baseline) + fieldCeiling2022 += fieldCeiling(baseline) + inCoreBlock ||= coreBlockSlugs.has(slug) + } + + return classifyMunicipalityTerritory({ + ownVotes, + validVotes, + stateOwnVotes: totals.ownVotes, + stateValidVotes: totals.validVotes, + fieldHeadroom, + medianFieldHeadroom, + captureRate: fieldCeiling2022 > 0 ? ownVotes / fieldCeiling2022 : null, + inCoreBlock, + }) +} + /** * Sort weight for the list's `classe` ordering: descending puts the * municípios that carry the campaign (reduto) first and the ones without a diff --git a/tests/unit/bahiaElectionAggregates.unit.spec.ts b/tests/unit/bahiaElectionAggregates.unit.spec.ts index fb0609e9..a6e7203b 100644 --- a/tests/unit/bahiaElectionAggregates.unit.spec.ts +++ b/tests/unit/bahiaElectionAggregates.unit.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' import { FEDERAL_BASELINE_CANDIDATE_NUMBER, federalBaselineMunicipalitySlugs, + getFederalCompetitiveRank, getMunicipalityFederalBaseline, } from '@/lib/bahiaElectionAggregates' import { @@ -111,12 +112,51 @@ describe('election aggregates artifact', () => { expect(HISTORICAL_SERIES_YEARS).toContain(ELECTION_YEAR_2022) }) + it('has a plausible competitive placement per IBGE municipality (v3 field)', () => { + const ibgeCodes = new Set(municipalityCatalog.map((entry) => entry.ibgeCode)) + let placed = 0 + + for (const ibgeCode of ibgeCodes) { + for (const year of HISTORICAL_SERIES_YEARS) { + const placement = getFederalCompetitiveRank(ibgeCode, year) + // Absent is legitimate: he took no votes in that municipality that + // year (351/417 municipalities placed in 2014, 416/417 in 2022). + if (!placement) continue + placed += 1 + expect(placement.rank).toBeGreaterThanOrEqual(1) + expect(placement.candidates).toBeGreaterThanOrEqual(placement.rank) + } + } + + // A statewide campaign places in most municipalities in most years. + expect(placed).toBeGreaterThan(ibgeCodes.size * 2) + }) + + it('gives Salvador one whole-city placement, not one per zone municipality', () => { + const salvadorZones = municipalityCatalog.filter((entry) => entry.kind === 'zona') + const salvadorCodes = new Set(salvadorZones.map((entry) => entry.ibgeCode)) + + // The 19 zone municipalities share Salvador's IBGE code, which is why the + // placement is keyed by code: "which place did he come in" only has an + // answer for the whole city. + expect(salvadorZones.length).toBeGreaterThan(1) + expect(salvadorCodes.size).toBe(1) + + const [salvadorCode] = salvadorCodes + expect(getFederalCompetitiveRank(salvadorCode, ELECTION_YEAR_2022)).not.toBeNull() + }) + + it('has no placement for an unknown IBGE code', () => { + expect(getFederalCompetitiveRank('0000000', ELECTION_YEAR_2022)).toBeNull() + }) + it('stays under the committed artifact byte budget', () => { const { size } = statSync(ARTIFACT_PATH) // v1 (votes + valid votes only) was ~96 KB; v2 adds campo votes, federal - // tally and the 2022 majoritarian slice, measured at ~522 KB — budget - // with headroom, not a precise pin, so future fields don't require - // touching this test. + // tally and the 2022 majoritarian slice, measured at ~522 KB; v3 adds the + // per-IBGE competitive placement, measured at ~613 KB — budget with + // headroom, not a precise pin, so future fields don't require touching + // this test. expect(size).toBeLessThan(700 * 1024) }) }) diff --git a/tests/unit/bahiaMapStyle.unit.spec.ts b/tests/unit/bahiaMapStyle.unit.spec.ts index f84fb27b..cb493a50 100644 --- a/tests/unit/bahiaMapStyle.unit.spec.ts +++ b/tests/unit/bahiaMapStyle.unit.spec.ts @@ -108,4 +108,31 @@ describe('bahiaMapStyle', () => { expect(style.weight).toBe(1) }) }) + + describe('discrete class fills (B13)', () => { + const classedContext = buildLayerStyleContext({ + values: { '2927408': 500, '2905701': 200, '2910800': 0 }, + fillMode: 'sequential', + fillByKey: { '2927408': '#111111', '2905701': '#eeeeee' }, + highlightKey: '', + selectedKey: null, + }) + + it('paints the class fill instead of the continuous ramp', () => { + expect(resolvePathStyle(classedContext, '2927408').fillColor).toBe('#111111') + expect(resolvePathStyle(classedContext, '2905701').fillColor).toBe('#eeeeee') + }) + + it('renders a key without a class as no data, even when it has a value', () => { + const style = resolvePathStyle(classedContext, '2910800') + expect(style.fillColor).toBe('#f4f4f5') + expect(style.fillOpacity).toBe(0.35) + }) + + it('still highlights hover and selection', () => { + const style = resolvePathStyle({ ...classedContext, hoveredKey: '2927408' }, '2927408') + expect(style.weight).toBe(2) + expect(style.fillColor).toBe('#111111') + }) + }) }) diff --git a/tests/unit/mapScaleClasses.unit.spec.ts b/tests/unit/mapScaleClasses.unit.spec.ts new file mode 100644 index 00000000..5b33aef9 --- /dev/null +++ b/tests/unit/mapScaleClasses.unit.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' + +import { + buildCompetitiveRankClassing, + buildLqClassing, + buildQuantileClassing, + fillsForClassing, + QUANTILE_CLASSES, + QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT, +} from '@/lib/mapScaleClasses' +import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors' + +const valuesFrom = (numbers: number[]): Record => + Object.fromEntries(numbers.map((value, index) => [`k${index}`, value])) + +describe('buildQuantileClassing', () => { + it('cuts a large set into five classes ordered weakest to strongest', () => { + const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1)) + const classing = buildQuantileClassing(values) + + expect(classing.classes).toHaveLength(QUANTILE_CLASSES) + expect(classing.reduced).toBe(false) + expect(classing.classIndexByKey.k0).toBe(0) + expect(classing.classIndexByKey.k99).toBe(QUANTILE_CLASSES - 1) + }) + + it('splits evenly: each quantile holds about a fifth of the features', () => { + const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1)) + const classing = buildQuantileClassing(values) + + const counts = new Array(QUANTILE_CLASSES).fill(0) + for (const index of Object.values(classing.classIndexByKey)) counts[index] += 1 + + expect(counts).toEqual([20, 20, 20, 20, 20]) + }) + + it('degrades to three classes below the minimum feature count', () => { + const values = valuesFrom( + Array.from({ length: QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT - 1 }, (_, i) => (i + 1) * 10), + ) + const classing = buildQuantileClassing(values) + + expect(classing.classes).toHaveLength(3) + expect(classing.reduced).toBe(true) + }) + + it('collapses classes rather than repeating a break when values tie heavily', () => { + const classing = buildQuantileClassing(valuesFrom([5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 900])) + + // Eleven features share one value: quantiles 1-4 all end at 5, so the + // legend gets two honest classes instead of five with duplicate bounds. + expect(classing.classes).toHaveLength(2) + expect(classing.reduced).toBe(true) + expect(classing.classes[0].label).toBe('5') + expect(classing.classes[1].label).toBe('900') + }) + + it('excludes zero and negative values from the classing', () => { + const classing = buildQuantileClassing({ a: 0, b: -5, c: 10, d: 20 }) + + expect(classing.classIndexByKey).not.toHaveProperty('a') + expect(classing.classIndexByKey).not.toHaveProperty('b') + expect(Object.keys(classing.classIndexByKey)).toEqual(['c', 'd']) + }) + + it('returns nothing to paint when no feature has a positive value', () => { + const classing = buildQuantileClassing({ a: 0, b: 0 }) + + expect(classing.classes).toEqual([]) + expect(classing.classIndexByKey).toEqual({}) + }) + + it('labels each class with its real extremes, never an invented gap', () => { + const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900])) + + expect(classing.classes.map((entry) => entry.label)).toEqual(['1–3', '40–60', '700–900']) + }) + + it('labels a single-value class with that value alone', () => { + const classing = buildQuantileClassing({ a: 42 }) + + expect(classing.classes).toHaveLength(1) + expect(classing.classes[0].label).toBe('42') + }) +}) + +describe('buildLqClassing', () => { + const classOf = (lq: number) => buildLqClassing({ a: lq }).classIndexByKey.a + + it('cuts on E10 anchors, so the map and the list agree on "reduto"', () => { + expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq - 0.01)).toBe(0) + expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq)).toBe(1) + expect(classOf(AT_STANDARD_LQ.min)).toBe(2) + expect(classOf(AT_STANDARD_LQ.max)).toBe(3) + expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq)).toBe(4) + expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq + 10)).toBe(4) + }) + + it('always offers the five named bands, even when only one is populated', () => { + const classing = buildLqClassing({ a: 1 }) + + expect(classing.classes).toHaveLength(5) + expect(classing.reduced).toBe(false) + }) + + it('spells the band edges without rounding 0,95 into 1', () => { + const labels = buildLqClassing({ a: 1 }).classes.map((entry) => entry.label) + + expect(labels).toEqual([ + 'menos de 0,5×', + '0,5×–0,95×', + 'no padrão (≈1×)', + '1,15×–2×', + '2× ou mais', + ]) + }) + + it('skips municípios with no measurable standard', () => { + expect(buildLqClassing({ a: 0 }).classIndexByKey).toEqual({}) + }) +}) + +describe('buildCompetitiveRankClassing', () => { + const classOf = (rank: number) => buildCompetitiveRankClassing({ a: rank }).classIndexByKey.a + + it('puts the strongest placement in the darkest class', () => { + expect(classOf(1)).toBe(2) + expect(classOf(2)).toBe(1) + expect(classOf(3)).toBe(1) + expect(classOf(4)).toBe(0) + expect(classOf(120)).toBe(0) + }) + + it('reads weakest to strongest, like every other legend', () => { + const labels = buildCompetitiveRankClassing({ a: 1 }).classes.map((entry) => entry.label) + + expect(labels).toEqual(['4º ou pior', '2º–3º', '1º']) + }) + + it('ignores an impossible placement instead of painting it', () => { + expect(buildCompetitiveRankClassing({ a: 0 }).classIndexByKey).toEqual({}) + }) +}) + +describe('fillsForClassing', () => { + it('maps every classed key to its class fill', () => { + const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900])) + const fills = fillsForClassing(classing) + + expect(Object.keys(fills)).toHaveLength(9) + expect(fills.k0).toBe(classing.classes[0].fill) + expect(fills.k8).toBe(classing.classes[2].fill) + }) + + it('gives each class a distinguishable fill', () => { + const classing = buildQuantileClassing( + valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1)), + ) + + expect(new Set(classing.classes.map((entry) => entry.fill)).size).toBe(QUANTILE_CLASSES) + }) +}) diff --git a/tests/unit/municipalityTerritorialClass.unit.spec.ts b/tests/unit/municipalityTerritorialClass.unit.spec.ts index 17f37c2d..4702187a 100644 --- a/tests/unit/municipalityTerritorialClass.unit.spec.ts +++ b/tests/unit/municipalityTerritorialClass.unit.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { municipalityCatalog } from '@/lib/municipalityCatalog' +import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors' import { classifyMunicipalityTerritory, + computeAggregateTerritorialClass, computeMunicipalityTerritorialClass, - TERRITORIAL_CLASS_ANCHORS, TERRITORIAL_CLASSES, territorialClassSortWeight, type MunicipalityTerritorialClass, @@ -150,6 +151,57 @@ describe('computeMunicipalityTerritorialClass over the committed artifact', () = }) }) +describe('computeAggregateTerritorialClass', () => { + const salvadorSlugs = municipalityCatalog + .filter((entry) => entry.kind === 'zona') + .map((entry) => entry.slug) + + it('reads Salvador as one territory, not nineteen', () => { + expect(salvadorSlugs.length).toBe(19) + + const aggregate = computeAggregateTerritorialClass(salvadorSlugs) + expect(aggregate.class).not.toBe('sem_base') + expect(aggregate.lq).toBeGreaterThan(0) + }) + + it('sums the inputs instead of averaging the zones\u2019 ratios', () => { + const aggregate = computeAggregateTerritorialClass(salvadorSlugs) + const zoneLqs = salvadorSlugs + .map((slug) => computeMunicipalityTerritorialClass(slug).lq) + .filter((lq): lq is number => lq !== null) + const meanZoneLq = zoneLqs.reduce((total, lq) => total + lq, 0) / zoneLqs.length + + // The aggregate is vote-weighted, so it must land inside the zones' range + // but not on their unweighted mean — LQ is a ratio, and the mean of ratios + // is not the ratio of the sums. + expect(aggregate.lq).toBeGreaterThanOrEqual(Math.min(...zoneLqs)) + expect(aggregate.lq).toBeLessThanOrEqual(Math.max(...zoneLqs)) + expect(aggregate.lq).not.toBeCloseTo(meanZoneLq, 6) + }) + + it('adds up the share of his statewide vote across the group', () => { + const aggregate = computeAggregateTerritorialClass(salvadorSlugs) + const summed = salvadorSlugs.reduce( + (total, slug) => total + computeMunicipalityTerritorialClass(slug).ownShare, + 0, + ) + + expect(aggregate.ownShare).toBeCloseTo(summed, 10) + }) + + it('matches the single-slug classifier for a group of one', () => { + const slug = municipalityCatalog.find((entry) => entry.kind === 'municipio')!.slug + + expect(computeAggregateTerritorialClass([slug])).toEqual( + computeMunicipalityTerritorialClass(slug), + ) + }) + + it('has no class for an empty group', () => { + expect(computeAggregateTerritorialClass([]).class).toBe('sem_base') + }) +}) + describe('territorialClassSortWeight', () => { it('ranks reduto first and leaves sem_base without a weight', () => { expect(territorialClassSortWeight.reduto).toBeGreaterThan(