teqo-backup/src/utilities/municipalityMapData.ts
Francisco Ignácio Fontoura Solla 66feecd66b simplify(B13): corrige classe agregada, bolhas em pane proprio e registra debitos
O /simplify da escala relativa do mapa achou um bug semantico real em
computeAggregateTerritorialClass — somava o headroom dos slugs e comparava
contra a mediana de UMA unidade, classificando um grupo grande como expansao
quando ele e marginal. A mediana agora escala por slugs.length, com teste.

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

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

Os quatro debitos maiores que o cleanup viraram o fill-in B13+
(docs/plans/escala-dry-pos-b13.md); a flakiness dos e2e, reproduzida em main
limpo, foi para o ledger com a causa afinada.
2026-07-26 05:12:37 -03:00

296 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'server-only'
import type { Payload } from 'payload'
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 {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
type VoteEstimateScenario,
type VoteEstimateScenarioFields,
} from '@/lib/voteEstimate'
import type { CampaignUser } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import {
municipalityElectionGeography,
type MunicipalityElectionGeography,
} from '@/utilities/municipalityElectionGeography'
import {
loadCandidateVotesByCityZone,
sumVotesForGeography,
} from '@/utilities/municipalityElectoralBaseline'
import {
buildMunicipalityListWhere,
parseMunicipalityListParams,
type MunicipalityListSearchParams,
type MunicipalityListState,
} from '@/utilities/municipalityListUrl'
import type {
MunicipalityMapBundle,
MunicipalityMapComparison,
} from '@/utilities/municipalityMapContract'
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import { projectedValidVotes } from '@/utilities/municipalityPotential'
import {
computeAggregateTerritorialClass,
type MunicipalityTerritorialClass,
} from '@/utilities/municipalityTerritorialClass'
import {
emptyMunicipalityPledgeAggregate,
resolveMunicipalityStaffVoteTotal,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeViews'
// Client components import the map vocabulary from the contract module; the
// server side re-exports it so data callers have one import surface.
export type { MunicipalityMapBundle } from '@/utilities/municipalityMapContract'
type ScopedMunicipality = {
id: number
slug: string
name: string
kind: 'municipio' | 'zona'
ibgeCode: string
expectedVotes?: VoteEstimateScenarioFields | null
geography: MunicipalityElectionGeography
}
type ScopedMunicipalityDoc = {
id: number
slug: string
name: string
kind: 'municipio' | 'zona'
ibgeCode: string
expectedVotes?: VoteEstimateScenarioFields | null
}
const scopeMunicipalitiesFromDocs = (
docs: ReadonlyArray<ScopedMunicipalityDoc>,
): ScopedMunicipality[] =>
docs.flatMap((municipality) => {
const entry = getMunicipalityCatalogEntry(municipality.slug)
if (!entry) return []
return [
{
id: municipality.id,
slug: municipality.slug,
name: municipality.name,
kind: municipality.kind,
ibgeCode: municipality.ibgeCode,
expectedVotes: municipality.expectedVotes ?? null,
geography: municipalityElectionGeography(entry),
},
]
})
const buildMunicipalityMapBundleFromMunicipalities = async (
payload: Payload,
user: CampaignUser,
state: MunicipalityListState,
municipalities: ScopedMunicipality[],
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
): Promise<MunicipalityMapBundle | null> => {
if (municipalities.length === 0) return null
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>>()
// One polygon per IBGE code, N catalog slugs behind it (Salvador is 19) —
// the same index the map uses to navigate, reused here for the rollups.
const municipalitiesByIbgeCode = buildMunicipalitiesByIbgeCode(municipalities)
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
for (const year of HISTORICAL_SERIES_YEARS) {
const values: Record<string, number> = {}
const validValues: Record<string, number> = {}
for (const municipality of municipalities) {
const baseline = getMunicipalityFederalBaseline(municipality.slug)
const votes = baseline.votesByYear[String(year)] ?? 0
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + votes
const valid = baseline.validVotesByYear[String(year)] ?? 0
validValues[municipality.ibgeCode] = (validValues[municipality.ibgeCode] ?? 0) + valid
if (municipality.kind === 'zona') {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
bySlug[String(year)] = votes
zoneVotesBySlug.set(municipality.slug, bySlug)
}
}
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 Object.keys(municipalitiesByIbgeCode)) {
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, entries] of Object.entries(municipalitiesByIbgeCode)) {
const slugs = entries.map((entry) => entry.slug)
territorialClassByCode[ibgeCode] = computeAggregateTerritorialClass(slugs).class
projectedValidVotesByCode[ibgeCode] = slugs.reduce(
(total, slug) => total + projectedValidVotes(getMunicipalityFederalBaseline(slug)),
0,
)
}
const pledgeValuesByScenario = Object.fromEntries(
VOTE_ESTIMATE_SCENARIOS.map((scenario) => [scenario, {} as Record<string, number>]),
) as Record<VoteEstimateScenario, Record<string, number>>
const zoneVotes2026BySlug = new Map<string, Record<VoteEstimateScenario, number>>()
const emptyZoneScenarioVotes = (): Record<VoteEstimateScenario, number> => ({
pessimistic: 0,
central: 0,
optimistic: 0,
})
for (const municipality of municipalities) {
const aggregate = pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
const votes = resolveMunicipalityStaffVoteTotal(
municipality.expectedVotes,
aggregate.effectiveByScenario[scenario],
scenario,
)
if (votes > 0) {
pledgeValuesByScenario[scenario][municipality.ibgeCode] =
(pledgeValuesByScenario[scenario][municipality.ibgeCode] ?? 0) + votes
}
if (municipality.kind === 'zona') {
const byScenario = zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes()
byScenario[scenario] = votes
zoneVotes2026BySlug.set(municipality.slug, byScenario)
if (scenario === DEFAULT_VOTE_ESTIMATE_SCENARIO) {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
bySlug['2026'] = votes
zoneVotesBySlug.set(municipality.slug, bySlug)
}
}
}
}
valuesByYear['2026'] = pledgeValuesByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO]
const zoneBreakdown = municipalities
.filter((municipality) => municipality.kind === 'zona')
.map((municipality) => ({
slug: municipality.slug,
name: municipality.name,
votesByYear: zoneVotesBySlug.get(municipality.slug) ?? {},
votes2026ByScenario: zoneVotes2026BySlug.get(municipality.slug) ?? emptyZoneScenarioVotes(),
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
let comparison: MunicipalityMapComparison | null = null
const compareCandidate = state.compare
if (compareCandidate) {
const candidates = await payload.find({
collection: 'electionCandidate',
where: {
and: [
{ candidateNumber: { equals: compareCandidate } },
{ office: { equals: 'deputado_federal' } },
{ turn: { equals: '1' } },
],
},
depth: 0,
limit: 1,
pagination: false,
sort: '-year',
select: { urnaName: true, party: true },
overrideAccess: true,
})
const candidate = candidates.docs[0]
if (candidate) {
const diffByYear: Record<string, Record<string, number>> = {}
await Promise.all(
HISTORICAL_SERIES_YEARS.map(async (year) => {
const otherVotes = await loadCandidateVotesByCityZone(payload, user, {
year,
candidateNumber: compareCandidate,
})
const values: Record<string, number> = {}
for (const municipality of municipalities) {
const solla =
getMunicipalityFederalBaseline(municipality.slug).votesByYear[String(year)] ?? 0
const other = sumVotesForGeography(otherVotes, municipality.geography)
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + (solla - other)
}
diffByYear[String(year)] = values
}),
)
comparison = {
candidateNumber: compareCandidate,
candidateName: `${candidate.urnaName}${candidate.party ? ` (${candidate.party})` : ''}`,
diffByYear,
}
}
}
return {
valuesByYear,
values2026ByScenario: pledgeValuesByScenario,
validVotesByYear,
statewideShareByYear,
territorialClassByCode,
competitiveRankByYear,
projectedValidVotesByCode,
municipalitiesByIbgeCode,
zoneBreakdown,
candidateName: BASELINE_TICKET_2022.candidate.name,
comparison,
}
}
/**
* Map data for the municipalities overview: municipalities matching the list URL filters (and
* role access) contribute their geography's votes (or 2026 pledge estimates)
* to municipality polygons. When `state.compare` is set, TSE years also carry
* a red↔white↔blue diff (Solla − other candidate).
*/
export const loadMunicipalityMapBundle = async (
payload: Payload,
user: CampaignUser,
searchParams: MunicipalityListSearchParams,
): Promise<MunicipalityMapBundle | null> => {
if (user.role === 'leader') return null
const state = parseMunicipalityListParams(searchParams)
const scope = await loadMunicipalityScope(payload, user, buildMunicipalityListWhere(state))
const municipalities = scopeMunicipalitiesFromDocs(scope.municipalities)
if (municipalities.length === 0) return null
return buildMunicipalityMapBundleFromMunicipalities(
payload,
user,
state,
municipalities,
scope.pledgeAggregates,
)
}