Leva a leitura dos 27 Territórios de Identidade para o sistema de listas com estado canônico na URL e remove o painel duplicado do Início.
252 lines
7.9 KiB
TypeScript
252 lines
7.9 KiB
TypeScript
/**
|
||
* E17 — Territórios de Identidade comparative rollup for the staff Início.
|
||
*
|
||
* First slice of the TI layer (E12 extends). Only sums and ratios of aggregates
|
||
* — never a mean of ratios (MAUP safeguard). The Metropolitano de Salvador TI
|
||
* is always decomposed into two sub-rows (Salvador = 19 zones, Demais RMS),
|
||
* the permanent exception from the discovery report (24,4% of the electorate).
|
||
*
|
||
* This module is **client-safe** (no `server-only`, no Payload/artifact imports):
|
||
* `computeTerritoryRollup` / `sortTerritoryRows` are pure and unit-tested with a
|
||
* fixture. The server loader lives in `loadTerritoryOverview.ts`.
|
||
*/
|
||
|
||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||
|
||
const SALVADOR_CITY = 'Salvador'
|
||
const SALVADOR_SUB_LABEL = 'Salvador (19 zonas)'
|
||
const RMS_DEMAIS_SUB_LABEL = 'Demais municípios da RMS'
|
||
const METROPOLITANO_REGION = 'Metropolitano de Salvador'
|
||
const ELECTION_YEAR_2022 = '2022'
|
||
|
||
export type TerritoryMunicipalityInput = {
|
||
slug: string
|
||
name: string
|
||
city: string
|
||
region: string
|
||
kind: 'municipio' | 'zona'
|
||
votesByYear: Record<string, number>
|
||
validVotesByYear: Record<string, number>
|
||
estimate2026: number
|
||
advisorCount: number
|
||
}
|
||
|
||
type TerritoryOverviewSubRow = {
|
||
label: string
|
||
municipalityCount: number
|
||
votesByYear: Record<string, number>
|
||
validVotes2022: number
|
||
estimate2026: number
|
||
withAdvisorCount: number
|
||
pctPropriaVotacao: number
|
||
}
|
||
|
||
export type TerritoryOverviewRow = {
|
||
region: string
|
||
municipalityCount: number
|
||
votesByYear: Record<string, number>
|
||
validVotes2022: number
|
||
pctPropriaVotacao: number
|
||
estimate2026: number
|
||
withAdvisorCount: number
|
||
subRows?: TerritoryOverviewSubRow[]
|
||
}
|
||
|
||
export type TerritoryTableRow =
|
||
| ({ variant: 'parent' } & Omit<TerritoryOverviewRow, 'subRows'>)
|
||
| ({ variant: 'sub'; parentRegion: string } & TerritoryOverviewSubRow)
|
||
|
||
export type TerritorySortKey =
|
||
| 'region'
|
||
| 'municipalities'
|
||
| 'votes2022'
|
||
| 'pct'
|
||
| 'validVotes2022'
|
||
| 'estimate2026'
|
||
| 'coverage'
|
||
|
||
export type TerritorySortDir = 'asc' | 'desc'
|
||
|
||
type Accumulator = {
|
||
region: string
|
||
municipalityCount: number
|
||
votesByYear: Record<string, number>
|
||
validVotes2022: number
|
||
estimate2026: number
|
||
withAdvisorCount: number
|
||
}
|
||
|
||
const createAccumulator = (region: string): Accumulator => ({
|
||
region,
|
||
municipalityCount: 0,
|
||
votesByYear: {},
|
||
validVotes2022: 0,
|
||
estimate2026: 0,
|
||
withAdvisorCount: 0,
|
||
})
|
||
|
||
const accumulateInto = (acc: Accumulator, input: TerritoryMunicipalityInput): void => {
|
||
acc.municipalityCount += 1
|
||
for (const [year, votes] of Object.entries(input.votesByYear)) {
|
||
acc.votesByYear[year] = (acc.votesByYear[year] ?? 0) + votes
|
||
}
|
||
acc.validVotes2022 += input.validVotesByYear[ELECTION_YEAR_2022] ?? 0
|
||
acc.estimate2026 += input.estimate2026
|
||
if (input.advisorCount > 0) acc.withAdvisorCount += 1
|
||
}
|
||
|
||
const pctOf = (acc: Accumulator, stateTotal2022: number): number =>
|
||
stateTotal2022 > 0 ? (acc.votesByYear[ELECTION_YEAR_2022] ?? 0) / stateTotal2022 : 0
|
||
|
||
const finalizeRow = (acc: Accumulator, stateTotal2022: number): TerritoryOverviewRow => ({
|
||
region: acc.region,
|
||
municipalityCount: acc.municipalityCount,
|
||
votesByYear: { ...acc.votesByYear },
|
||
validVotes2022: acc.validVotes2022,
|
||
pctPropriaVotacao: pctOf(acc, stateTotal2022),
|
||
estimate2026: acc.estimate2026,
|
||
withAdvisorCount: acc.withAdvisorCount,
|
||
})
|
||
|
||
const finalizeSubRow = (
|
||
label: string,
|
||
acc: Accumulator,
|
||
stateTotal2022: number,
|
||
): TerritoryOverviewSubRow => ({
|
||
label,
|
||
municipalityCount: acc.municipalityCount,
|
||
votesByYear: { ...acc.votesByYear },
|
||
validVotes2022: acc.validVotes2022,
|
||
estimate2026: acc.estimate2026,
|
||
withAdvisorCount: acc.withAdvisorCount,
|
||
pctPropriaVotacao: pctOf(acc, stateTotal2022),
|
||
})
|
||
|
||
/**
|
||
* Computes the 27-TI comparative rollup. Rows are returned in first-seen order
|
||
* (catalog order when fed from the loader); the Metropolitano de Salvador row
|
||
* carries two sub-rows (Salvador 19 zones × Demais RMS). Use `sortTerritoryRows`
|
||
* for display order.
|
||
*/
|
||
export const computeTerritoryRollup = (
|
||
inputs: ReadonlyArray<TerritoryMunicipalityInput>,
|
||
): TerritoryOverviewRow[] => {
|
||
const stateTotal2022 = inputs.reduce(
|
||
(sum, input) => sum + (input.votesByYear[ELECTION_YEAR_2022] ?? 0),
|
||
0,
|
||
)
|
||
|
||
const byRegion = new Map<string, Accumulator>()
|
||
const metropolitanoSalvador = createAccumulator(METROPOLITANO_REGION)
|
||
const metropolitanoDemais = createAccumulator(METROPOLITANO_REGION)
|
||
|
||
for (const input of inputs) {
|
||
// Metropolitano inputs are decomposed into the Salvador/Demais sub-rows
|
||
// below AND rolled into the parent TI total — this is intentional, not a
|
||
// double-count: the parent row sums all of Metropolitano, the sub-rows
|
||
// break it down.
|
||
if (input.region === METROPOLITANO_REGION) {
|
||
if (input.city === SALVADOR_CITY) {
|
||
accumulateInto(metropolitanoSalvador, input)
|
||
} else {
|
||
accumulateInto(metropolitanoDemais, input)
|
||
}
|
||
}
|
||
const acc = byRegion.get(input.region) ?? createAccumulator(input.region)
|
||
accumulateInto(acc, input)
|
||
byRegion.set(input.region, acc)
|
||
}
|
||
|
||
const rows: TerritoryOverviewRow[] = []
|
||
for (const acc of byRegion.values()) {
|
||
const row = finalizeRow(acc, stateTotal2022)
|
||
if (acc.region === METROPOLITANO_REGION) {
|
||
row.subRows = [
|
||
finalizeSubRow(SALVADOR_SUB_LABEL, metropolitanoSalvador, stateTotal2022),
|
||
finalizeSubRow(RMS_DEMAIS_SUB_LABEL, metropolitanoDemais, stateTotal2022),
|
||
]
|
||
}
|
||
rows.push(row)
|
||
}
|
||
|
||
return rows
|
||
}
|
||
|
||
const rowSortValue = (row: TerritoryOverviewRow, key: TerritorySortKey): number | string => {
|
||
switch (key) {
|
||
case 'region':
|
||
return row.region
|
||
case 'municipalities':
|
||
return row.municipalityCount
|
||
case 'votes2022':
|
||
return row.votesByYear[ELECTION_YEAR_2022] ?? 0
|
||
case 'pct':
|
||
return row.pctPropriaVotacao
|
||
case 'validVotes2022':
|
||
return row.validVotes2022
|
||
case 'estimate2026':
|
||
return row.estimate2026
|
||
case 'coverage':
|
||
return row.municipalityCount > 0 ? row.withAdvisorCount / row.municipalityCount : 0
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sorts the 27 top-level rows. Metropolitano sub-rows stay attached to their
|
||
* parent (never re-sorted independently). Returns a new array.
|
||
*/
|
||
export const sortTerritoryRows = (
|
||
rows: ReadonlyArray<TerritoryOverviewRow>,
|
||
key: TerritorySortKey,
|
||
dir: TerritorySortDir = 'desc',
|
||
): TerritoryOverviewRow[] => {
|
||
const factor = dir === 'asc' ? 1 : -1
|
||
return [...rows].sort((left, right) => {
|
||
const a = rowSortValue(left, key)
|
||
const b = rowSortValue(right, key)
|
||
if (typeof a === 'string' || typeof b === 'string') {
|
||
return String(a).localeCompare(String(b), 'pt-BR') * factor
|
||
}
|
||
return (a - b) * factor
|
||
})
|
||
}
|
||
|
||
type TerritoryRowFilters = {
|
||
q?: string
|
||
regions?: readonly string[]
|
||
coverage?: 'com_assessor' | 'sem_assessor'
|
||
}
|
||
|
||
/** Filters top-level territories; display-only sub-rows always follow their parent. */
|
||
export const filterTerritoryRows = (
|
||
rows: ReadonlyArray<TerritoryOverviewRow>,
|
||
filters: TerritoryRowFilters,
|
||
): TerritoryOverviewRow[] => {
|
||
const query = filters.q ? normalizeSearchPhrase(filters.q) : ''
|
||
const selectedRegions = new Set(filters.regions ?? [])
|
||
|
||
return rows.filter((row) => {
|
||
if (query && !normalizeSearchPhrase(row.region).includes(query)) return false
|
||
if (selectedRegions.size && !selectedRegions.has(row.region)) return false
|
||
if (filters.coverage === 'com_assessor') {
|
||
return row.municipalityCount > 0 && row.withAdvisorCount === row.municipalityCount
|
||
}
|
||
if (filters.coverage === 'sem_assessor') {
|
||
return row.withAdvisorCount < row.municipalityCount
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
/** Flattens display-only sub-rows while preserving their parent adjacency. */
|
||
export const flattenTerritoryRows = (
|
||
rows: ReadonlyArray<TerritoryOverviewRow>,
|
||
): TerritoryTableRow[] =>
|
||
rows.flatMap(({ subRows, ...row }) => [
|
||
{ ...row, variant: 'parent' as const },
|
||
...(subRows ?? []).map((subRow) => ({
|
||
...subRow,
|
||
variant: 'sub' as const,
|
||
parentRegion: row.region,
|
||
})),
|
||
])
|