B13+: concordância LQ, readout de escopo no mapa e DRY do build de agregados.
Rede de segurança para E15, copy honesta no Quadro para assessores, uma varredura federal por ano no artefato e tupla de rank no bundle RSC.
This commit is contained in:
parent
7dcfd2989f
commit
e462d39fda
13 changed files with 308 additions and 135 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-B13 (escala relativa no mapa dos Municípios)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-26
|
||||
Status: entregue (2026-07-29)
|
||||
Atualizado em: 2026-07-29
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B13+**, fill-in de engenharia)
|
||||
Impeccable: A — N/A (nenhuma superfície nova; as quatro fases preservam o comportamento visível do B13, exceto uma linha de copy em F2)
|
||||
Appetite: ~0,5–0,75 dia eng; quatro fases independentes, nenhuma com migration, collection ou `Consent`
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- ~~**Cenário junto aos filtros**~~ — **✓ (entregue 2026-07-29)** `VoteEstimateScenarioField` na barra slim de `MunicipalityFilters` (desktop/mobile); overview só consome contexto; provider envolve filtros + resultados · ~0,25–0,5d · Impeccable B · [plano](plans/cenario-junto-filtros-municipios.md)
|
||||
- ~~**B16+** escala/DRY pós-B16~~ — **absorvido pelo Pass 2 W1-D1 (2026-07-25)**: `useOptimistic`, facet por slugs, hrefs via serializador canônico, facets no mesmo `Promise.all` · [plano](plans/escala-dry-pos-b16.md)
|
||||
- **E10+** escala/DRY pós-E10 — quatro fases do `/simplify` da classificação territorial: **F1** a lista lê `municipality` 2× por request (`listQuery` + `loadMunicipalityScope`, mesmo `where`, select superset) — **fecha o "Adiado com gatilho" do A11**, cujo gatilho ("3º path derivado no loader") disparou com `classe` sendo o 4º (`votos`, `deficit`, `frescor`); **F2** `computeAllMunicipalityPotentials()` recomputa 435 linhas puras do artefato por request (memo de processo, como `bahiaElectionAggregates`/`municipalityVoteRank`); **F3** um `TooltipProvider` por tooltip (~30 na lista) anula o agrupamento de delay do Radix — hoist para o layout de `(app)`; **F4** os 4 multi-filtros do mobile saem da mesma `municipalityFilterDefinitions` que os single-select vizinhos já usam · ~0,75–1d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-e10.md)
|
||||
- **B13+** escala/DRY pós-B13 — quatro fases do `/simplify` da escala relativa no mapa, as duas primeiras de **coerência** e não de custo: **F1** a razão do LQ é escrita duas vezes (painel cliente `:210` × classificador `server-only` `:108`, separação **deliberada** — o classificador arrasta o artefato de 600 KB) sem nenhum teste travando a concordância, que é a rede de segurança do **E15**; **F2** no readout do mapa a posição vale para a cidade inteira enquanto a classe é calculada sobre o escopo do usuário, então dois assessores veem classes diferentes para o mesmo polígono de Salvador — declarar a geografia, sem mudar o cálculo; **F3** `pnpm build:election-aggregates` varre a mesma fatia estadual do TSE 3× por ano (~225k docs extras) porque `loadFederalVotesByCityZoneAndCandidate` só precisa de `party` no `select` para ser superconjunto estrito dos outros dois; **F4** `competitiveRankByYear` soma ~57 dos ~63,6 KB que o B13 pôs no payload RSC, pagos mesmo por quem nunca abre a escala — tupla `[rank, candidates]` corta ~metade e responde ao "profiling antes de agir" que o ledger pediu em 2026-07-23 · ~0,5–0,75d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b13.md)
|
||||
- ~~**B13+** escala/DRY pós-B13~~ — **✓ (entregue 2026-07-29)** quatro fases do `/simplify` pós-B13: **F1** `tests/unit/mapLqConcordance.unit.spec.ts` pina o LQ do painel contra `computeMunicipalityTerritorialClass` (rede de segurança do E15, sem extrair helper); **F2** `actorRole` no mapa do **Quadro** (`quadro/page.tsx` → `MunicipalityMapPanelDynamic`) e copy de escopo no readout (quantis/LQ "no seu escopo" para assessor; posição "na cidade inteira"; bolha E10 "(lista)"); **F3** uma varredura federal por ano no `build:election-aggregates` via `candidateVotesByCityZoneFromFederalSlice` / `campoFederalVotesByCityZoneFromFederalSlice` (`party` no select do slice); **F4** `competitiveRankByYear` no bundle RSC como tupla `[rank, candidates]` + `readFederalCompetitivePlacement` (artefato JSON inalterado) · sem migration · [plano](plans/escala-dry-pos-b13.md)
|
||||
- ~~**B8+** escala/DRY pós-B8 F2~~ — **✓ (entregue 2026-07-27)** as quatro fases do `/simplify` dos polígonos das ZE. **F1:** a política de escala de chão nasceu em `scripts/lib/topology.mjs` (primeiro módulo de `scripts/lib/` — **B5 F2** pousa nele) declarando tolerância de Visvalingam em **m² de chão** e derivando os dígitos de quantização do bbox de cada topologia; `SIMPLIFY_QUANTILE`/`QUANTIZE_DIGITS` morreram nos dois scripts. A auditoria corrigiu a alavanca do plano: quantização sozinha quase não corta (mesmo na grade estadual as zonas ficariam em 203 pontos/feature), o peso absoluto é que manda — as zonas caíram de **9.120 → 574 pontos de arco (480 → 30,2/feature) e 72.310 → 7.687 bytes**, `bahia-municipalities` saiu **byte-idêntico** e territórios mudou 1 byte (grade 102,730 → 102,822 m). Tolerância própria por malha, com o número medido no comentário: a tolerância municipal chegaria a 17,9/feature nas zonas mas comeria 5% da área de Salvador (306 → 291 km²), que é justamente a grandeza que prova que nenhum bairro sumiu. `MAX_TOPO_BYTES` ganhou ao lado `MAX_POINTS_PER_FEATURE = 60`. **F2:** `featureMapKey` passou a ser exercido sobre features **reais** das três malhas, cada uma no laço que já a varre inteira (417 `codarea` + 27 `code` + 19 `slug`) — o `/simplify` corrigiu a primeira escrita, que asseria a cidade dentro do laço das zonas e portanto cobria **uma** feature municipal 19 vezes; o teste de precedência sintético caiu e o comentário passou a declarar o invariante real (as três propriedades de chave são mutuamente exclusivas; `ibgeCode` na zona deliberadamente não é chave). **F3:** `.catch()` na metade das zonas — o mapa segue sem elas (com o motivo logado), com Salvador como polígono sem dado nem interação — e a ressalva de aproximação saiu do gate `!comparisonActive` para um `<p>` condicionado a `bundle.hasZoneMunicipalities`, campo novo publicado pelo loader a partir de `kind === 'zona'`: o `/simplify` **rejeitou** o predicado `isZoneMapKey` que lia isso da forma da chave (7 dígitos ⇒ município), porque era uma segunda codificação da regra de `mapKeyForMunicipality`, e gatear em `zoneBreakdown` recriaria o acoplamento que o fill-in da lista (✓ 2026-07-28) deletou. **F4:** dois acessores órfãos deletados, a união de navegação de 3 estados colapsada em `string | undefined`, `MunicipalitiesByMapKey` virou `Record<string, string>` (435 strings fora do payload RSC do Início), `polygonRingsOf` exportado e os helpers de geometria retipados em `PolygonalFeature`/`BahiaFeature<P>`/`BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017, então não são código morto (só o `zoneNumber` do **artefato** TopoJSON saiu) · sem migration · [plano](plans/escala-dry-pos-b8f2.md)
|
||||
- **E12+** escala/DRY pós-E12 — **✓ (entregue 2026-07-29)** F1: `src/lib/metropolitanoTerritoryPeers.ts` (peers/sub-linhas Metropolitano unificados para rollup, classe agregada e benchmark T4); F2 adiada — `benchmarkBySlug` já cobre · ~0,25d · Impeccable A · [plano](plans/escala-dry-pos-e12.md)
|
||||
- **O0+** escala/DRY pós-Onda 0 · [plano](plans/escala-dry-pos-onda0.md)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts'
|
|||
const { municipalityElectionGeography } =
|
||||
await import('../src/utilities/municipality/municipalityElectionGeography.ts')
|
||||
const {
|
||||
loadCampoFederalVotesByCityZone,
|
||||
campoFederalVotesByCityZoneFromFederalSlice,
|
||||
candidateVotesByCityZoneFromFederalSlice,
|
||||
loadCandidateVotesByCityZone,
|
||||
loadFederalVotesByCityZoneAndCandidate,
|
||||
loadOfficeTallyByCityZone,
|
||||
|
|
@ -83,17 +84,30 @@ for (const entry of municipalityCatalog) {
|
|||
}
|
||||
}
|
||||
|
||||
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 [candidateVotes, validVotes, campoVotes, federalTally] = await Promise.all([
|
||||
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
|
||||
const [federalNominalSlice, validVotes, federalTally] = await Promise.all([
|
||||
loadFederalVotesByCityZoneAndCandidate(payload, cliReader, { year }),
|
||||
loadValidVotesByCityZone(payload, cliReader, { year }),
|
||||
loadCampoFederalVotesByCityZone(payload, cliReader, { year }),
|
||||
loadOfficeTallyByCityZone(payload, cliReader, {
|
||||
year,
|
||||
office: FEDERAL_DEPUTY_OFFICE,
|
||||
turn: '1',
|
||||
}),
|
||||
])
|
||||
const candidateVotes = candidateVotesByCityZoneFromFederalSlice(
|
||||
federalNominalSlice,
|
||||
candidateNumber,
|
||||
)
|
||||
const campoVotes = campoFederalVotesByCityZoneFromFederalSlice(federalNominalSlice, year)
|
||||
|
||||
if (candidateVotes.size === 0) {
|
||||
console.error(
|
||||
|
|
@ -122,6 +136,36 @@ for (const year of HISTORICAL_SERIES_YEARS) {
|
|||
`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber} ` +
|
||||
`(campo total: ${campoTotal}).`,
|
||||
)
|
||||
|
||||
for (const [ibgeCode, geographies] of geographiesByIbgeCode) {
|
||||
const byCandidate = new Map()
|
||||
for (const geography of geographies) {
|
||||
for (const [number, votes] of sumCandidateVotesForGeography(federalNominalSlice, geography)) {
|
||||
byCandidate.set(number, (byCandidate.get(number) ?? 0) + votes)
|
||||
}
|
||||
}
|
||||
|
||||
const ownVotes = byCandidate.get(candidateNumber) ?? 0
|
||||
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] ??= {}
|
||||
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.`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2022-only: majoritarian (presidente/governador #13) votes + tally, used for
|
||||
|
|
@ -171,63 +215,6 @@ 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.',
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ const DashboardMapSection = async ({
|
|||
|
||||
return (
|
||||
<MunicipalityEstimateScenarioProvider>
|
||||
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
|
||||
<MunicipalityMapPanelDynamic
|
||||
bundle={mapBundle}
|
||||
candidateOptions={candidateOptions}
|
||||
actorRole={user.role}
|
||||
/>
|
||||
</MunicipalityEstimateScenarioProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type ChoroplethValues,
|
||||
} from '@/lib/bahiaMapStyle'
|
||||
import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts'
|
||||
import { isUnrestrictedCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
|
||||
import {
|
||||
choroplethMaxValue,
|
||||
computeValidVoteShares,
|
||||
|
|
@ -55,6 +56,7 @@ import {
|
|||
municipalityMapScaleModeHints,
|
||||
municipalityMapScaleModeLabels,
|
||||
municipalityMapYearLabels,
|
||||
readFederalCompetitivePlacement,
|
||||
type MunicipalityMapBundle,
|
||||
type MunicipalityMapScaleMode,
|
||||
type MunicipalityMapYear,
|
||||
|
|
@ -132,12 +134,14 @@ const MapBubbleKey = ({ largestValue }: { largestValue: number }) => (
|
|||
type MunicipalityMapPanelProps = {
|
||||
bundle: MunicipalityMapBundle
|
||||
candidateOptions: FederalCandidateOption[]
|
||||
actorRole: CampaignRole
|
||||
defaultYear?: MunicipalityMapYear
|
||||
}
|
||||
|
||||
export const MunicipalityMapPanel = ({
|
||||
bundle,
|
||||
candidateOptions,
|
||||
actorRole,
|
||||
defaultYear = 2022,
|
||||
}: MunicipalityMapPanelProps) => {
|
||||
const router = useRouter()
|
||||
|
|
@ -157,6 +161,7 @@ export const MunicipalityMapPanel = ({
|
|||
|
||||
const comparison = bundle.comparison
|
||||
const comparisonActive = comparison !== null && year !== 2026
|
||||
const portfolioScopedActor = !isUnrestrictedCampaignRole(actorRole)
|
||||
|
||||
// Comparing forces the diverging absolute scale, and the competitive
|
||||
// placement has no 2026 reading (it is a TSE result). Rather than let the
|
||||
|
|
@ -180,7 +185,14 @@ export const MunicipalityMapPanel = ({
|
|||
return bundle.values2026ByScenario[estimateScenario] ?? {}
|
||||
}
|
||||
return bundle.valuesByYear[String(year)] ?? {}
|
||||
}, [bundle, comparison, comparisonActive, estimateScenario, year])
|
||||
}, [
|
||||
bundle.valuesByYear,
|
||||
bundle.values2026ByScenario,
|
||||
comparison,
|
||||
comparisonActive,
|
||||
estimateScenario,
|
||||
year,
|
||||
])
|
||||
|
||||
const validVotesForYear = useMemo(
|
||||
() => bundle.validVotesByYear[String(year)] ?? {},
|
||||
|
|
@ -206,6 +218,8 @@ export const MunicipalityMapPanel = ({
|
|||
|
||||
/** LQ per município against his statewide standard — the same ratio E10 classes on. */
|
||||
const lqByMapKey = useMemo(() => {
|
||||
if (effectiveScaleMode !== 'lq') return {}
|
||||
|
||||
const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0
|
||||
if (statewideShare <= 0) return {}
|
||||
|
||||
|
|
@ -216,7 +230,7 @@ export const MunicipalityMapPanel = ({
|
|||
lq[mapKey] = votes / validVotes / statewideShare
|
||||
}
|
||||
return lq
|
||||
}, [bundle.statewideShareByYear, rawValues, validVotesForYear, year])
|
||||
}, [bundle.statewideShareByYear, effectiveScaleMode, rawValues, validVotesForYear, year])
|
||||
|
||||
const competitiveRankForYear = useMemo(
|
||||
() => bundle.competitiveRankByYear[String(year)] ?? {},
|
||||
|
|
@ -234,7 +248,7 @@ export const MunicipalityMapPanel = ({
|
|||
Object.fromEntries(
|
||||
Object.entries(competitiveRankForYear).map(([mapKey, placement]) => [
|
||||
mapKey,
|
||||
placement.rank,
|
||||
placement[0],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
|
@ -248,7 +262,7 @@ export const MunicipalityMapPanel = ({
|
|||
// classed and grey out every polygon under a red gradient legend.
|
||||
const classed = classing !== null && classing.classes.length > 0
|
||||
const fillByKey = useMemo(
|
||||
() => (classed && classing ? fillsForClassing(classing) : undefined),
|
||||
() => (classed && classing !== null ? fillsForClassing(classing) : undefined),
|
||||
[classed, classing],
|
||||
)
|
||||
|
||||
|
|
@ -342,9 +356,19 @@ export const MunicipalityMapPanel = ({
|
|||
|
||||
const territorialClass = bundle.territorialClassByMapKey[key]
|
||||
const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null
|
||||
return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}`
|
||||
const classSuffix = classLabel
|
||||
? portfolioScopedActor
|
||||
? ` · ${classLabel} (lista)`
|
||||
: ` · ${classLabel}`
|
||||
: ''
|
||||
return `${formatElectionNumber(projected)} válidos projetados em jogo${classSuffix}`
|
||||
},
|
||||
[bundle.projectedValidVotesByMapKey, bundle.territorialClassByMapKey, showBubbles],
|
||||
[
|
||||
bundle.projectedValidVotesByMapKey,
|
||||
bundle.territorialClassByMapKey,
|
||||
portfolioScopedActor,
|
||||
showBubbles,
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -361,22 +385,26 @@ export const MunicipalityMapPanel = ({
|
|||
case 'quantile': {
|
||||
if (classIndex === undefined) return null
|
||||
const range = classing.classes[classIndex]?.label
|
||||
return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}`
|
||||
const scopeSuffix = portfolioScopedActor ? ' no seu escopo' : ''
|
||||
return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}${scopeSuffix}`
|
||||
}
|
||||
case 'lq': {
|
||||
const lq = lqByMapKey[key]
|
||||
return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq)
|
||||
if (lq === undefined) return null
|
||||
const reading = formatDominanceAgainstOwnStandard(lq)
|
||||
return portfolioScopedActor ? `${reading} no seu escopo` : reading
|
||||
}
|
||||
case 'competitiveRank': {
|
||||
const placement = competitiveRankForYear[key]
|
||||
if (!placement) return null
|
||||
return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui`
|
||||
const tuple = competitiveRankForYear[key]
|
||||
if (!tuple) return null
|
||||
const placement = readFederalCompetitivePlacement(tuple)
|
||||
return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui, na cidade inteira`
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey],
|
||||
[classing, competitiveRankForYear, effectiveScaleMode, lqByMapKey, portfolioScopedActor],
|
||||
)
|
||||
|
||||
// Optimistic on the control, honest pending on the map: the comparison data
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
|
||||
import type { MunicipalityMapBundle } from '@/utilities/municipality/municipalityMapContract'
|
||||
|
||||
|
|
@ -24,7 +25,15 @@ const MunicipalityMapPanelLazy = dynamic(
|
|||
export const MunicipalityMapPanelDynamic = ({
|
||||
bundle,
|
||||
candidateOptions,
|
||||
actorRole,
|
||||
}: {
|
||||
bundle: MunicipalityMapBundle
|
||||
candidateOptions: FederalCandidateOption[]
|
||||
}) => <MunicipalityMapPanelLazy bundle={bundle} candidateOptions={candidateOptions} />
|
||||
actorRole: CampaignRole
|
||||
}) => (
|
||||
<MunicipalityMapPanelLazy
|
||||
bundle={bundle}
|
||||
candidateOptions={candidateOptions}
|
||||
actorRole={actorRole}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -277,45 +277,6 @@ export const loadCandidateVotesByCityZone = async (
|
|||
return votes
|
||||
}
|
||||
|
||||
/**
|
||||
* cityCode:zone → sum of nominal deputado-federal T1 votes for every
|
||||
* candidate whose party is in the curated `campoParties.ts` field for that
|
||||
* year (E8 "share intracampo" denominator — CLI/build-time use only, this
|
||||
* scans the whole statewide slice for the office).
|
||||
*/
|
||||
export const loadCampoFederalVotesByCityZone = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year }: { year: number },
|
||||
): Promise<Map<string, 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, party: true, votes: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const votes = new Map<string, number>()
|
||||
for (const row of result.docs) {
|
||||
if (!isCampoParty(row.party, year)) continue
|
||||
const key = `${row.cityCode}:${row.zoneNumber}`
|
||||
votes.set(key, (votes.get(key) ?? 0) + (row.votes ?? 0))
|
||||
}
|
||||
return votes
|
||||
}
|
||||
|
||||
/**
|
||||
* cityCode:zone → (candidateNumber → nominal deputado-federal T1 votes) for
|
||||
* one year — the whole statewide slice, ungrouped by candidate, so a caller
|
||||
|
|
@ -326,11 +287,16 @@ export const loadCampoFederalVotesByCityZone = async (
|
|||
* scans ~100k rows and must never run on a request path — the ranking it
|
||||
* feeds is precomputed into the committed artifact.
|
||||
*/
|
||||
export type FederalNominalVoteCell = {
|
||||
votes: number
|
||||
party: string | null
|
||||
}
|
||||
|
||||
export const loadFederalVotesByCityZoneAndCandidate = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year }: { year: number },
|
||||
): Promise<Map<string, Map<number, number>>> => {
|
||||
): Promise<Map<string, Map<number, FederalNominalVoteCell>>> => {
|
||||
assertCanReadElectionData(user)
|
||||
|
||||
const result = await payload.find({
|
||||
|
|
@ -346,37 +312,87 @@ export const loadFederalVotesByCityZoneAndCandidate = async (
|
|||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { cityCode: true, zoneNumber: true, candidateNumber: true, votes: true },
|
||||
select: {
|
||||
cityCode: true,
|
||||
zoneNumber: true,
|
||||
candidateNumber: true,
|
||||
votes: true,
|
||||
party: true,
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const byCityZone = new Map<string, Map<number, number>>()
|
||||
const byCityZone = new Map<string, Map<number, FederalNominalVoteCell>>()
|
||||
for (const row of result.docs) {
|
||||
const key = `${row.cityCode}:${row.zoneNumber}`
|
||||
let byCandidate = byCityZone.get(key)
|
||||
if (!byCandidate) {
|
||||
byCandidate = new Map<number, number>()
|
||||
byCandidate = new Map<number, FederalNominalVoteCell>()
|
||||
byCityZone.set(key, byCandidate)
|
||||
}
|
||||
byCandidate.set(
|
||||
row.candidateNumber,
|
||||
(byCandidate.get(row.candidateNumber) ?? 0) + (row.votes ?? 0),
|
||||
)
|
||||
const previous = byCandidate.get(row.candidateNumber)
|
||||
byCandidate.set(row.candidateNumber, {
|
||||
votes: (previous?.votes ?? 0) + (row.votes ?? 0),
|
||||
party: row.party ?? previous?.party ?? null,
|
||||
})
|
||||
}
|
||||
return byCityZone
|
||||
}
|
||||
|
||||
/** Derives the per-candidate map used by `loadCandidateVotesByCityZone` from one federal slice. */
|
||||
export const candidateVotesByCityZoneFromFederalSlice = (
|
||||
slice: Map<string, Map<number, FederalNominalVoteCell>>,
|
||||
candidateNumber: number,
|
||||
): Map<string, number> => {
|
||||
const votes = new Map<string, number>()
|
||||
for (const [key, byCandidate] of slice) {
|
||||
const cell = byCandidate.get(candidateNumber)
|
||||
if (cell && cell.votes > 0) votes.set(key, cell.votes)
|
||||
}
|
||||
return votes
|
||||
}
|
||||
|
||||
/** Derives the campo-parties map used by `loadCampoFederalVotesByCityZone` from one federal slice. */
|
||||
export const campoFederalVotesByCityZoneFromFederalSlice = (
|
||||
slice: Map<string, Map<number, FederalNominalVoteCell>>,
|
||||
year: number,
|
||||
): Map<string, number> => {
|
||||
const votes = new Map<string, number>()
|
||||
for (const [key, byCandidate] of slice) {
|
||||
let total = 0
|
||||
for (const cell of byCandidate.values()) {
|
||||
if (cell.party && isCampoParty(cell.party, year)) total += cell.votes
|
||||
}
|
||||
if (total > 0) votes.set(key, total)
|
||||
}
|
||||
return votes
|
||||
}
|
||||
|
||||
/**
|
||||
* cityCode:zone → sum of nominal deputado-federal T1 votes for campo parties
|
||||
* (E8 "share intracampo"). One DB scan via the federal nominal slice.
|
||||
*/
|
||||
export const loadCampoFederalVotesByCityZone = async (
|
||||
payload: Payload,
|
||||
user: ElectionReader,
|
||||
{ year }: { year: number },
|
||||
): Promise<Map<string, number>> => {
|
||||
assertCanReadElectionData(user)
|
||||
const slice = await loadFederalVotesByCityZoneAndCandidate(payload, user, { year })
|
||||
return campoFederalVotesByCityZoneFromFederalSlice(slice, year)
|
||||
}
|
||||
|
||||
/** Folds `loadFederalVotesByCityZoneAndCandidate` over one municipality's cityCode×zones. */
|
||||
export const sumCandidateVotesForGeography = (
|
||||
votesByCityZoneAndCandidate: Map<string, Map<number, number>>,
|
||||
votesByCityZoneAndCandidate: Map<string, Map<number, FederalNominalVoteCell>>,
|
||||
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)
|
||||
for (const [candidateNumber, cell] of byCandidate) {
|
||||
total.set(candidateNumber, (total.get(candidateNumber) ?? 0) + cell.votes)
|
||||
}
|
||||
}
|
||||
return total
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { FederalCompetitiveRank } from '@/lib/bahiaElectionAggregates'
|
||||
import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
|
||||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { MunicipalitiesByMapKey } from '@/utilities/municipality/municipalityMapNavigation'
|
||||
|
|
@ -86,6 +85,21 @@ export type MunicipalityMapComparison = {
|
|||
diffByYear: Record<string, Record<string, number>>
|
||||
}
|
||||
|
||||
/** B13+ F4 — compact wire format for competitive placement in the RSC bundle. */
|
||||
export type FederalCompetitiveRankTuple = readonly [rank: number, candidates: number]
|
||||
|
||||
const FEDERAL_COMPETITIVE_RANK_INDEX = {
|
||||
rank: 0,
|
||||
candidates: 1,
|
||||
} as const
|
||||
|
||||
export const readFederalCompetitivePlacement = (
|
||||
tuple: FederalCompetitiveRankTuple,
|
||||
): { rank: number; candidates: number } => ({
|
||||
rank: tuple[FEDERAL_COMPETITIVE_RANK_INDEX.rank],
|
||||
candidates: tuple[FEDERAL_COMPETITIVE_RANK_INDEX.candidates],
|
||||
})
|
||||
|
||||
/**
|
||||
* Every record below is keyed by MAP KEY, not by IBGE codarea: since B8 F2 the
|
||||
* map paints one polygon per catalog unit, so Salvador's 19 zones are addressed
|
||||
|
|
@ -113,7 +127,7 @@ export type MunicipalityMapBundle = {
|
|||
* TSE artifact ranks by codarea only, so Salvador's zones all carry the city's
|
||||
* position (the legend says so).
|
||||
*/
|
||||
competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRank>>
|
||||
competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRankTuple>>
|
||||
/** map key → projected 2026 valid votes (E8) — the size of the proportional symbol. */
|
||||
projectedValidVotesByMapKey: Record<string, number>
|
||||
/** map key → the municipality it opens on click. */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
getFederalCompetitiveRank,
|
||||
getMunicipalityFederalBaseline,
|
||||
getStatewideFederalTotals,
|
||||
type FederalCompetitiveRank,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
|
|
@ -33,6 +32,7 @@ import {
|
|||
type MunicipalityListState,
|
||||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import type {
|
||||
FederalCompetitiveRankTuple,
|
||||
MunicipalityMapBundle,
|
||||
MunicipalityMapComparison,
|
||||
} from '@/utilities/municipality/municipalityMapContract'
|
||||
|
|
@ -105,7 +105,7 @@ 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 competitiveRankByYear: Record<string, Record<string, FederalCompetitiveRankTuple>> = {}
|
||||
// One polygon per catalog unit since B8 F2, so this index — the same one the
|
||||
// map navigates by — is also the key of every rollup below.
|
||||
const municipalitiesByMapKey = buildMunicipalitiesByMapKey(municipalities)
|
||||
|
|
@ -134,10 +134,10 @@ const buildMunicipalityMapBundleFromMunicipalities = async (
|
|||
|
||||
// Rank exists per city only, so every zone of Salvador carries the city's
|
||||
// position — stated in the legend rather than faked per zone.
|
||||
const ranks: Record<string, FederalCompetitiveRank> = {}
|
||||
const ranks: Record<string, FederalCompetitiveRankTuple> = {}
|
||||
for (const municipality of municipalities) {
|
||||
const rank = getFederalCompetitiveRank(municipality.ibgeCode, year)
|
||||
if (rank) ranks[mapKeyForMunicipality(municipality)] = rank
|
||||
if (rank) ranks[mapKeyForMunicipality(municipality)] = [rank.rank, rank.candidates]
|
||||
}
|
||||
competitiveRankByYear[String(year)] = ranks
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
|||
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import config from '@/payload.config'
|
||||
import { readFederalCompetitivePlacement } from '@/utilities/municipality/municipalityMapContract'
|
||||
import { loadMunicipalityMapBundle } from '@/utilities/municipality/municipalityMapData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
|
@ -49,7 +50,15 @@ describe('loadMunicipalityMapBundle — list URL filters', () => {
|
|||
|
||||
// The zones share the city's TSE rank — the artifact ranks by codarea only.
|
||||
const ranks = bundle!.competitiveRankByYear['2022'] ?? {}
|
||||
const positions = new Set(mapKeys.map((key) => ranks[key]?.rank))
|
||||
const sampleKey = mapKeys[0]
|
||||
const sampleTuple = ranks[sampleKey]
|
||||
expect(sampleTuple).toEqual([expect.any(Number), expect.any(Number)])
|
||||
const positions = new Set(
|
||||
mapKeys.map((key) => {
|
||||
const tuple = ranks[key]
|
||||
return tuple ? readFederalCompetitivePlacement(tuple).rank : undefined
|
||||
}),
|
||||
)
|
||||
expect(positions.size).toBe(1)
|
||||
})
|
||||
|
||||
|
|
|
|||
29
tests/unit/federalNominalSliceFolds.unit.spec.ts
Normal file
29
tests/unit/federalNominalSliceFolds.unit.spec.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BASELINE_TICKET_2022, ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import {
|
||||
campoFederalVotesByCityZoneFromFederalSlice,
|
||||
candidateVotesByCityZoneFromFederalSlice,
|
||||
type FederalNominalVoteCell,
|
||||
} from '@/utilities/municipality/municipalityElectoralBaseline'
|
||||
|
||||
describe('federal nominal slice folds (B13+ F3)', () => {
|
||||
it('derives candidate votes and campo totals from one in-memory slice', () => {
|
||||
const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber
|
||||
const slice = new Map<string, Map<number, FederalNominalVoteCell>>([
|
||||
[
|
||||
'2927408:1',
|
||||
new Map([
|
||||
[candidateNumber, { votes: 40, party: 'PT' }],
|
||||
[9999, { votes: 10, party: 'PL' }],
|
||||
]),
|
||||
],
|
||||
])
|
||||
|
||||
const byCandidate = candidateVotesByCityZoneFromFederalSlice(slice, candidateNumber)
|
||||
expect(byCandidate.get('2927408:1')).toBe(40)
|
||||
|
||||
const campo = campoFederalVotesByCityZoneFromFederalSlice(slice, ELECTION_YEAR_2022)
|
||||
expect(campo.get('2927408:1')).toBe(40)
|
||||
})
|
||||
})
|
||||
75
tests/unit/mapLqConcordance.unit.spec.ts
Normal file
75
tests/unit/mapLqConcordance.unit.spec.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
getMunicipalityFederalBaseline,
|
||||
getStatewideFederalTotals,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import { ELECTION_YEAR_2022 } from '@/lib/electionResults'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
|
||||
/**
|
||||
* B13+ F1 — the map panel computes LQ client-side; E10 classifies server-side
|
||||
* over the same artifact. E15 will touch the anchors — this pin is the safety
|
||||
* net that both paths still agree on the ratio.
|
||||
*/
|
||||
const mapLqForSlug = (slug: string, year: number): number | null => {
|
||||
const baseline = getMunicipalityFederalBaseline(slug)
|
||||
const votes = baseline.votesByYear[String(year)] ?? 0
|
||||
const validVotes = baseline.validVotesByYear[String(year)] ?? 0
|
||||
const statewide = getStatewideFederalTotals(year)
|
||||
const statewideShare = statewide.validVotes > 0 ? statewide.ownVotes / statewide.validVotes : 0
|
||||
|
||||
if (votes <= 0 || validVotes <= 0 || statewideShare <= 0) return null
|
||||
|
||||
return votes / validVotes / statewideShare
|
||||
}
|
||||
|
||||
const firstSlugWithFederalVotes2022 = (): string => {
|
||||
const slug = municipalityCatalog.find((entry) => {
|
||||
const baseline = getMunicipalityFederalBaseline(entry.slug)
|
||||
return (baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0) > 0
|
||||
})?.slug
|
||||
|
||||
if (!slug) {
|
||||
throw new Error('expected at least one municipality with 2022 federal votes in the catalog')
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
describe('map LQ concordance (B13+ F1)', () => {
|
||||
it('matches computeMunicipalityTerritorialClass.lq for a municipality with 2022 votes', () => {
|
||||
const slug = firstSlugWithFederalVotes2022()
|
||||
|
||||
const mapLq = mapLqForSlug(slug, ELECTION_YEAR_2022)
|
||||
const classifierLq = computeMunicipalityTerritorialClass(slug).lq
|
||||
|
||||
expect(mapLq).not.toBeNull()
|
||||
expect(classifierLq).not.toBeNull()
|
||||
if (mapLq === null || classifierLq === null) return
|
||||
|
||||
expect(mapLq).toBeCloseTo(classifierLq, 10)
|
||||
})
|
||||
|
||||
it('uses the same statewide standard as the classifier inputs', () => {
|
||||
const slug = firstSlugWithFederalVotes2022()
|
||||
const totals = getStatewideFederalTotals(ELECTION_YEAR_2022)
|
||||
const statewideShare = totals.validVotes > 0 ? totals.ownVotes / totals.validVotes : 0
|
||||
|
||||
const baseline = getMunicipalityFederalBaseline(slug)
|
||||
const ownVotes = baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0
|
||||
const validVotes = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
|
||||
|
||||
expect(ownVotes).toBeGreaterThan(0)
|
||||
expect(validVotes).toBeGreaterThan(0)
|
||||
expect(statewideShare).toBeGreaterThan(0)
|
||||
|
||||
const expectedLq = ownVotes / validVotes / statewideShare
|
||||
const classifierLq = computeMunicipalityTerritorialClass(slug).lq
|
||||
|
||||
expect(classifierLq).not.toBeNull()
|
||||
if (classifierLq === null) return
|
||||
|
||||
expect(classifierLq).toBeCloseTo(expectedLq, 10)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue