teqo-backup/src/lib/choroplethColorScale.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

84 lines
3.6 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.

/** Light muted rose → campaign primary #c51414 */
const choroplethGradientStart = { r: 254, g: 226, b: 226 } as const
const choroplethGradientEnd = { r: 197, g: 20, b: 20 } as const
export const choroplethGradientCss = `linear-gradient(to right, rgb(${choroplethGradientStart.r} ${choroplethGradientStart.g} ${choroplethGradientStart.b}), rgb(${choroplethGradientEnd.r} ${choroplethGradientEnd.g} ${choroplethGradientEnd.b}))`
/** Painted where the geography has no reading at all — never where the value is zero. */
export const NO_DATA_FILL = '#f4f4f5'
/** Sequential fill for campaign choropleth maps (primary red scale). */
export const choroplethFillColor = (value: number, max: number): string => {
if (value <= 0 || max <= 0) return NO_DATA_FILL
const ratio = Math.min(1, value / max)
const start = choroplethGradientStart
const end = choroplethGradientEnd
const r = Math.round(start.r + (end.r - start.r) * ratio)
const g = Math.round(start.g + (end.g - start.g) * ratio)
const b = Math.round(start.b + (end.b - start.b) * ratio)
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_FILL`.
*/
const DISCRETE_CLASS_FLOOR = 0.18
export const discreteChoroplethFill = (index: number, total: number): string => {
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
}
/**
* Diverging comparison scale (product decision 2026-07-20): campaign red where
* Solla leads, white at the tie, blue where the compared candidate leads.
*/
const divergingBlueEnd = { r: 30, g: 64, b: 175 } as const
const white = { r: 255, g: 255, b: 255 } as const
export const divergingFillColor = (diff: number, maxAbs: number): string => {
if (maxAbs <= 0 || diff === 0) return `rgb(${white.r}, ${white.g}, ${white.b})`
const ratio = Math.min(1, Math.abs(diff) / maxAbs)
const end = diff > 0 ? choroplethGradientEnd : divergingBlueEnd
const r = Math.round(white.r + (end.r - white.r) * ratio)
const g = Math.round(white.g + (end.g - white.g) * ratio)
const b = Math.round(white.b + (end.b - white.b) * ratio)
return `rgb(${r}, ${g}, ${b})`
}
export const divergingGradientCss = `linear-gradient(to right, rgb(${divergingBlueEnd.r} ${divergingBlueEnd.g} ${divergingBlueEnd.b}), rgb(255 255 255), rgb(${choroplethGradientEnd.r} ${choroplethGradientEnd.g} ${choroplethGradientEnd.b}))`
export const choroplethMaxAbsValue = (values: Record<string, number>): number => {
const entries = Object.values(values).map((value) => Math.abs(value))
return entries.length > 0 ? Math.max(...entries) : 0
}
/** Share of valid votes per geography (0–1). Omits entries with no valid denominator or zero votes. */
export const computeValidVoteShares = (
votes: Record<string, number>,
validVotes: Record<string, number>,
): Record<string, number> => {
const shares: Record<string, number> = {}
for (const [code, voteCount] of Object.entries(votes)) {
const valid = validVotes[code]
if (valid > 0 && voteCount > 0) {
shares[code] = voteCount / valid
}
}
return shares
}