mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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.
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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,
|
||
)
|
||
}
|