mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
B13: escala relativa no mapa dos Municipios (quantis/LQ/posicao) + artefato TSE v3
O coropleto do Inicio era quase monocromatico porque 0-100% dos validos comprime uma disputa cujo topo estadual e 4,71%. Tres escalas relativas ao proprio candidato, todas em classes discretas: quantis (novo default), padrao proprio (LQ) e posicao no municipio, ao lado de "% dos validos" (B11) e "Total (votos)". Artefato v3: federalRankByIbgeCode com a colocacao entre todos os candidatos com votos > 0 na geografia (2014/2018/2022), chaveado por ibgeCode porque e o que o mapa desenha. Fica no artefato commitado, nao em query de request, que varreria ~100 mil linhas por ano para um dado imutavel. 534 -> 623 KB (orcamento 700 KB). LQ virou 5 classes nas ancoras do E10, nao diverging: o plano pedia as duas coisas e se contradizia. Vermelho x azul continua significando "Solla x adversario" e segue exclusivo do compare. computeAggregateTerritorialClass classifica um grupo de slugs como um territorio so, somando insumos e rodando o mesmo classificador (LQ e razao; a media de razoes nao e a razao das somas) - o E12 herda o helper. TERRITORIAL_CLASS_ANCHORS mudou para src/lib/territorialClassAnchors.ts para nao arrastar o artefato TSE ao bundle do cliente. Quantis sobre o escopo de acesso do usuario, degradando para 3 classes abaixo de 10 municipios com a legenda declarando a contagem efetiva. Rank indisponivel em 2026 (dado TSE); LQ em 2026 reusa o padrao estadual de 2022. Bolhas opcionais por validos projetados (area proporcional ao valor, cor da classe E10, interactive: false). 417 marcadores medidos antes de otimizar - nao derrubaram o frame, entao o corte para top-K nao foi feito. Sem migration, sem collection, sem server action; bundle client de /campanha byte a byte igual.
This commit is contained in:
parent
b16d2c9831
commit
29f45e093a
26 changed files with 7040 additions and 69 deletions
|
|
@ -7,6 +7,15 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B13 entregue em código (2026-07-26):** escala relativa no mapa do Início. O coropleto era quase monocromático porque 0–100% dos válidos comprime uma disputa cujo topo estadual é 4,71% — três escalas **relativas ao próprio candidato**, em **classes discretas** (nunca rampa contínua): **quantis** (novo default), **padrão próprio (LQ)** e **posição no município**, ao lado de "% dos válidos" (B11) e "Total (votos)". **Auditoria do plano antes de codar** achou três defasagens, corrigidas em [`escala-relativa-mapa.md`](../../../docs/plans/escala-relativa-mapa.md) § Revisão na entrega: caminhos pós-Pass 2 W2 (`components/campaign/map/`), o nome inexistente `computeDfTerritorialClass` (é `computeMunicipalityTerritorialClass`) e a premissa "o mapa acompanha a lista" (desde a remodelagem o mapa só existe no Início) — resolvida como **quantis sobre o escopo de acesso do usuário**, decisão do usuário nesta sessão. Resolvi também a **contradição interna do plano** sobre LQ: os Objetivos pediam diverging em torno de 1 e as Decisões travadas reservavam diverging ao compare — ficou com as Decisões travadas, LQ como 5 classes nomeadas nas âncoras do E10, porque vermelho × azul significa "Solla × adversário" e não pode passar a significar duas coisas.
|
||||
|
||||
**Artefato v3 (decisão cara do usuário: construir).** "Posição no município" não existia nem no artefato nem em query reaproveitável, e computá-la em request seria varrer ~100 mil linhas por ano para um dado imutável. Foi para o artefato commitado (regra 3 da escada de cache, como o E8 na v2): `federalRankByIbgeCode: Record<codarea, Record<year, { rank, candidates }>>` — colocação entre **todos** os candidatos com votos > 0 na geografia, 2014/2018/2022, alimentada por `loadFederalVotesByCityZoneAndCandidate` (irmão CLI-only de `loadCampoFederalVotesByCityZone`). Chaveado por `ibgeCode` e não por slug porque é o que o mapa desenha (as 19 zonas de Salvador somam a cidade inteira); rank por slug entra quando dossiê/E11 pedirem. 534 → 623 KB, dentro do orçamento de 700 KB. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022.
|
||||
|
||||
**Peças novas que outros itens herdam.** `computeAggregateTerritorialClass(slugs)` classifica um grupo como um território só — soma os insumos e roda o **mesmo** `classifyMunicipalityTerritory`, nunca a média das classes por slug (LQ é razão, e a média de razões não é a razão das somas): **o E12 deve herdá-lo em vez de escrever um rollup por TI próprio**. `TERRITORIAL_CLASS_ANCHORS` e `AT_STANDARD_LQ` mudaram de casa para `src/lib/territorialClassAnchors.ts` porque o painel do mapa é client component e importar o classificador arrastaria o artefato TSE (~600 KB) para o bundle do navegador — números apenas, com classificador e copy lendo do mesmo lugar, então a recalibração do **E15** continua sendo um diff de uma linha. A matemática das escalas ficou em `src/lib/mapScaleClasses.ts` (arquivo novo; `bahiaMapStyle.ts` já carregava estilo de path), com **degradação declarada para 3 classes abaixo de 10 municípios** — não hipotético com carteira de assessor — e a legenda dizendo a contagem efetiva.
|
||||
|
||||
**UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`.
|
||||
|
||||
Sem migration, sem collection, sem server action; bundle client de `/campanha` **byte a byte igual** (9,46 kB / 265 kB — o painel do mapa já carrega em chunk próprio). Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba".
|
||||
- **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover <nome>"`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? <CampaignHoverTooltip>… : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md).
|
||||
- **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `<thead>` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
# B13 — Escala relativa no mapa dos Municípios (quantis/LQ/rank + símbolo proporcional)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
|
||||
Status: entregue (2026-07-26)
|
||||
Atualizado em: 2026-07-26 (as-built da entrega; auditoria do plano aplicada)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", B13; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
|
||||
Impeccable: B — estende `MunicipalityMapPanel`/`BahiaMap` existentes (seletor de escala, camada de símbolos); sem rota nova
|
||||
Appetite: ~2 dias eng; sem migration
|
||||
Responsável: —
|
||||
|
||||
## Revisão na entrega (2026-07-26)
|
||||
|
||||
Três correções do plano original, encontradas ao auditá-lo contra o repositório antes de codar:
|
||||
|
||||
1. **Caminhos e nomes defasados.** `BahiaMap.tsx`/`MunicipalityMapPanel.tsx` moraram em `src/components/campaign/` até o Pass 2 W2 e hoje estão em `src/components/campaign/map/`; o classificador do E10 chama-se `computeMunicipalityTerritorialClass`, não `computeDfTerritorialClass`. Os diagramas e a lista de componentes abaixo ficaram como estavam para preservar o registro do que foi planejado — leia-os com esta errata.
|
||||
2. **"O mapa acompanha a lista" deixou de ser verdade.** Desde a remodelagem dos Municípios o mapa só existe no Início (`/campanha`) e recebe apenas `compare`; `/campanha/municipios` é lista/overview. A questão em aberto "quantis sobre o conjunto filtrado ou o estado inteiro" foi resolvida como **escopo de acesso do usuário** (435 municípios para coordenador/candidato, a carteira para o assessor), que é o análogo do "conjunto filtrado" na única superfície que o mapa tem.
|
||||
3. **LQ como classes, não diverging.** Os Objetivos pediam "LQ diverging em torno de 1" e as Decisões travadas, três linhas abaixo, reservavam diverging exclusivamente ao compare. Resolvido a favor das decisões travadas: LQ virou **5 classes nomeadas** cortadas nas âncoras que o E10 já usa (`TERRITORIAL_CLASS_ANCHORS.weakLq` 0,5 / `strongLq` 2 mais a banda `AT_STANDARD_LQ` 0,95–1,15), na rampa sequencial do tema. Vermelho × azul continua significando "Solla × adversário" e só aparece no compare.
|
||||
|
||||
Uma decisão cara que o plano não previa: **"Posição no município" não existia em lugar nenhum** — nem no artefato, nem em query reaproveitável (`municipalityCandidateComparison.ts` é por município × candidatos escolhidos). Construí-la em request significaria varrer ~100 mil linhas por ano para um dado que nunca muda, então o ranking foi para o **artefato TSE commitado** (regra 3 da escada de cache), como o E8 fez na v2. O rabbit hole "rank v1 = posição entre os top-N carregados, documentado" desapareceu junto: o rank do artefato é sobre **todos** os candidatos com votos na geografia.
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípio 5; anti-goal cartograma) / `DESIGN.md` (register `product`) · precedentes B10/B11/B12/B6 (`BahiaMap`, `bahiaMapStyle.ts`, `MapFeatureReadout`).
|
||||
|
|
@ -89,6 +99,22 @@ Componentes:
|
|||
- **Value-by-alpha (opacidade ∝ eleitorado).** Gatilho: teste com o time em reunião real pedir alternativa às bolhas.
|
||||
- **Roll-off como métrica de mapa.** Gatilho: I-A entrar no motor (E11 fase 2) e o time pedir a leitura espacial.
|
||||
|
||||
## As-built (2026-07-26)
|
||||
|
||||
**Artefato v3.** `pnpm build:election-aggregates` passou a emitir `federalRankByIbgeCode: Record<codarea, Record<year, { rank, candidates }>>` — a colocação do candidato entre **todos** os candidatos a deputado federal com votos > 0 naquela geografia, em 2014/2018/2022. Chaveado por `ibgeCode` (417 códigos) e não por slug porque é o que o mapa desenha: as 19 zonas de Salvador somam a cidade inteira. Geografia sem votos dele não recebe entrada (nunca `rank: 0`). O insumo é `loadFederalVotesByCityZoneAndCandidate` em `municipalityElectoralBaseline.ts` — irmão de `loadCampoFederalVotesByCityZone`, mesma varredura estadual, CLI/build-time only. O arquivo foi de 534 KB para 623 KB, dentro do orçamento de 700 KB que o E8 já tinha fixado.
|
||||
|
||||
**Escalas.** A matemática pura vive em `src/lib/mapScaleClasses.ts` (arquivo novo — `bahiaMapStyle.ts` já carregava estilo de path e teria virado duas responsabilidades): quantis sobre os valores > 0 do escopo renderizado, **degradando para 3 classes com menos de 10 municípios** e devolvendo a contagem efetiva para a legenda não mentir; faixas de LQ nas âncoras do E10; buckets de rank (1º · 2º–3º · 4º+). `resolvePathStyle` ganhou um caminho de fill discreto por chave (`fillByKey`) que **substitui** a rampa contínua quando presente — chave ausente é "sem dado", que não é a mesma coisa que valor zero.
|
||||
|
||||
As âncoras saíram de `municipalityTerritorialClass.ts` para `src/lib/territorialClassAnchors.ts` porque o painel do mapa é client component: importar o classificador lá arrastaria o artefato TSE inteiro para o bundle do navegador. Só números, sem dados nem lógica; classificador e copy leem do mesmo lugar, então recalibrar (E15) continua sendo um diff de uma linha em um arquivo.
|
||||
|
||||
**Bundle.** `computeAggregateTerritorialClass(slugs)` classifica um grupo de slugs como um território só — soma os insumos e roda o **mesmo** `classifyMunicipalityTerritory`, nunca a média das classes por slug (LQ é razão, e a média de razões não é a razão das somas). O E12 herda o helper para o rollup por TI. O loader do mapa preenche `statewideShareByYear`, `territorialClassByCode`, `competitiveRankByYear` e `projectedValidVotesByCode`, todos puros sobre o artefato: **nenhuma query nova em request**. Rank fica indisponível em 2026 (dado TSE), mesmo precedente do compare; LQ em 2026 reusa o padrão estadual de 2022, como `percentValid` já fazia.
|
||||
|
||||
**UI.** Default passou a ser **quantis**. A legenda discreta (`MapScaleLegend`) mostra um swatch por classe com a faixa real, do mais fraco para o mais forte, e a nota abaixo dela é ligada ao seletor por `aria-describedby` — inclusive a nota que declara a degradação ("cerca de um quinto dos N municípios com votos no seu escopo"). O readout nunca mostra a classe sozinha: soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Duas entradas novas em `/campanha/conceitos` (Quantis e Posição no município) e um "Saiba mais" inline na nota da legenda; LQ já estava coberto por `dominancia-relativa`.
|
||||
|
||||
**Bolhas.** `L.circleMarker` no centro dos bounds de cada feature, raio ∝ √(válidos projetados) — a **área** carrega o valor. Fill pela classe do E10, `interactive: false` (a bolha fica em cima do próprio município, então deixar o ponteiro atravessar mantém um alvo de hover e um readout, não dois empilhados), desenhadas da menor para a maior para que os prêmios grandes não fiquem cobertos. Toggle desligado por default, com legenda de tamanho + cor que só aparece com a camada. Medido antes de otimizar, como o plano mandava: 417 marcadores não derrubaram o frame, então o corte para top-K **não** foi feito.
|
||||
|
||||
**Sem migration, sem collection, sem server action.** O bundle client de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) — o painel do mapa já carrega em chunk próprio.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Inteligência de campanha, B13) · [plano-mestre](inteligencia-campanha.md)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ type ChoroplethLegendProps = {
|
|||
max: number
|
||||
metricLabel: string
|
||||
formatMax?: (max: number) => string
|
||||
/** What the colour means, in the caller's words. Shares the id with the scale selector. */
|
||||
noteId?: string
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export const ChoroplethLegend = ({
|
||||
max,
|
||||
metricLabel,
|
||||
formatMax = formatElectionNumber,
|
||||
noteId,
|
||||
note,
|
||||
}: ChoroplethLegendProps) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -25,8 +30,8 @@ export const ChoroplethLegend = ({
|
|||
{formatMax(max)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Escala: intensidade da cor indica {metricLabel} no território.
|
||||
<p id={noteId} className="text-xs text-muted-foreground">
|
||||
{note ?? `Escala: intensidade da cor indica ${metricLabel} no território.`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ type MapFeatureReadoutProps = {
|
|||
rawMetricValue: number | undefined
|
||||
metricLabel: string
|
||||
scaleMode: MunicipalityMapScaleMode
|
||||
/**
|
||||
* What the colour means for THIS município, spelled out — "4ª de 5 faixas",
|
||||
* "1,8× o padrão estadual do candidato", "12º entre 663 candidatos". A class
|
||||
* without its reason is a verdict the reader cannot check.
|
||||
*/
|
||||
relativeReading: string | null
|
||||
/** What the bubble encodes for THIS município — only while the layer is on. */
|
||||
bubbleReading: string | null
|
||||
comparisonActive: boolean
|
||||
navigation: MunicipalityMapNavigation | null
|
||||
}
|
||||
|
|
@ -41,6 +49,8 @@ export const MapFeatureReadout = ({
|
|||
rawMetricValue,
|
||||
metricLabel,
|
||||
scaleMode,
|
||||
relativeReading,
|
||||
bubbleReading,
|
||||
comparisonActive,
|
||||
navigation,
|
||||
}: MapFeatureReadoutProps) => {
|
||||
|
|
@ -53,6 +63,10 @@ export const MapFeatureReadout = ({
|
|||
}
|
||||
|
||||
const formattedValue = formatMetricValue(metricValue, scaleMode, comparisonActive)
|
||||
// Every mode except the percentage prints the metric next to the number, so
|
||||
// announcing it again would read it twice.
|
||||
const showMetricLabel =
|
||||
!comparisonActive && metricValue !== undefined && scaleMode !== 'percentValid'
|
||||
const showPercentSecondary =
|
||||
!comparisonActive &&
|
||||
scaleMode === 'percentValid' &&
|
||||
|
|
@ -68,23 +82,27 @@ export const MapFeatureReadout = ({
|
|||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="truncate text-sm font-medium">{feature.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="sr-only">{metricLabel}: </span>
|
||||
{showMetricLabel ? null : <span className="sr-only">{metricLabel}: </span>}
|
||||
<span className="tabular-nums">{formattedValue}</span>
|
||||
{!comparisonActive && metricValue !== undefined && scaleMode === 'absolute' ? (
|
||||
<span className="text-muted-foreground"> {metricLabel}</span>
|
||||
) : null}
|
||||
{showMetricLabel ? <span className="text-muted-foreground"> {metricLabel}</span> : null}
|
||||
{!comparisonActive && metricValue !== undefined && scaleMode === 'percentValid' ? (
|
||||
<span className="text-muted-foreground"> dos válidos</span>
|
||||
) : null}
|
||||
{comparisonActive && metricValue !== undefined ? (
|
||||
<span className="text-muted-foreground"> (diferença)</span>
|
||||
) : null}
|
||||
{relativeReading ? (
|
||||
<span className="text-muted-foreground"> · {relativeReading}</span>
|
||||
) : null}
|
||||
</p>
|
||||
{showPercentSecondary ? (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatElectionNumber(rawMetricValue)} votos
|
||||
</p>
|
||||
) : null}
|
||||
{/* The bubble's own line: mixing "votes he got" and "votes at stake"
|
||||
into one sentence is how a reader ends up quoting the wrong one. */}
|
||||
{bubbleReading ? <p className="text-xs text-muted-foreground">{bubbleReading}</p> : null}
|
||||
{navigation?.kind === 'zones' ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Toque de novo no mapa ou role até os Municípios por zona abaixo.
|
||||
|
|
|
|||
61
src/components/campaign/map/MapScaleLegend.tsx
Normal file
61
src/components/campaign/map/MapScaleLegend.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { campaignConceptHref, type CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
|
||||
import type { MapScaleClassing } from '@/lib/mapScaleClasses'
|
||||
|
||||
type MapScaleLegendProps = {
|
||||
classing: MapScaleClassing
|
||||
/** What the swatches measure — "votos de Jorge Solla em 2022". */
|
||||
metricLabel: string
|
||||
/** What the colour means plus any caveat (small scope, missing data). */
|
||||
note: string
|
||||
/** Ties the note to the scale selector through `aria-describedby`. */
|
||||
noteId: string
|
||||
/** Glossary entry for this scale, when it has one (E18). */
|
||||
conceptID?: CampaignConceptId
|
||||
}
|
||||
|
||||
/**
|
||||
* Discrete counterpart of `ChoroplethLegend`: a swatch per class with its real
|
||||
* range, weakest on the left. A gradient bar cannot answer "which band is this
|
||||
* município in", which is the only question a classed map is asked.
|
||||
*/
|
||||
export const MapScaleLegend = ({
|
||||
classing,
|
||||
metricLabel,
|
||||
note,
|
||||
noteId,
|
||||
conceptID,
|
||||
}: MapScaleLegendProps) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ul
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1.5"
|
||||
aria-label={`Faixas de cor para ${metricLabel}`}
|
||||
>
|
||||
{classing.classes.map((entry) => (
|
||||
<li key={entry.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 rounded-[3px] ring-1 ring-foreground/15"
|
||||
style={{ backgroundColor: entry.fill }}
|
||||
/>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{entry.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p id={noteId} className="text-xs text-muted-foreground">
|
||||
{note}
|
||||
{conceptID ? (
|
||||
<>
|
||||
{' '}
|
||||
<Link
|
||||
href={campaignConceptHref(conceptID)}
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Saiba mais
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -4,19 +4,32 @@ import Link from 'next/link'
|
|||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
|
||||
|
||||
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap'
|
||||
import {
|
||||
BahiaMap,
|
||||
BUBBLE_MAX_RADIUS,
|
||||
type BahiaMapFeatureInfo,
|
||||
} from '@/components/campaign/map/BahiaMap'
|
||||
import { ChoroplethLegend } from '@/components/campaign/map/ChoroplethLegend'
|
||||
import { MapFeatureReadout } from '@/components/campaign/map/MapFeatureReadout'
|
||||
import { MapScaleLegend } from '@/components/campaign/map/MapScaleLegend'
|
||||
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
|
||||
import {
|
||||
VOTE_ESTIMATE_SCENARIO_MAP_HINT,
|
||||
VoteEstimateScenarioField,
|
||||
} from '@/components/campaign/votePledge/VoteEstimateScenarioField'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { computeChoroplethMax } from '@/lib/bahiaMapStyle'
|
||||
import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
|
||||
import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethColorScale'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import {
|
||||
buildCompetitiveRankClassing,
|
||||
buildLqClassing,
|
||||
buildQuantileClassing,
|
||||
fillsForClassing,
|
||||
} from '@/lib/mapScaleClasses'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
voteEstimateScenarioLabels,
|
||||
|
|
@ -24,8 +37,16 @@ import {
|
|||
} from '@/lib/voteEstimate'
|
||||
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
|
||||
import {
|
||||
formatDominanceAgainstOwnStandard,
|
||||
territorialClassLabels,
|
||||
territorialClassMapFill,
|
||||
} from '@/utilities/municipalityLabels'
|
||||
import {
|
||||
DEFAULT_MUNICIPALITY_MAP_SCALE_MODE,
|
||||
isMunicipalityMapScaleModeAvailable,
|
||||
MUNICIPALITY_MAP_SCALE_MODES,
|
||||
MUNICIPALITY_MAP_YEARS,
|
||||
municipalityMapScaleModeHints,
|
||||
municipalityMapScaleModeLabels,
|
||||
municipalityMapYearLabels,
|
||||
type MunicipalityMapBundle,
|
||||
|
|
@ -33,6 +54,74 @@ import {
|
|||
type MunicipalityMapYear,
|
||||
} from '@/utilities/municipalityMapContract'
|
||||
import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
|
||||
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
|
||||
|
||||
/** The legend's note doubles as the scale selector's description. */
|
||||
const SCALE_NOTE_ID = 'municipality-map-scale-note'
|
||||
|
||||
/**
|
||||
* Scales that have their own glossary entry (E18). LQ is documented as
|
||||
* "dominância relativa" — the same ratio under the name the list already uses.
|
||||
*/
|
||||
const SCALE_CONCEPT_ID: Partial<Record<MunicipalityMapScaleMode, CampaignConceptId>> = {
|
||||
quantile: 'quantis-do-mapa',
|
||||
lq: 'dominancia-relativa',
|
||||
competitiveRank: 'posicao-no-municipio',
|
||||
}
|
||||
|
||||
/** Reference sizes in the bubble key: the largest bubble and a quarter of it. */
|
||||
const BUBBLE_KEY_FRACTIONS = [0.25, 1] as const
|
||||
|
||||
/**
|
||||
* Two encodings need decoding when the layer is on — how big the prize is
|
||||
* (area) and how he stands there (colour) — so the key carries both, with
|
||||
* real circles rather than a sentence about sizes nobody can eyeball.
|
||||
*/
|
||||
const MapBubbleKey = ({ largestValue }: { largestValue: number }) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||
<ul className="flex items-center gap-3" aria-label="Tamanho da bolha por votos em jogo">
|
||||
{BUBBLE_KEY_FRACTIONS.map((fraction) => {
|
||||
const diameter = 2 * BUBBLE_MAX_RADIUS * Math.sqrt(fraction)
|
||||
return (
|
||||
<li key={fraction} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shrink-0 rounded-full bg-muted-foreground/70 ring-1 ring-background"
|
||||
style={{ width: diameter, height: diameter }}
|
||||
/>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(Math.round(largestValue * fraction))}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<ul
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1.5"
|
||||
aria-label="Cor da bolha por classe do município"
|
||||
>
|
||||
{Object.entries(territorialClassLabels).map(([territorialClass, label]) => (
|
||||
<li key={territorialClass} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-background"
|
||||
style={{
|
||||
backgroundColor:
|
||||
territorialClassMapFill[territorialClass as MunicipalityTerritorialClass],
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tamanho = votos válidos projetados para 2026, o que está em jogo ali. Cor = classe do
|
||||
município, a leitura da lista.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
type MunicipalityMapPanelProps = {
|
||||
bundle: MunicipalityMapBundle
|
||||
|
|
@ -49,7 +138,10 @@ export const MunicipalityMapPanel = ({
|
|||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [year, setYear] = useState<MunicipalityMapYear>(defaultYear)
|
||||
const [scaleMode, setScaleMode] = useState<MunicipalityMapScaleMode>('percentValid')
|
||||
const [scaleMode, setScaleMode] = useState<MunicipalityMapScaleMode>(
|
||||
DEFAULT_MUNICIPALITY_MAP_SCALE_MODE,
|
||||
)
|
||||
const [bubblesEnabled, setBubblesEnabled] = useState(false)
|
||||
const scenarioContext = useMunicipalityEstimateScenarioOptional()
|
||||
const [localEstimateScenario, setLocalEstimateScenario] = useState<VoteEstimateScenario>(
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
|
|
@ -59,7 +151,19 @@ export const MunicipalityMapPanel = ({
|
|||
|
||||
const comparison = bundle.comparison
|
||||
const comparisonActive = comparison !== null && year !== 2026
|
||||
const percentScaleActive = !comparisonActive && scaleMode === 'percentValid'
|
||||
|
||||
// Comparing forces the diverging absolute scale, and the competitive
|
||||
// placement has no 2026 reading (it is a TSE result). Rather than let the
|
||||
// select show a mode the map isn't painting, fall back and say so.
|
||||
const effectiveScaleMode: MunicipalityMapScaleMode = comparisonActive
|
||||
? 'absolute'
|
||||
: isMunicipalityMapScaleModeAvailable(scaleMode, year)
|
||||
? scaleMode
|
||||
: DEFAULT_MUNICIPALITY_MAP_SCALE_MODE
|
||||
const percentScaleActive = effectiveScaleMode === 'percentValid'
|
||||
// The compare map already spends colour on two candidates; a third encoding
|
||||
// on top of it would be unreadable, so the bubbles step aside there.
|
||||
const showBubbles = bubblesEnabled && !comparisonActive
|
||||
|
||||
const rawValues = useMemo(() => {
|
||||
if (comparisonActive && comparison) {
|
||||
|
|
@ -93,6 +197,83 @@ export const MunicipalityMapPanel = ({
|
|||
[bundle.municipalitiesByIbgeCode],
|
||||
)
|
||||
|
||||
/** LQ per município against his statewide standard — the same ratio E10 classes on. */
|
||||
const lqByCode = useMemo(() => {
|
||||
const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0
|
||||
if (statewideShare <= 0) return {}
|
||||
|
||||
const lq: Record<string, number> = {}
|
||||
for (const [code, votes] of Object.entries(rawValues)) {
|
||||
const validVotes = validVotesForYear[code] ?? 0
|
||||
if (votes <= 0 || validVotes <= 0) continue
|
||||
lq[code] = votes / validVotes / statewideShare
|
||||
}
|
||||
return lq
|
||||
}, [bundle.statewideShareByYear, rawValues, validVotesForYear, year])
|
||||
|
||||
const competitiveRankForYear = useMemo(
|
||||
() => bundle.competitiveRankByYear[String(year)] ?? {},
|
||||
[bundle.competitiveRankByYear, year],
|
||||
)
|
||||
|
||||
const classing = useMemo(() => {
|
||||
switch (effectiveScaleMode) {
|
||||
case 'quantile':
|
||||
return buildQuantileClassing(rawValues)
|
||||
case 'lq':
|
||||
return buildLqClassing(lqByCode)
|
||||
case 'competitiveRank':
|
||||
return buildCompetitiveRankClassing(
|
||||
Object.fromEntries(
|
||||
Object.entries(competitiveRankForYear).map(([code, placement]) => [
|
||||
code,
|
||||
placement.rank,
|
||||
]),
|
||||
),
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues])
|
||||
|
||||
const fillByKey = useMemo(() => (classing ? fillsForClassing(classing) : undefined), [classing])
|
||||
|
||||
/**
|
||||
* What the colour means, said ONCE and next to the swatches that need
|
||||
* decoding — putting it under the selector instead made the control column
|
||||
* taller than the title beside it, and repeated the sentence the legend was
|
||||
* already carrying.
|
||||
*/
|
||||
const legendNote = useMemo(() => {
|
||||
const parts = [municipalityMapScaleModeHints[effectiveScaleMode]]
|
||||
|
||||
if (classing) {
|
||||
const painted = Object.keys(classing.classIndexByKey).length
|
||||
const missing = scopedKeys.length - painted
|
||||
|
||||
if (effectiveScaleMode === 'quantile') {
|
||||
parts.push(
|
||||
classing.reduced
|
||||
? `Só ${formatElectionNumber(painted)} municípios do seu escopo têm votos — poucos para cinco, então a escala usa ${classing.classes.length}.`
|
||||
: `Aqui são ${formatElectionNumber(painted)} municípios com votos no seu escopo.`,
|
||||
)
|
||||
}
|
||||
if (effectiveScaleMode === 'lq') {
|
||||
parts.push('1× é a média estadual dele, não a dos municípios em exibição.')
|
||||
}
|
||||
if (effectiveScaleMode === 'competitiveRank') {
|
||||
parts.push('Vale para a cidade inteira: em Salvador, as 19 zonas dividem a mesma posição.')
|
||||
}
|
||||
if (missing > 0) {
|
||||
parts.push(
|
||||
`${formatElectionNumber(missing)} sem dado ${missing === 1 ? 'ficou' : 'ficaram'} em cinza.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}, [classing, effectiveScaleMode, scopedKeys.length])
|
||||
|
||||
const metricLabel = useMemo(() => {
|
||||
if (comparisonActive) {
|
||||
return `diferença de votos em ${year}`
|
||||
|
|
@ -108,6 +289,76 @@ export const MunicipalityMapPanel = ({
|
|||
return `votos de ${bundle.candidateName} em ${year}`
|
||||
}, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year])
|
||||
|
||||
/**
|
||||
* The relative sentence for one município — the class alone ("Q4") is a
|
||||
* verdict without evidence, so the readout always spells out what the colour
|
||||
* means for the município under the cursor.
|
||||
*/
|
||||
/**
|
||||
* Votes at stake, not votes won: the bubble asks "how big is the prize
|
||||
* here", which is a different question from the colour's "how is he doing
|
||||
* here" — reading them together is the point of the layer.
|
||||
*/
|
||||
const bubbleValues = useMemo(
|
||||
() => (showBubbles ? bundle.projectedValidVotesByCode : undefined),
|
||||
[bundle.projectedValidVotesByCode, showBubbles],
|
||||
)
|
||||
|
||||
const bubbleFillByKey = useMemo(() => {
|
||||
if (!showBubbles) return undefined
|
||||
const fills: Record<string, string> = {}
|
||||
for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) {
|
||||
fills[code] = territorialClassMapFill[territorialClass]
|
||||
}
|
||||
return fills
|
||||
}, [bundle.territorialClassByCode, showBubbles])
|
||||
|
||||
const largestBubbleValue = useMemo(() => {
|
||||
if (!bubbleValues) return 0
|
||||
return Object.values(bubbleValues).reduce((max, value) => Math.max(max, value), 0)
|
||||
}, [bubbleValues])
|
||||
|
||||
const bubbleReadingFor = useCallback(
|
||||
(key: string): string | null => {
|
||||
if (!showBubbles) return null
|
||||
|
||||
const projected = bundle.projectedValidVotesByCode[key] ?? 0
|
||||
if (projected <= 0) return null
|
||||
|
||||
const territorialClass = bundle.territorialClassByCode[key]
|
||||
const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null
|
||||
return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}`
|
||||
},
|
||||
[bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles],
|
||||
)
|
||||
|
||||
const relativeReadingFor = useCallback(
|
||||
(key: string): string | null => {
|
||||
if (!classing) return null
|
||||
|
||||
const classIndex = classing.classIndexByKey[key]
|
||||
switch (effectiveScaleMode) {
|
||||
case 'quantile': {
|
||||
if (classIndex === undefined) return null
|
||||
const range = classing.classes[classIndex]?.label
|
||||
return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}`
|
||||
}
|
||||
case 'lq': {
|
||||
const lq = lqByCode[key]
|
||||
return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq)
|
||||
}
|
||||
case 'competitiveRank': {
|
||||
const placement = competitiveRankForYear[key]
|
||||
if (!placement) return null
|
||||
return `${placement.rank}º entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui`
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[classing, competitiveRankForYear, effectiveScaleMode, lqByCode],
|
||||
)
|
||||
|
||||
// Optimistic on the control, honest pending on the map: the comparison data
|
||||
// is a server round trip, so the select flips at once and the map region
|
||||
// dims until the refreshed bundle arrives.
|
||||
|
|
@ -140,12 +391,12 @@ export const MunicipalityMapPanel = ({
|
|||
<h2 id="municipality-map-title" className="text-base font-medium">
|
||||
Mapa dos Municípios
|
||||
</h2>
|
||||
{/* What the map is made of stays here; what the COLOUR means is
|
||||
said once, next to the scale selector that decides it. */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{comparisonActive && comparison
|
||||
? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.`
|
||||
: percentScaleActive
|
||||
? `Cor pela participação nos votos válidos — em 2026, estimativas sobre válidos de 2022.`
|
||||
: `Cor pela votação de ${bundle.candidateName} — em 2026, pelas estimativas da campanha.`}
|
||||
: `Votação de ${bundle.candidateName} por município — em 2026, pelas estimativas da campanha.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
|
|
@ -165,15 +416,18 @@ export const MunicipalityMapPanel = ({
|
|||
</NativeSelect>
|
||||
</Field>
|
||||
{!comparisonActive ? (
|
||||
<Field className="w-full sm:w-44">
|
||||
<Field className="w-full sm:w-52">
|
||||
<FieldLabel htmlFor="municipality-map-scale">Escala</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-map-scale"
|
||||
value={scaleMode}
|
||||
value={effectiveScaleMode}
|
||||
onChange={(event) => setScaleMode(event.target.value as MunicipalityMapScaleMode)}
|
||||
className="min-h-11 w-full"
|
||||
aria-describedby={SCALE_NOTE_ID}
|
||||
>
|
||||
{MUNICIPALITY_MAP_SCALE_MODES.map((mode) => (
|
||||
{MUNICIPALITY_MAP_SCALE_MODES.filter((mode) =>
|
||||
isMunicipalityMapScaleModeAvailable(mode, year),
|
||||
).map((mode) => (
|
||||
<NativeSelectOption key={mode} value={mode}>
|
||||
{municipalityMapScaleModeLabels[mode]}
|
||||
</NativeSelectOption>
|
||||
|
|
@ -241,16 +495,48 @@ export const MunicipalityMapPanel = ({
|
|||
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
|
||||
</p>
|
||||
</div>
|
||||
) : classing && classing.classes.length > 0 ? (
|
||||
<MapScaleLegend
|
||||
classing={classing}
|
||||
metricLabel={metricLabel}
|
||||
note={legendNote}
|
||||
noteId={SCALE_NOTE_ID}
|
||||
conceptID={SCALE_CONCEPT_ID[effectiveScaleMode]}
|
||||
/>
|
||||
) : displayMax > 0 ? (
|
||||
<ChoroplethLegend
|
||||
max={displayMax}
|
||||
metricLabel={metricLabel}
|
||||
formatMax={percentScaleActive ? () => '100%' : undefined}
|
||||
note={legendNote}
|
||||
noteId={SCALE_NOTE_ID}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
|
||||
)}
|
||||
|
||||
{/* A second encoding, so it is opt-in and its key only appears with it. */}
|
||||
{!comparisonActive ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex min-h-11 w-fit items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={bubblesEnabled}
|
||||
onCheckedChange={(checked) => setBubblesEnabled(checked === true)}
|
||||
/>
|
||||
Bolhas por votos em jogo
|
||||
</label>
|
||||
{showBubbles ? (
|
||||
largestBubbleValue > 0 ? (
|
||||
<MapBubbleKey largestValue={largestBubbleValue} />
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sem projeção de votos válidos para os municípios do seu escopo.
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{comparison && year === 2026 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para
|
||||
|
|
@ -262,9 +548,14 @@ export const MunicipalityMapPanel = ({
|
|||
displayValues={displayValues}
|
||||
rawValues={rawValues}
|
||||
displayMax={displayMax}
|
||||
fillByKey={fillByKey}
|
||||
bubbleValues={bubbleValues}
|
||||
bubbleFillByKey={bubbleFillByKey}
|
||||
scopedKeys={scopedKeys}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={comparisonActive ? 'absolute' : scaleMode}
|
||||
scaleMode={effectiveScaleMode}
|
||||
relativeReadingFor={relativeReadingFor}
|
||||
bubbleReadingFor={bubbleReadingFor}
|
||||
comparisonActive={comparisonActive}
|
||||
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
|
||||
ariaLabel={
|
||||
|
|
@ -320,9 +611,14 @@ const MunicipalityMapSelection = ({
|
|||
displayValues,
|
||||
rawValues,
|
||||
displayMax,
|
||||
fillByKey,
|
||||
bubbleValues,
|
||||
bubbleFillByKey,
|
||||
scopedKeys,
|
||||
metricLabel,
|
||||
scaleMode,
|
||||
relativeReadingFor,
|
||||
bubbleReadingFor,
|
||||
comparisonActive,
|
||||
municipalitiesByIbgeCode,
|
||||
ariaLabel,
|
||||
|
|
@ -330,9 +626,14 @@ const MunicipalityMapSelection = ({
|
|||
displayValues: Record<string, number>
|
||||
rawValues: Record<string, number>
|
||||
displayMax: number
|
||||
fillByKey: Record<string, string> | undefined
|
||||
bubbleValues: Record<string, number> | undefined
|
||||
bubbleFillByKey: Record<string, string> | undefined
|
||||
scopedKeys: string[]
|
||||
metricLabel: string
|
||||
scaleMode: MunicipalityMapScaleMode
|
||||
relativeReadingFor: (key: string) => string | null
|
||||
bubbleReadingFor: (key: string) => string | null
|
||||
comparisonActive: boolean
|
||||
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
|
||||
ariaLabel: string
|
||||
|
|
@ -388,6 +689,9 @@ const MunicipalityMapSelection = ({
|
|||
mode="municipality"
|
||||
values={displayValues}
|
||||
scaleMax={displayMax > 0 ? displayMax : undefined}
|
||||
fillByKey={fillByKey}
|
||||
bubbleValues={bubbleValues}
|
||||
bubbleFillByKey={bubbleFillByKey}
|
||||
fillMode={comparisonActive ? 'diverging' : 'sequential'}
|
||||
fitToKeys={scopedKeys}
|
||||
interactiveKeys={scopedKeys}
|
||||
|
|
@ -403,6 +707,8 @@ const MunicipalityMapSelection = ({
|
|||
rawMetricValue={selectedRawMetricValue}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={scaleMode}
|
||||
relativeReading={selectedFeature ? relativeReadingFor(selectedFeature.key) : null}
|
||||
bubbleReading={selectedFeature ? bubbleReadingFor(selectedFeature.key) : null}
|
||||
comparisonActive={comparisonActive}
|
||||
navigation={selectedNavigation}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
|
|||
* this artifact; a future consumer that needs it extends `majoritarian2022`
|
||||
* into a `majoritarianByYear` map.
|
||||
*
|
||||
* v3 (B13 "posição no município"): adds `federalRankByIbgeCode` — where the
|
||||
* candidate placed among every federal-deputy candidate voted inside that
|
||||
* municipality. Keyed by IBGE code, not by catalog slug, because it is the
|
||||
* map's key and because the competitive question only has an answer for a
|
||||
* whole city: Salvador's 19 zone municipalities share one placement.
|
||||
*
|
||||
* Generated by `pnpm build:election-aggregates` from the locally seeded TSE
|
||||
* data — never computed at build or request time (see the script header for
|
||||
* the rationale). A snapshot test pins artifact keys ↔ catalog slugs.
|
||||
|
|
@ -50,12 +56,22 @@ export type MunicipalityFederalBaseline = {
|
|||
}
|
||||
}
|
||||
|
||||
/** Where the candidate placed among the federal-deputy candidates voted in one municipality. */
|
||||
export type FederalCompetitiveRank = {
|
||||
/** 1 = most voted. Ties share a placement (competition rank). */
|
||||
rank: number
|
||||
/** Candidates with at least one vote there — the honest denominator for "12º de …". */
|
||||
candidates: number
|
||||
}
|
||||
|
||||
type FederalBaselineArtifact = {
|
||||
provenance: string
|
||||
candidateNumber: number
|
||||
candidateName: string
|
||||
years: number[]
|
||||
municipalities: Record<string, MunicipalityFederalBaseline>
|
||||
/** IBGE codarea → year (as string) → placement. Absent where he took no votes. */
|
||||
federalRankByIbgeCode: Record<string, Record<string, FederalCompetitiveRank>>
|
||||
}
|
||||
|
||||
const artifact = artifactJson as FederalBaselineArtifact
|
||||
|
|
@ -79,6 +95,15 @@ const EMPTY_BASELINE: MunicipalityFederalBaseline = {
|
|||
export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline =>
|
||||
artifact.municipalities[slug] ?? EMPTY_BASELINE
|
||||
|
||||
/**
|
||||
* Competitive placement inside one IBGE municipality for one year, or `null`
|
||||
* where the candidate took no votes there (never a misleading last place).
|
||||
*/
|
||||
export const getFederalCompetitiveRank = (
|
||||
ibgeCode: string,
|
||||
year: number,
|
||||
): FederalCompetitiveRank | null => artifact.federalRankByIbgeCode[ibgeCode]?.[String(year)] ?? null
|
||||
|
||||
/** Statewide sums of the artifact for one year — the denominators of every relative reading. */
|
||||
type StatewideFederalTotals = {
|
||||
/** Candidate's own nominal votes across every catalog municipality. */
|
||||
|
|
|
|||
|
|
@ -13,20 +13,32 @@ export type BahiaMapFillMode = 'sequential' | 'diverging'
|
|||
|
||||
export type FeatureKeyProperty = 'codarea' | 'code'
|
||||
|
||||
/**
|
||||
* Feature key → fill, for the discrete relative scales (B13). When present it
|
||||
* REPLACES the continuous ramp: a key absent from the record has no class and
|
||||
* renders as "no data", which is not the same as a value of zero.
|
||||
*/
|
||||
export type ChoroplethFills = Record<string, string>
|
||||
|
||||
export type LayerStyleContext = {
|
||||
values: ChoroplethValues
|
||||
fillMode: BahiaMapFillMode
|
||||
max: number
|
||||
fillByKey?: ChoroplethFills
|
||||
highlightSet: Set<string>
|
||||
selectedKey: string | null
|
||||
hoveredKey: string | null
|
||||
}
|
||||
|
||||
const NO_DATA_FILL = '#f4f4f5'
|
||||
|
||||
type FeatureStyleInput = {
|
||||
metric: number
|
||||
highlighted: boolean
|
||||
fillMode: BahiaMapFillMode
|
||||
max: number
|
||||
classFill?: string
|
||||
classed: boolean
|
||||
}
|
||||
|
||||
const getFeatureStyle = ({
|
||||
|
|
@ -34,12 +46,17 @@ const getFeatureStyle = ({
|
|||
highlighted,
|
||||
fillMode,
|
||||
max,
|
||||
classFill,
|
||||
classed,
|
||||
}: FeatureStyleInput): PathOptions => ({
|
||||
weight: highlighted ? 2 : 1,
|
||||
color: highlighted ? '#c51414' : '#a8a29e',
|
||||
fillColor:
|
||||
fillMode === 'diverging' ? divergingFillColor(metric, max) : choroplethFillColor(metric, max),
|
||||
fillOpacity: metric !== 0 ? 0.78 : 0.35,
|
||||
fillColor: classed
|
||||
? (classFill ?? NO_DATA_FILL)
|
||||
: fillMode === 'diverging'
|
||||
? divergingFillColor(metric, max)
|
||||
: choroplethFillColor(metric, max),
|
||||
fillOpacity: (classed ? classFill !== undefined : metric !== 0) ? 0.78 : 0.35,
|
||||
})
|
||||
|
||||
export const computeChoroplethMax = (
|
||||
|
|
@ -65,12 +82,15 @@ export const featureKeyFromProperties = (
|
|||
): string | undefined => properties?.[keyProperty]
|
||||
|
||||
export const resolvePathStyle = (context: LayerStyleContext, key: string): PathOptions => {
|
||||
const classed = context.fillByKey !== undefined
|
||||
|
||||
if (!key) {
|
||||
return getFeatureStyle({
|
||||
metric: 0,
|
||||
highlighted: false,
|
||||
fillMode: context.fillMode,
|
||||
max: context.max,
|
||||
classed,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +103,8 @@ export const resolvePathStyle = (context: LayerStyleContext, key: string): PathO
|
|||
highlighted,
|
||||
fillMode: context.fillMode,
|
||||
max: context.max,
|
||||
classFill: context.fillByKey?.[key],
|
||||
classed,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +112,7 @@ export const buildLayerStyleContext = ({
|
|||
values,
|
||||
fillMode,
|
||||
scaleMax,
|
||||
fillByKey,
|
||||
highlightKey,
|
||||
selectedKey,
|
||||
hoveredKey = null,
|
||||
|
|
@ -97,6 +120,7 @@ export const buildLayerStyleContext = ({
|
|||
values: ChoroplethValues
|
||||
fillMode: BahiaMapFillMode
|
||||
scaleMax?: number
|
||||
fillByKey?: ChoroplethFills
|
||||
highlightKey: string
|
||||
selectedKey: string | null
|
||||
hoveredKey?: string | null
|
||||
|
|
@ -104,6 +128,7 @@ export const buildLayerStyleContext = ({
|
|||
values,
|
||||
fillMode,
|
||||
max: computeChoroplethMax(values, fillMode, scaleMax),
|
||||
fillByKey,
|
||||
highlightSet: buildHighlightSet(highlightKey),
|
||||
selectedKey,
|
||||
hoveredKey,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
* actually computes; when a formula changes, this text changes with it.
|
||||
*
|
||||
* v1 covers the E8 "conta da cadeira" numbers; E10 added dominância relativa
|
||||
* and a classe territorial. Each later slice of the intelligence program (B13
|
||||
* escala do mapa, E11 sugestões, E12 TI, E13 giros, E14 níveis) appends its
|
||||
* own entries as part of its own delivery.
|
||||
* and a classe territorial; B13 added the two map scales that are not just a
|
||||
* re-rendering of an existing number (quantis and posição no município — LQ is
|
||||
* already documented as dominância relativa). Each later slice of the
|
||||
* intelligence program (E11 sugestões, E12 TI, E13 giros, E14 níveis) appends
|
||||
* its own entries as part of its own delivery.
|
||||
*/
|
||||
|
||||
export type CampaignConceptId =
|
||||
|
|
@ -23,6 +25,8 @@ export type CampaignConceptId =
|
|||
| 'roll-off'
|
||||
| 'dominancia-relativa'
|
||||
| 'classe-territorial'
|
||||
| 'quantis-do-mapa'
|
||||
| 'posicao-no-municipio'
|
||||
| 'meta'
|
||||
| 'cobertura-da-meta'
|
||||
|
||||
|
|
@ -146,6 +150,34 @@ export const campaignIntelligenceConcepts: ReadonlyArray<CampaignIntelligenceCon
|
|||
whereItAppears:
|
||||
'Card "Conta da cadeira" e capa do dossiê, no detalhe do município, e a coluna "Classe" da lista de municípios (com filtro e ordenação).',
|
||||
},
|
||||
{
|
||||
id: 'quantis-do-mapa',
|
||||
categoryID: 'diagnostico',
|
||||
title: 'Quantis (escala do mapa)',
|
||||
oneLiner:
|
||||
'Divide os municípios em cinco faixas com o mesmo número de municípios cada, da menor votação para a maior.',
|
||||
formula:
|
||||
'Ordena os municípios do seu escopo que tiveram votos e corta a lista em cinco partes iguais em QUANTIDADE de municípios. Cada faixa da legenda mostra o menor e o maior valor real que caíram nela. Com menos de 10 municípios em tela a escala cai para três faixas, e a legenda avisa.',
|
||||
example:
|
||||
'Com 415 municípios, cada faixa tem cerca de 83. A faixa mais escura pode ir de 267 a 27.264 votos — as distâncias entre faixas não são iguais, o número de municípios é.',
|
||||
whyItMatters:
|
||||
'Numa disputa de deputado federal, uma escala de 0 a 100% pinta a Bahia inteira de uma cor só: o melhor município dele dá menos de 5% dos válidos de lá. O quantil sempre usa a paleta inteira, então o mapa volta a discriminar. O preço é que a cor é relativa ao conjunto em tela — não compare a cor de uma carteira de assessor com a cor do mapa estadual.',
|
||||
whereItAppears: 'Mapa dos Municípios, no Início — é a escala padrão.',
|
||||
},
|
||||
{
|
||||
id: 'posicao-no-municipio',
|
||||
categoryID: 'diagnostico',
|
||||
title: 'Posição no município',
|
||||
oneLiner:
|
||||
'Em que lugar o Jorge Solla ficou entre todos os candidatos a deputado federal votados naquele município.',
|
||||
formula:
|
||||
'Ordena todos os candidatos a deputado federal por votos no município (1º turno do ano escolhido) e lê a colocação dele. Vale para a cidade inteira: em Salvador, as 19 zonas dividem a mesma posição, porque o mapa desenha a cidade e não a zona.',
|
||||
example:
|
||||
'5.005 votos em Vitória da Conquista o colocaram em 6º entre 663 candidatos votados lá.',
|
||||
whyItMatters:
|
||||
'É a leitura de competição que falta nas outras escalas: dominância compara ele com ele mesmo, e a posição compara ele com quem disputa a mesma cadeira ali. Ser 1º num município pequeno é uma relação política concreta; ser 40º num município grande diz que o voto está pulverizado. Só existe para anos com resultado do TSE — 2026 não tem posição.',
|
||||
whereItAppears: 'Mapa dos Municípios, no Início (escala "Posição no município").',
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
categoryID: 'meta',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,24 @@ export const choroplethFillColor = (value: number, max: number): string => {
|
|||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same sequential ramp sampled at `total` evenly spaced steps — the fill
|
||||
* for class `index` (0 = weakest). Discrete classes are what make a relative
|
||||
* scale readable at a glance: a continuous ramp over a federal-deputy race
|
||||
* paints almost the whole state the same shade (B11's lesson).
|
||||
*
|
||||
* The floor keeps the lightest class distinguishable from "no data" (#f4f4f5).
|
||||
*/
|
||||
const DISCRETE_CLASS_FLOOR = 0.18
|
||||
|
||||
export const discreteChoroplethFill = (index: number, total: number): string => {
|
||||
if (total <= 0) return '#f4f4f5'
|
||||
if (total === 1) return choroplethFillColor(1, 1)
|
||||
|
||||
const step = index / (total - 1)
|
||||
return choroplethFillColor(DISCRETE_CLASS_FLOOR + (1 - DISCRETE_CLASS_FLOOR) * step, 1)
|
||||
}
|
||||
|
||||
export const choroplethMaxValue = (values: Record<string, number>): number => {
|
||||
const entries = Object.values(values).filter((value) => value > 0)
|
||||
return entries.length > 0 ? Math.max(...entries) : 0
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
201
src/lib/mapScaleClasses.ts
Normal file
201
src/lib/mapScaleClasses.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { discreteChoroplethFill } from '@/lib/choroplethColorScale'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
|
||||
|
||||
/**
|
||||
* B13 — relative map scales: the pure statistics behind the choropleth's
|
||||
* discrete classes, with no Leaflet and no data access, so the map and its
|
||||
* legend read the same source and a unit test can pin every cut.
|
||||
*
|
||||
* Why relative at all: "% dos válidos" (B11) is honest and useless here — a
|
||||
* federal-deputy candidate tops out around 5% of a município's valid votes,
|
||||
* so a 0–100% ramp paints Bahia one colour. Every scale below is measured
|
||||
* against the candidate himself (his own distribution, his own statewide
|
||||
* standard, his own placement), which is what discriminates.
|
||||
*/
|
||||
|
||||
type MapScaleClass = {
|
||||
/** Fill painted on the polygon and swatched in the legend. */
|
||||
fill: string
|
||||
/** pt-BR range label — "13–48", "2× ou mais", "2º–3º". */
|
||||
label: string
|
||||
}
|
||||
|
||||
export type MapScaleClassing = {
|
||||
/** Weakest first, so every legend reads left-to-right the same way. */
|
||||
classes: MapScaleClass[]
|
||||
/** Feature key → class index. Keys without a class are absent, never 0. */
|
||||
classIndexByKey: Record<string, number>
|
||||
/**
|
||||
* True when the set was too small to cut into the full number of classes —
|
||||
* the legend says so instead of implying a five-way split that isn't there.
|
||||
*/
|
||||
reduced: boolean
|
||||
}
|
||||
|
||||
const EMPTY_CLASSING: MapScaleClassing = { classes: [], classIndexByKey: {}, reduced: false }
|
||||
|
||||
const buildClassing = (
|
||||
ranges: ReadonlyArray<{ label: string }>,
|
||||
classIndexByKey: Record<string, number>,
|
||||
reduced: boolean,
|
||||
): MapScaleClassing => ({
|
||||
classes: ranges.map((range, index) => ({
|
||||
label: range.label,
|
||||
fill: discreteChoroplethFill(index, ranges.length),
|
||||
})),
|
||||
classIndexByKey,
|
||||
reduced,
|
||||
})
|
||||
|
||||
/** Full quantile split; below this many features the split degrades. */
|
||||
export const QUANTILE_CLASSES = 5
|
||||
|
||||
/**
|
||||
* An advisor's portfolio can be a handful of municípios. Cutting eight values
|
||||
* into five "quantiles" invents precision, so the split drops to three.
|
||||
*/
|
||||
export const QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT = 10
|
||||
const QUANTILE_REDUCED_CLASSES = 3
|
||||
|
||||
/**
|
||||
* Quantile classes over the positive values of the rendered set (Brewer &
|
||||
* Pickle: quantiles discriminate by construction and stay comparable across
|
||||
* years, unlike Jenks, whose breaks move with the data).
|
||||
*
|
||||
* Labels carry the real extremes of each class rather than the computed
|
||||
* break, so no gap is ever implied between "até 12" and "13–48".
|
||||
*/
|
||||
export const buildQuantileClassing = (values: Record<string, number>): MapScaleClassing => {
|
||||
const entries = Object.entries(values).filter(([, value]) => value > 0)
|
||||
if (entries.length === 0) return EMPTY_CLASSING
|
||||
|
||||
const sorted = entries.map(([, value]) => value).sort((left, right) => left - right)
|
||||
const distinct = new Set(sorted).size
|
||||
const desired =
|
||||
sorted.length < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT
|
||||
? QUANTILE_REDUCED_CLASSES
|
||||
: QUANTILE_CLASSES
|
||||
const count = Math.min(desired, distinct)
|
||||
|
||||
// Upper bound of each quantile, de-duplicated: heavy ties (many municípios
|
||||
// on the same vote count) can collapse two quantiles into one bound, and a
|
||||
// repeated bound would render as an empty class in the legend.
|
||||
const upperBounds: number[] = []
|
||||
for (let index = 1; index <= count; index += 1) {
|
||||
const position = Math.ceil((index * sorted.length) / count) - 1
|
||||
const bound = sorted[position]
|
||||
if (upperBounds.at(-1) !== bound) upperBounds.push(bound)
|
||||
}
|
||||
|
||||
const extremes = upperBounds.map(() => ({ min: Number.POSITIVE_INFINITY, max: 0 }))
|
||||
const classIndexByKey: Record<string, number> = {}
|
||||
for (const [key, value] of entries) {
|
||||
const index = upperBounds.findIndex((bound) => value <= bound)
|
||||
const resolved = index === -1 ? upperBounds.length - 1 : index
|
||||
classIndexByKey[key] = resolved
|
||||
extremes[resolved].min = Math.min(extremes[resolved].min, value)
|
||||
extremes[resolved].max = Math.max(extremes[resolved].max, value)
|
||||
}
|
||||
|
||||
const ranges = extremes.map(({ min, max }) => ({
|
||||
label:
|
||||
min === max
|
||||
? formatElectionNumber(min)
|
||||
: `${formatElectionNumber(min)}–${formatElectionNumber(max)}`,
|
||||
}))
|
||||
|
||||
return buildClassing(ranges, classIndexByKey, upperBounds.length < QUANTILE_CLASSES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two decimals, not one: the "no padrão" band starts at 0,95× and rounding
|
||||
* that to "1,0×" would print the same number on two adjacent legend swatches.
|
||||
*/
|
||||
const lqMultipleFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 })
|
||||
|
||||
const formatLqMultiple = (value: number): string => `${lqMultipleFormatter.format(value)}×`
|
||||
|
||||
/**
|
||||
* Location-quotient classes cut on the SAME anchors as E10's territorial
|
||||
* class, so the map and the list's "Classe" column can never disagree about
|
||||
* where "reduto" starts.
|
||||
*
|
||||
* Deliberately NOT the diverging red↔blue scale: in this product that palette
|
||||
* means "Solla × adversário" (the compare mode), and reusing it for "above ×
|
||||
* below his own standard" would make two different questions look identical.
|
||||
*/
|
||||
const LQ_CLASS_RANGES = [
|
||||
{
|
||||
upperBound: TERRITORIAL_CLASS_ANCHORS.weakLq,
|
||||
label: `menos de ${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.weakLq)}`,
|
||||
},
|
||||
{
|
||||
upperBound: AT_STANDARD_LQ.min,
|
||||
label: `${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.weakLq)}–${formatLqMultiple(AT_STANDARD_LQ.min)}`,
|
||||
},
|
||||
{ upperBound: AT_STANDARD_LQ.max, label: 'no padrão (≈1×)' },
|
||||
{
|
||||
upperBound: TERRITORIAL_CLASS_ANCHORS.strongLq,
|
||||
label: `${formatLqMultiple(AT_STANDARD_LQ.max)}–${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.strongLq)}`,
|
||||
},
|
||||
{
|
||||
upperBound: Number.POSITIVE_INFINITY,
|
||||
label: `${formatLqMultiple(TERRITORIAL_CLASS_ANCHORS.strongLq)} ou mais`,
|
||||
},
|
||||
] as const
|
||||
|
||||
export const buildLqClassing = (lqByKey: Record<string, number>): MapScaleClassing => {
|
||||
const classIndexByKey: Record<string, number> = {}
|
||||
for (const [key, lq] of Object.entries(lqByKey)) {
|
||||
if (!(lq > 0)) continue
|
||||
classIndexByKey[key] = LQ_CLASS_RANGES.findIndex((range) => lq < range.upperBound)
|
||||
}
|
||||
|
||||
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
|
||||
return buildClassing(LQ_CLASS_RANGES, classIndexByKey, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Competitive placement, ordered weakest first so the legend keeps the same
|
||||
* left-to-right reading as the other scales even though a LOWER placement
|
||||
* number is the better one.
|
||||
*/
|
||||
const COMPETITIVE_RANK_RANGES = [
|
||||
{ label: '4º ou pior' },
|
||||
{ label: '2º–3º' },
|
||||
{ label: '1º' },
|
||||
] as const
|
||||
|
||||
/** Worst placement still inside each class, read from the strongest end. */
|
||||
const COMPETITIVE_RANK_TOP = 1
|
||||
const COMPETITIVE_RANK_CONTENDER = 3
|
||||
|
||||
const competitiveRankClassIndex = (rank: number): number => {
|
||||
if (rank <= COMPETITIVE_RANK_TOP) return 2
|
||||
if (rank <= COMPETITIVE_RANK_CONTENDER) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export const buildCompetitiveRankClassing = (
|
||||
rankByKey: Record<string, number>,
|
||||
): MapScaleClassing => {
|
||||
const classIndexByKey: Record<string, number> = {}
|
||||
for (const [key, rank] of Object.entries(rankByKey)) {
|
||||
if (!(rank >= 1)) continue
|
||||
classIndexByKey[key] = competitiveRankClassIndex(rank)
|
||||
}
|
||||
|
||||
if (Object.keys(classIndexByKey).length === 0) return EMPTY_CLASSING
|
||||
return buildClassing(COMPETITIVE_RANK_RANGES, classIndexByKey, false)
|
||||
}
|
||||
|
||||
/** Feature key → fill, ready for `BahiaMap`'s `fillByKey`. */
|
||||
export const fillsForClassing = (classing: MapScaleClassing): Record<string, string> => {
|
||||
const fills: Record<string, string> = {}
|
||||
for (const [key, index] of Object.entries(classing.classIndexByKey)) {
|
||||
const fill = classing.classes[index]?.fill
|
||||
if (fill) fills[key] = fill
|
||||
}
|
||||
return fills
|
||||
}
|
||||
27
src/lib/territorialClassAnchors.ts
Normal file
27
src/lib/territorialClassAnchors.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* The cut points behind E10's territorial class, in one client-safe module.
|
||||
*
|
||||
* They live in `lib/` rather than next to the classifier because the map
|
||||
* (B13) paints an LQ scale on the SAME cuts the list's "Classe" column uses,
|
||||
* and the map panel is a client component: importing the classifier there
|
||||
* would drag the committed TSE artifact (~600 KB of JSON) into the browser
|
||||
* bundle. Numbers only, no data, no logic — the classifier
|
||||
* (`municipalityTerritorialClass.ts`) and the copy
|
||||
* (`municipalityLabels.ts`) both read from here, so a recalibration is still
|
||||
* a one-line diff in one file.
|
||||
*
|
||||
* Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape
|
||||
* (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact
|
||||
* numbers can only come from a backtest against 2014–2022 — that is **E15**.
|
||||
*/
|
||||
export const TERRITORIAL_CLASS_ANCHORS = {
|
||||
/** LQ at or above this = performing at least 2× his own statewide standard. */
|
||||
strongLq: 2,
|
||||
/** LQ below this = performing at less than half his own standard. */
|
||||
weakLq: 0.5,
|
||||
/** Municípios that together hold this share of his statewide vote are the "core block". */
|
||||
coreCumulativeShare: 0.5,
|
||||
} as const
|
||||
|
||||
/** Band where the LQ multiple rounds to "1×", which says nothing — name it instead. */
|
||||
export const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const
|
||||
|
|
@ -316,6 +316,72 @@ export const loadCampoFederalVotesByCityZone = async (
|
|||
return votes
|
||||
}
|
||||
|
||||
/**
|
||||
* cityCode:zone → (candidateNumber → nominal deputado-federal T1 votes) for
|
||||
* one year — the whole statewide slice, ungrouped by candidate, so a caller
|
||||
* can fold it over any municipality geography and rank candidates against
|
||||
* each other (B13's "posição no município").
|
||||
*
|
||||
* CLI/build-time use only, same as `loadCampoFederalVotesByCityZone`: this
|
||||
* scans ~100k rows and must never run on a request path — the ranking it
|
||||
* feeds is precomputed into the committed artifact.
|
||||
*/
|
||||
export const loadFederalVotesByCityZoneAndCandidate = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year }: { year: number },
|
||||
): Promise<Map<string, Map<number, number>>> => {
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'electionCandidateVote',
|
||||
where: {
|
||||
and: [
|
||||
{ year: { equals: year } },
|
||||
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
|
||||
{ turn: { equals: '1' } },
|
||||
{ voteType: { equals: 'nominal' } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { cityCode: true, zoneNumber: true, candidateNumber: true, votes: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const byCityZone = new Map<string, Map<number, number>>()
|
||||
for (const row of result.docs) {
|
||||
const key = `${row.cityCode}:${row.zoneNumber}`
|
||||
let byCandidate = byCityZone.get(key)
|
||||
if (!byCandidate) {
|
||||
byCandidate = new Map<number, number>()
|
||||
byCityZone.set(key, byCandidate)
|
||||
}
|
||||
byCandidate.set(
|
||||
row.candidateNumber,
|
||||
(byCandidate.get(row.candidateNumber) ?? 0) + (row.votes ?? 0),
|
||||
)
|
||||
}
|
||||
return byCityZone
|
||||
}
|
||||
|
||||
/** Folds `loadFederalVotesByCityZoneAndCandidate` over one municipality's cityCode×zones. */
|
||||
export const sumCandidateVotesForGeography = (
|
||||
votesByCityZoneAndCandidate: Map<string, Map<number, number>>,
|
||||
geography: MunicipalityElectionGeography,
|
||||
): Map<number, number> => {
|
||||
const total = new Map<number, number>()
|
||||
for (const zone of geography.zones) {
|
||||
const byCandidate = votesByCityZoneAndCandidate.get(`${geography.cityCode}:${zone}`)
|
||||
if (!byCandidate) continue
|
||||
for (const [candidateNumber, votes] of byCandidate) {
|
||||
total.set(candidateNumber, (total.get(candidateNumber) ?? 0) + votes)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Turnout/valid/blank/null tally cell — same shape the committed artifact stores per year. */
|
||||
export type OfficeTallyCell = FederalBaselineTallyCell
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
oneDecimalFormatter,
|
||||
} from '@/lib/electionFormat'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { AT_STANDARD_LQ } from '@/lib/territorialClassAnchors'
|
||||
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage'
|
||||
|
|
@ -83,8 +84,24 @@ export const territorialClassBadgeVariant = {
|
|||
sem_base: 'ghost',
|
||||
} as const
|
||||
|
||||
/** Band where the multiple rounds to "1×", which says nothing — name it instead. */
|
||||
const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const
|
||||
/**
|
||||
* The same ladder as fills for the map's proportional symbols (B13). Literal
|
||||
* hex rather than the CSS tokens above because Leaflet writes the SVG `fill`
|
||||
* ATTRIBUTE, where `var(--estimate-confirmed)` does not resolve — these mirror
|
||||
* the token values in `styles.css` (see `DESIGN.md` § Status Badge) and must
|
||||
* be changed together with them.
|
||||
*
|
||||
* Saturated over a basemap: the badge tints are tuned for a white row and
|
||||
* would disappear on top of tiles, so each step is the readable sibling of
|
||||
* its token, not the token itself.
|
||||
*/
|
||||
export const territorialClassMapFill: Record<MunicipalityTerritorialClass, string> = {
|
||||
reduto: '#16a34a',
|
||||
expansao: '#d97706',
|
||||
manutencao: '#78716c',
|
||||
marginal: '#a8a29e',
|
||||
sem_base: '#d6d3d1',
|
||||
}
|
||||
|
||||
/**
|
||||
* The "por quê" behind a class, in the mesa's own phrasing — "aqui a votação
|
||||
|
|
@ -92,15 +109,19 @@ const AT_STANDARD_LQ = { min: 0.95, max: 1.15 } as const
|
|||
* survives the table; "LQ 0,6" is not. The possessive names the candidate
|
||||
* ("do candidato"), never "seu", which an assessor reads as their own.
|
||||
*/
|
||||
export const formatDominanceAgainstOwnStandard = (lq: number): string => {
|
||||
if (lq >= AT_STANDARD_LQ.min && lq < AT_STANDARD_LQ.max) {
|
||||
return 'no padrão estadual do candidato'
|
||||
}
|
||||
return lq >= 1
|
||||
? `${oneDecimalFormatter.format(lq)}× o padrão estadual do candidato`
|
||||
: `${formatRatioAsPercentLabel(1 - lq)} abaixo do padrão estadual do candidato`
|
||||
}
|
||||
|
||||
const formatTerritorialClassFactor = (factor: TerritorialFactor): string => {
|
||||
switch (factor.id) {
|
||||
case 'dominance':
|
||||
if (factor.value >= AT_STANDARD_LQ.min && factor.value < AT_STANDARD_LQ.max) {
|
||||
return 'no padrão estadual do candidato'
|
||||
}
|
||||
return factor.value >= 1
|
||||
? `${oneDecimalFormatter.format(factor.value)}× o padrão estadual do candidato`
|
||||
: `${formatRatioAsPercentLabel(1 - factor.value)} abaixo do padrão estadual do candidato`
|
||||
return formatDominanceAgainstOwnStandard(factor.value)
|
||||
case 'ownShare':
|
||||
return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato`
|
||||
case 'field':
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates'
|
||||
import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
|
||||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
|
||||
import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass'
|
||||
|
||||
/**
|
||||
* Client-safe contract of the municipality map: the bundle shape the server
|
||||
|
|
@ -13,18 +15,54 @@ import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNaviga
|
|||
export const MUNICIPALITY_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
|
||||
export type MunicipalityMapYear = (typeof MUNICIPALITY_MAP_YEARS)[number]
|
||||
|
||||
export type MunicipalityMapScaleMode = 'absolute' | 'percentValid'
|
||||
/**
|
||||
* B13: the first three modes are RELATIVE to the candidate himself and are
|
||||
* what actually discriminate on a federal-deputy map — his best município
|
||||
* gives him ~5% of its valid votes, so `percentValid` and `absolute` paint
|
||||
* Bahia in one shade plus Salvador. The two absolute modes stay because they
|
||||
* answer "how many votes, really", which the relative ones deliberately hide.
|
||||
*/
|
||||
export type MunicipalityMapScaleMode =
|
||||
| 'quantile'
|
||||
| 'lq'
|
||||
| 'competitiveRank'
|
||||
| 'percentValid'
|
||||
| 'absolute'
|
||||
|
||||
export const MUNICIPALITY_MAP_SCALE_MODES = [
|
||||
'quantile',
|
||||
'lq',
|
||||
'competitiveRank',
|
||||
'percentValid',
|
||||
'absolute',
|
||||
] as const satisfies readonly MunicipalityMapScaleMode[]
|
||||
|
||||
export const DEFAULT_MUNICIPALITY_MAP_SCALE_MODE: MunicipalityMapScaleMode = 'quantile'
|
||||
|
||||
export const municipalityMapScaleModeLabels: Record<MunicipalityMapScaleMode, string> = {
|
||||
absolute: 'Total (votos)',
|
||||
quantile: 'Quantis (5 faixas)',
|
||||
lq: 'Padrão próprio (LQ)',
|
||||
competitiveRank: 'Posição no município',
|
||||
percentValid: '% dos válidos',
|
||||
absolute: 'Total (votos)',
|
||||
}
|
||||
|
||||
/** What the colour means, printed with the legend the scale selector describes. */
|
||||
export const municipalityMapScaleModeHints: Record<MunicipalityMapScaleMode, string> = {
|
||||
quantile: 'Cinco faixas com o mesmo número de municípios.',
|
||||
lq: 'Força local comparada ao padrão estadual do próprio candidato.',
|
||||
competitiveRank: 'Colocação dele entre os candidatos a deputado federal votados ali.',
|
||||
percentValid:
|
||||
'Participação nos votos válidos — honesta, mas comprime quase tudo numa faixa estreita.',
|
||||
absolute: 'Votos em números absolutos — favorece os municípios grandes.',
|
||||
}
|
||||
|
||||
/** Scales that need TSE results and therefore have no 2026 reading. */
|
||||
export const isMunicipalityMapScaleModeAvailable = (
|
||||
mode: MunicipalityMapScaleMode,
|
||||
year: MunicipalityMapYear,
|
||||
): boolean => mode !== 'competitiveRank' || year !== 2026
|
||||
|
||||
export const municipalityMapYearLabels: Record<MunicipalityMapYear, string> = {
|
||||
2014: '2014 (TSE)',
|
||||
2018: '2018 (TSE)',
|
||||
|
|
@ -53,6 +91,19 @@ export type MunicipalityMapBundle = {
|
|||
values2026ByScenario: Record<VoteEstimateScenario, Record<string, number>>
|
||||
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
|
||||
validVotesByYear: Record<string, Record<string, number>>
|
||||
/**
|
||||
* year (as string) → his own STATEWIDE share of valid votes: the "1×" the LQ
|
||||
* scale divides by. Statewide, never the share of the municipalities on
|
||||
* screen, so an advisor's map and the list's "Classe" column measure against
|
||||
* the same standard. 2026 reuses the 2022 standard, like `validVotesByYear`.
|
||||
*/
|
||||
statewideShareByYear: Record<string, number>
|
||||
/** codarea → E10 territorial class, aggregated over the slugs in scope. */
|
||||
territorialClassByCode: Record<string, MunicipalityTerritorialClass>
|
||||
/** codarea → his placement among the federal-deputy candidates voted there. */
|
||||
competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>>
|
||||
/** codarea → projected 2026 valid votes (E8) — the size of the proportional symbol. */
|
||||
projectedValidVotesByCode: Record<string, number>
|
||||
/** IBGE codarea → accessible municipality slugs for map click navigation. */
|
||||
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode
|
||||
/** Zone municipalities in scope (Salvador) with per-year values. */
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
|
||||
import {
|
||||
getFederalCompetitiveRank,
|
||||
getMunicipalityFederalBaseline,
|
||||
getStatewideFederalTotals,
|
||||
type FederalCompetitiveRank,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import {
|
||||
|
|
@ -32,6 +37,11 @@ import type {
|
|||
MunicipalityMapComparison,
|
||||
} from '@/utilities/municipalityMapContract'
|
||||
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
|
||||
import { projectedValidVotes } from '@/utilities/municipalityPotential'
|
||||
import {
|
||||
computeAggregateTerritorialClass,
|
||||
type MunicipalityTerritorialClass,
|
||||
} from '@/utilities/municipalityTerritorialClass'
|
||||
import {
|
||||
emptyMunicipalityPledgeAggregate,
|
||||
resolveMunicipalityStaffVoteTotal,
|
||||
|
|
@ -91,7 +101,15 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
|
|||
|
||||
const valuesByYear: Record<string, Record<string, number>> = {}
|
||||
const validVotesByYear: Record<string, Record<string, number>> = {}
|
||||
const statewideShareByYear: Record<string, number> = {}
|
||||
const competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>> = {}
|
||||
const zoneVotesBySlug = new Map<string, Record<string, number>>()
|
||||
const slugsByIbgeCode = new Map<string, string[]>()
|
||||
for (const municipality of municipalities) {
|
||||
const slugs = slugsByIbgeCode.get(municipality.ibgeCode) ?? []
|
||||
slugs.push(municipality.slug)
|
||||
slugsByIbgeCode.set(municipality.ibgeCode, slugs)
|
||||
}
|
||||
|
||||
// Historical years come from the committed artifact (immutable TSE data,
|
||||
// pre-aggregated per municipality) — zero database work.
|
||||
|
|
@ -112,8 +130,36 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
|
|||
}
|
||||
valuesByYear[String(year)] = values
|
||||
validVotesByYear[String(year)] = validValues
|
||||
|
||||
// The LQ denominator is his STATEWIDE standard, not the average of what is
|
||||
// on screen: an advisor looking at 14 municípios must read the same "2×"
|
||||
// that the list's "Classe" column shows.
|
||||
const statewide = getStatewideFederalTotals(year)
|
||||
statewideShareByYear[String(year)] =
|
||||
statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0
|
||||
|
||||
const ranks: Record<string, FederalCompetitiveRank> = {}
|
||||
for (const ibgeCode of slugsByIbgeCode.keys()) {
|
||||
const rank = getFederalCompetitiveRank(ibgeCode, year)
|
||||
if (rank) ranks[ibgeCode] = rank
|
||||
}
|
||||
competitiveRankByYear[String(year)] = ranks
|
||||
}
|
||||
validVotesByYear['2026'] = validVotesByYear['2022'] ?? {}
|
||||
// 2026 has no statewide truth to measure against — the estimates only exist
|
||||
// where the mesa filled them in — so it keeps 2022 as the standard, the same
|
||||
// substitution `validVotesByYear` already makes.
|
||||
statewideShareByYear['2026'] = statewideShareByYear['2022'] ?? 0
|
||||
|
||||
const territorialClassByCode: Record<string, MunicipalityTerritorialClass> = {}
|
||||
const projectedValidVotesByCode: Record<string, number> = {}
|
||||
for (const [ibgeCode, slugs] of slugsByIbgeCode) {
|
||||
territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class
|
||||
projectedValidVotesByCode[ibgeCode] = slugs.reduce(
|
||||
(total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
const pledgeValuesByScenario = Object.fromEntries(
|
||||
VOTE_ESTIMATE_SCENARIOS.map((scenario) => [scenario, {} as Record<string, number>]),
|
||||
|
|
@ -213,6 +259,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
|
|||
valuesByYear,
|
||||
values2026ByScenario: pledgeValuesByScenario,
|
||||
validVotesByYear,
|
||||
statewideShareByYear,
|
||||
territorialClassByCode,
|
||||
competitiveRankByYear,
|
||||
projectedValidVotesByCode,
|
||||
municipalitiesByIbgeCode: buildMunicipalitiesByIbgeCode(municipalities),
|
||||
zoneBreakdown,
|
||||
candidateName: BASELINE_TICKET_2022.candidate.name,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ import {
|
|||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import { computeVoteRankByYear } from '@/lib/municipalityVoteRank'
|
||||
import { captureRate, ownVotes2022, projectedFieldCeiling } from '@/utilities/municipalityPotential'
|
||||
import { TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
|
||||
import {
|
||||
captureRate,
|
||||
fieldCeiling,
|
||||
ownVotes2022,
|
||||
projectedFieldCeiling,
|
||||
} from '@/utilities/municipalityPotential'
|
||||
|
||||
/**
|
||||
* E10 "classificação territorial relativa" — the operational class of one
|
||||
|
|
@ -36,22 +42,6 @@ export const TERRITORIAL_CLASSES = [
|
|||
|
||||
export type MunicipalityTerritorialClass = (typeof TERRITORIAL_CLASSES)[number]
|
||||
|
||||
/**
|
||||
* Cuts are ILLUSTRATIVE, not calibrated: the research report gives the shape
|
||||
* (LQ > 2–3 = reduto, ~1 = padrão, < 0,5 = fraqueza) and says the exact
|
||||
* numbers can only come from a backtest against 2014–2022 — that is **E15**.
|
||||
* They live here, named and versioned, so recalibration is a one-line diff
|
||||
* with a test to prove what moved.
|
||||
*/
|
||||
export const TERRITORIAL_CLASS_ANCHORS = {
|
||||
/** LQ at or above this = performing at least 2× his own statewide standard. */
|
||||
strongLq: 2,
|
||||
/** LQ below this = performing at less than half his own standard. */
|
||||
weakLq: 0.5,
|
||||
/** Municípios that together hold this share of his statewide vote are the "core block". */
|
||||
coreCumulativeShare: 0.5,
|
||||
} as const
|
||||
|
||||
/** The axes behind a class — the UI always shows the "por quê", never the label alone. */
|
||||
type TerritorialFactorId = 'dominance' | 'ownShare' | 'field' | 'capture'
|
||||
|
||||
|
|
@ -220,6 +210,55 @@ export const computeMunicipalityTerritorialClass = (
|
|||
return classification
|
||||
}
|
||||
|
||||
/**
|
||||
* The class of a GROUP of catalog slugs read as one territory — the map paints
|
||||
* one polygon per IBGE municipality, and Salvador is 19 catalog slugs.
|
||||
*
|
||||
* It sums the inputs and runs the SAME `classifyMunicipalityTerritory`; it
|
||||
* never averages or votes on the per-slug classes, because LQ is a ratio and
|
||||
* the average of ratios is not the ratio of the sums. The catalog-wide
|
||||
* references (median field headroom, core block) stay exactly as they are:
|
||||
* the aggregate is compared against the same statewide standard as everyone
|
||||
* else.
|
||||
*
|
||||
* E12's TI rollup inherits this helper rather than growing a second one.
|
||||
*/
|
||||
export const computeAggregateTerritorialClass = (
|
||||
slugs: ReadonlyArray<string>,
|
||||
): MunicipalityTerritorialClassification => {
|
||||
if (slugs.length === 1) return computeMunicipalityTerritorialClass(slugs[0])
|
||||
if (slugs.length === 0) return UNCLASSIFIED
|
||||
|
||||
const totals = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR)
|
||||
const { medianFieldHeadroom, coreBlockSlugs } = getCatalogContext()
|
||||
|
||||
let ownVotes = 0
|
||||
let validVotes = 0
|
||||
let fieldHeadroom = 0
|
||||
let fieldCeiling2022 = 0
|
||||
let inCoreBlock = false
|
||||
|
||||
for (const slug of slugs) {
|
||||
const baseline = getMunicipalityFederalBaseline(slug)
|
||||
ownVotes += ownVotes2022(baseline)
|
||||
validVotes += baseline.validVotesByYear[String(TERRITORIAL_CLASS_YEAR)] ?? 0
|
||||
fieldHeadroom += fieldHeadroomOf(baseline)
|
||||
fieldCeiling2022 += fieldCeiling(baseline)
|
||||
inCoreBlock ||= coreBlockSlugs.has(slug)
|
||||
}
|
||||
|
||||
return classifyMunicipalityTerritory({
|
||||
ownVotes,
|
||||
validVotes,
|
||||
stateOwnVotes: totals.ownVotes,
|
||||
stateValidVotes: totals.validVotes,
|
||||
fieldHeadroom,
|
||||
medianFieldHeadroom,
|
||||
captureRate: fieldCeiling2022 > 0 ? ownVotes / fieldCeiling2022 : null,
|
||||
inCoreBlock,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort weight for the list's `classe` ordering: descending puts the
|
||||
* municípios that carry the campaign (reduto) first and the ones without a
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
162
tests/unit/mapScaleClasses.unit.spec.ts
Normal file
162
tests/unit/mapScaleClasses.unit.spec.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildCompetitiveRankClassing,
|
||||
buildLqClassing,
|
||||
buildQuantileClassing,
|
||||
fillsForClassing,
|
||||
QUANTILE_CLASSES,
|
||||
QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT,
|
||||
} from '@/lib/mapScaleClasses'
|
||||
import { AT_STANDARD_LQ, TERRITORIAL_CLASS_ANCHORS } from '@/lib/territorialClassAnchors'
|
||||
|
||||
const valuesFrom = (numbers: number[]): Record<string, number> =>
|
||||
Object.fromEntries(numbers.map((value, index) => [`k${index}`, value]))
|
||||
|
||||
describe('buildQuantileClassing', () => {
|
||||
it('cuts a large set into five classes ordered weakest to strongest', () => {
|
||||
const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1))
|
||||
const classing = buildQuantileClassing(values)
|
||||
|
||||
expect(classing.classes).toHaveLength(QUANTILE_CLASSES)
|
||||
expect(classing.reduced).toBe(false)
|
||||
expect(classing.classIndexByKey.k0).toBe(0)
|
||||
expect(classing.classIndexByKey.k99).toBe(QUANTILE_CLASSES - 1)
|
||||
})
|
||||
|
||||
it('splits evenly: each quantile holds about a fifth of the features', () => {
|
||||
const values = valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1))
|
||||
const classing = buildQuantileClassing(values)
|
||||
|
||||
const counts = new Array(QUANTILE_CLASSES).fill(0)
|
||||
for (const index of Object.values(classing.classIndexByKey)) counts[index] += 1
|
||||
|
||||
expect(counts).toEqual([20, 20, 20, 20, 20])
|
||||
})
|
||||
|
||||
it('degrades to three classes below the minimum feature count', () => {
|
||||
const values = valuesFrom(
|
||||
Array.from({ length: QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT - 1 }, (_, i) => (i + 1) * 10),
|
||||
)
|
||||
const classing = buildQuantileClassing(values)
|
||||
|
||||
expect(classing.classes).toHaveLength(3)
|
||||
expect(classing.reduced).toBe(true)
|
||||
})
|
||||
|
||||
it('collapses classes rather than repeating a break when values tie heavily', () => {
|
||||
const classing = buildQuantileClassing(valuesFrom([5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 900]))
|
||||
|
||||
// Eleven features share one value: quantiles 1-4 all end at 5, so the
|
||||
// legend gets two honest classes instead of five with duplicate bounds.
|
||||
expect(classing.classes).toHaveLength(2)
|
||||
expect(classing.reduced).toBe(true)
|
||||
expect(classing.classes[0].label).toBe('5')
|
||||
expect(classing.classes[1].label).toBe('900')
|
||||
})
|
||||
|
||||
it('excludes zero and negative values from the classing', () => {
|
||||
const classing = buildQuantileClassing({ a: 0, b: -5, c: 10, d: 20 })
|
||||
|
||||
expect(classing.classIndexByKey).not.toHaveProperty('a')
|
||||
expect(classing.classIndexByKey).not.toHaveProperty('b')
|
||||
expect(Object.keys(classing.classIndexByKey)).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
it('returns nothing to paint when no feature has a positive value', () => {
|
||||
const classing = buildQuantileClassing({ a: 0, b: 0 })
|
||||
|
||||
expect(classing.classes).toEqual([])
|
||||
expect(classing.classIndexByKey).toEqual({})
|
||||
})
|
||||
|
||||
it('labels each class with its real extremes, never an invented gap', () => {
|
||||
const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900]))
|
||||
|
||||
expect(classing.classes.map((entry) => entry.label)).toEqual(['1–3', '40–60', '700–900'])
|
||||
})
|
||||
|
||||
it('labels a single-value class with that value alone', () => {
|
||||
const classing = buildQuantileClassing({ a: 42 })
|
||||
|
||||
expect(classing.classes).toHaveLength(1)
|
||||
expect(classing.classes[0].label).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLqClassing', () => {
|
||||
const classOf = (lq: number) => buildLqClassing({ a: lq }).classIndexByKey.a
|
||||
|
||||
it('cuts on E10 anchors, so the map and the list agree on "reduto"', () => {
|
||||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq - 0.01)).toBe(0)
|
||||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.weakLq)).toBe(1)
|
||||
expect(classOf(AT_STANDARD_LQ.min)).toBe(2)
|
||||
expect(classOf(AT_STANDARD_LQ.max)).toBe(3)
|
||||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq)).toBe(4)
|
||||
expect(classOf(TERRITORIAL_CLASS_ANCHORS.strongLq + 10)).toBe(4)
|
||||
})
|
||||
|
||||
it('always offers the five named bands, even when only one is populated', () => {
|
||||
const classing = buildLqClassing({ a: 1 })
|
||||
|
||||
expect(classing.classes).toHaveLength(5)
|
||||
expect(classing.reduced).toBe(false)
|
||||
})
|
||||
|
||||
it('spells the band edges without rounding 0,95 into 1', () => {
|
||||
const labels = buildLqClassing({ a: 1 }).classes.map((entry) => entry.label)
|
||||
|
||||
expect(labels).toEqual([
|
||||
'menos de 0,5×',
|
||||
'0,5×–0,95×',
|
||||
'no padrão (≈1×)',
|
||||
'1,15×–2×',
|
||||
'2× ou mais',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips municípios with no measurable standard', () => {
|
||||
expect(buildLqClassing({ a: 0 }).classIndexByKey).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCompetitiveRankClassing', () => {
|
||||
const classOf = (rank: number) => buildCompetitiveRankClassing({ a: rank }).classIndexByKey.a
|
||||
|
||||
it('puts the strongest placement in the darkest class', () => {
|
||||
expect(classOf(1)).toBe(2)
|
||||
expect(classOf(2)).toBe(1)
|
||||
expect(classOf(3)).toBe(1)
|
||||
expect(classOf(4)).toBe(0)
|
||||
expect(classOf(120)).toBe(0)
|
||||
})
|
||||
|
||||
it('reads weakest to strongest, like every other legend', () => {
|
||||
const labels = buildCompetitiveRankClassing({ a: 1 }).classes.map((entry) => entry.label)
|
||||
|
||||
expect(labels).toEqual(['4º ou pior', '2º–3º', '1º'])
|
||||
})
|
||||
|
||||
it('ignores an impossible placement instead of painting it', () => {
|
||||
expect(buildCompetitiveRankClassing({ a: 0 }).classIndexByKey).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fillsForClassing', () => {
|
||||
it('maps every classed key to its class fill', () => {
|
||||
const classing = buildQuantileClassing(valuesFrom([1, 2, 3, 40, 50, 60, 700, 800, 900]))
|
||||
const fills = fillsForClassing(classing)
|
||||
|
||||
expect(Object.keys(fills)).toHaveLength(9)
|
||||
expect(fills.k0).toBe(classing.classes[0].fill)
|
||||
expect(fills.k8).toBe(classing.classes[2].fill)
|
||||
})
|
||||
|
||||
it('gives each class a distinguishable fill', () => {
|
||||
const classing = buildQuantileClassing(
|
||||
valuesFrom(Array.from({ length: 100 }, (_, index) => index + 1)),
|
||||
)
|
||||
|
||||
expect(new Set(classing.classes.map((entry) => entry.fill)).size).toBe(QUANTILE_CLASSES)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue