'use client' import Link from 'next/link' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useCallback, useMemo, useRef, useState, useTransition } from 'react' import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/map/BahiaMap' import { ChoroplethLegend } from '@/components/campaign/map/ChoroplethLegend' import { MapFeatureReadout } from '@/components/campaign/map/MapFeatureReadout' import { MapScaleLegend } from '@/components/campaign/map/MapScaleLegend' import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext' import { VOTE_ESTIMATE_SCENARIO_MAP_HINT, VoteEstimateScenarioField, } from '@/components/campaign/votePledge/VoteEstimateScenarioField' import { Checkbox } from '@/components/ui/Checkbox' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { bubbleRadius, computeChoroplethMax, type ChoroplethFills, type ChoroplethValues, } from '@/lib/bahiaMapStyle' import type { CampaignConceptId } from '@/lib/campaignIntelligenceConcepts' import { choroplethMaxValue, computeValidVoteShares, divergingGradientCss, } from '@/lib/choroplethColorScale' import { formatElectionNumber, formatPlacementOrdinal } from '@/lib/electionFormat' import { buildCompetitiveRankClassing, buildLqClassing, buildQuantileClassing, fillsForClassing, QUANTILE_CLASSES, QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT, } from '@/lib/mapScaleClasses' import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels, type VoteEstimateScenario, } from '@/lib/voteEstimate' import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions' import { formatDominanceAgainstOwnStandard, territorialClassLabels, territorialClassMapFill, } from '@/utilities/municipalityLabels' import { DEFAULT_MUNICIPALITY_MAP_SCALE_MODE, isMunicipalityMapScaleModeAvailable, MUNICIPALITY_MAP_SCALE_MODES, MUNICIPALITY_MAP_YEARS, municipalityMapScaleModeHints, municipalityMapScaleModeLabels, municipalityMapYearLabels, type MunicipalityMapBundle, type MunicipalityMapScaleMode, type MunicipalityMapYear, } from '@/utilities/municipalityMapContract' import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation' import type { MunicipalityTerritorialClass } from '@/utilities/municipalityTerritorialClass' /** The legend's note doubles as the scale selector's description. */ const SCALE_NOTE_ID = 'municipality-map-scale-note' const BUBBLE_TOGGLE_ID = 'municipality-map-bubbles' /** * Scales that have their own glossary entry (E18). LQ is documented as * "dominância relativa" — the same ratio under the name the list already uses. */ const SCALE_CONCEPT_ID: Partial> = { quantile: 'quantis-do-mapa', lq: 'dominancia-relativa', competitiveRank: 'posicao-no-municipio', } /** Reference sizes in the bubble key: the largest bubble and a quarter of it. */ const BUBBLE_KEY_FRACTIONS = [0.25, 1] as const /** * Two encodings need decoding when the layer is on — how big the prize is * (area) and how he stands there (colour) — so the key carries both, with * real circles rather than a sentence about sizes nobody can eyeball. */ const MapBubbleKey = ({ largestValue }: { largestValue: number }) => (
    {BUBBLE_KEY_FRACTIONS.map((fraction) => { const diameter = 2 * bubbleRadius(fraction, 1) return (
  • ) })}
    {Object.entries(territorialClassLabels).map(([territorialClass, label]) => (
  • ))}

Tamanho = votos válidos projetados para 2026, o que está em jogo ali. Cor = classe do município, a leitura da lista.

) type MunicipalityMapPanelProps = { bundle: MunicipalityMapBundle candidateOptions: FederalCandidateOption[] defaultYear?: MunicipalityMapYear } export const MunicipalityMapPanel = ({ bundle, candidateOptions, defaultYear = 2022, }: MunicipalityMapPanelProps) => { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() const [year, setYear] = useState(defaultYear) const [scaleMode, setScaleMode] = useState( DEFAULT_MUNICIPALITY_MAP_SCALE_MODE, ) const [bubblesEnabled, setBubblesEnabled] = useState(false) const scenarioContext = useMunicipalityEstimateScenarioOptional() const [localEstimateScenario, setLocalEstimateScenario] = useState( DEFAULT_VOTE_ESTIMATE_SCENARIO, ) const estimateScenario = scenarioContext?.scenario ?? localEstimateScenario const setEstimateScenario = scenarioContext?.setScenario ?? setLocalEstimateScenario const comparison = bundle.comparison const comparisonActive = comparison !== null && year !== 2026 // Comparing forces the diverging absolute scale, and the competitive // placement has no 2026 reading (it is a TSE result). Rather than let the // select show a mode the map isn't painting, fall back — and the legend note // below says which mode stepped aside, so the fallback isn't silent. const effectiveScaleMode: MunicipalityMapScaleMode = comparisonActive ? 'absolute' : isMunicipalityMapScaleModeAvailable(scaleMode, year) ? scaleMode : DEFAULT_MUNICIPALITY_MAP_SCALE_MODE const percentScaleActive = effectiveScaleMode === 'percentValid' // The compare map already spends colour on two candidates; a third encoding // on top of it would be unreadable, so the bubbles step aside there. const showBubbles = bubblesEnabled && !comparisonActive const rawValues = useMemo(() => { if (comparisonActive && comparison) { return comparison.diffByYear[String(year)] ?? {} } if (year === 2026) { return bundle.values2026ByScenario[estimateScenario] ?? {} } return bundle.valuesByYear[String(year)] ?? {} }, [bundle, comparison, comparisonActive, estimateScenario, year]) const validVotesForYear = useMemo( () => bundle.validVotesByYear[String(year)] ?? {}, [bundle.validVotesByYear, year], ) const displayValues = useMemo(() => { if (percentScaleActive) { return computeValidVoteShares(rawValues, validVotesForYear) } return rawValues }, [percentScaleActive, rawValues, validVotesForYear]) const displayMax = useMemo(() => { const fillMode = comparisonActive ? 'diverging' : 'sequential' return computeChoroplethMax(displayValues, fillMode, percentScaleActive ? 1 : undefined) }, [comparisonActive, displayValues, percentScaleActive]) const scopedKeys = useMemo( () => Object.keys(bundle.municipalitiesByIbgeCode), [bundle.municipalitiesByIbgeCode], ) /** LQ per município against his statewide standard — the same ratio E10 classes on. */ const lqByCode = useMemo(() => { const statewideShare = bundle.statewideShareByYear[String(year)] ?? 0 if (statewideShare <= 0) return {} const lq: Record = {} for (const [code, votes] of Object.entries(rawValues)) { const validVotes = validVotesForYear[code] ?? 0 if (votes <= 0 || validVotes <= 0) continue lq[code] = votes / validVotes / statewideShare } return lq }, [bundle.statewideShareByYear, rawValues, validVotesForYear, year]) const competitiveRankForYear = useMemo( () => bundle.competitiveRankByYear[String(year)] ?? {}, [bundle.competitiveRankByYear, year], ) const classing = useMemo(() => { switch (effectiveScaleMode) { case 'quantile': return buildQuantileClassing(rawValues) case 'lq': return buildLqClassing(lqByCode) case 'competitiveRank': return buildCompetitiveRankClassing( Object.fromEntries( Object.entries(competitiveRankForYear).map(([code, placement]) => [ code, placement.rank, ]), ), ) default: return null } }, [competitiveRankForYear, effectiveScaleMode, lqByCode, rawValues]) // A classing with no classes paints nothing, so the legend must fall through // to the continuous ramp — passing `{}` here would tell `BahiaMap` the map is // 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], ) /** * What the colour means, said ONCE and next to the swatches that need * decoding — putting it under the selector instead made the control column * taller than the title beside it, and repeated the sentence the legend was * already carrying. */ const legendNote = useMemo(() => { const parts = [municipalityMapScaleModeHints[effectiveScaleMode]] if (!isMunicipalityMapScaleModeAvailable(scaleMode, year) && !comparisonActive) { parts.push( `${municipalityMapScaleModeLabels[scaleMode]} só existe nos anos com resultado do TSE, então ${year} volta para esta escala.`, ) } if (classed && classing) { const painted = Object.keys(classing.classIndexByKey).length // One sentence for the coverage, not two: "N com votos" plus "M em // cinza" made the reader add them up to check they matched the scope. parts.push( painted === scopedKeys.length ? `Todos os ${formatElectionNumber(painted)} municípios do seu escopo entraram na escala.` : `${formatElectionNumber(painted)} de ${formatElectionNumber(scopedKeys.length)} municípios do seu escopo entraram na escala; os demais ficam em cinza.`, ) if (effectiveScaleMode === 'quantile' && classing.classes.length < QUANTILE_CLASSES) { parts.push( painted < QUANTILE_MIN_FEATURES_FOR_FULL_SPLIT ? `São poucos para cinco faixas, então a escala usa ${classing.classes.length}.` : `Os valores se repetem demais para cinco faixas, então a escala usa ${classing.classes.length}.`, ) } if (effectiveScaleMode === 'lq') { parts.push('1× é a média estadual dele, não a dos municípios em exibição.') } if (effectiveScaleMode === 'competitiveRank') { parts.push('Vale para a cidade inteira: em Salvador, as 19 zonas dividem a mesma posição.') } } return parts.join(' ') }, [classed, classing, comparisonActive, effectiveScaleMode, scaleMode, scopedKeys.length, year]) const metricLabel = useMemo(() => { if (comparisonActive) { return `diferença de votos em ${year}` } if (percentScaleActive) { if (year === 2026) { return 'participação nos válidos 2022 (estimativas 2026)' } return `participação nos válidos (${year})` } if (year === 2026) return `votos estimados 2026 (${voteEstimateScenarioLabels[estimateScenario].toLowerCase()})` return `votos de ${bundle.candidateName} em ${year}` }, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year]) /** * Votes at stake, not votes won: the bubble asks "how big is the prize * here", which is a different question from the colour's "how is he doing * here" — reading them together is the point of the layer. * * Both sides are already stable references, so there is nothing to memoize. */ const bubbleValues = showBubbles ? bundle.projectedValidVotesByCode : undefined const bubbleFillByKey = useMemo(() => { if (!showBubbles) return undefined const fills: ChoroplethFills = {} for (const [code, territorialClass] of Object.entries(bundle.territorialClassByCode)) { fills[code] = territorialClassMapFill[territorialClass] } return fills }, [bundle.territorialClassByCode, showBubbles]) // The key's reference circles and the map's radius denominator have to be // the same number, or the key labels a size the map never draws. const largestBubbleValue = bubbleValues ? choroplethMaxValue(bubbleValues) : 0 const bubbleReadingFor = useCallback( (key: string): string | null => { if (!showBubbles) return null const projected = bundle.projectedValidVotesByCode[key] ?? 0 if (projected <= 0) return null const territorialClass = bundle.territorialClassByCode[key] const classLabel = territorialClass ? territorialClassLabels[territorialClass] : null return `${formatElectionNumber(projected)} válidos projetados em jogo${classLabel ? ` · ${classLabel}` : ''}` }, [bundle.projectedValidVotesByCode, bundle.territorialClassByCode, showBubbles], ) /** * The relative sentence for one município — the class alone ("Q4") is a * verdict without evidence, so the readout always spells out what the colour * means for the município under the cursor. */ const relativeReadingFor = useCallback( (key: string): string | null => { if (!classing) return null const classIndex = classing.classIndexByKey[key] switch (effectiveScaleMode) { case 'quantile': { if (classIndex === undefined) return null const range = classing.classes[classIndex]?.label return `${classIndex + 1}ª de ${classing.classes.length} faixas${range ? ` (${range} votos)` : ''}` } case 'lq': { const lq = lqByCode[key] return lq === undefined ? null : formatDominanceAgainstOwnStandard(lq) } case 'competitiveRank': { const placement = competitiveRankForYear[key] if (!placement) return null return `${formatPlacementOrdinal(placement.rank)} entre ${formatElectionNumber(placement.candidates)} candidatos votados aqui` } default: return null } }, [classing, competitiveRankForYear, effectiveScaleMode, lqByCode], ) // Optimistic on the control, honest pending on the map: the comparison data // is a server round trip, so the select flips at once and the map region // dims until the refreshed bundle arrives. const [isComparePending, startCompareTransition] = useTransition() const [optimisticCompare, setOptimisticCompare] = useState(null) const compareSelectValue = isComparePending && optimisticCompare !== null ? optimisticCompare : comparison ? String(comparison.candidateNumber) : '' const setCompare = (candidateNumber: string) => { setOptimisticCompare(candidateNumber) const params = new URLSearchParams(searchParams.toString()) if (candidateNumber) params.set('compare', candidateNumber) else params.delete('compare') startCompareTransition(() => { router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false }) }) } return (

Mapa dos Municípios

{/* What the map is made of stays here; what the COLOUR means is said once, next to the scale selector that decides it. */}

{comparisonActive && comparison ? `Comparação ${bundle.candidateName} × ${comparison.candidateName} em ${year}.` : `Votação de ${bundle.candidateName} por município — ${year === 2026 ? 'em 2026, pelas estimativas da campanha' : `em ${year}, pelo resultado do TSE`}.`}

Ano setYear(Number(event.target.value) as MunicipalityMapYear)} className="min-h-11 w-full" > {MUNICIPALITY_MAP_YEARS.map((entry) => ( {municipalityMapYearLabels[entry]} ))} {!comparisonActive ? ( Escala setScaleMode(event.target.value as MunicipalityMapScaleMode)} className="min-h-11 w-full" aria-describedby={SCALE_NOTE_ID} > {MUNICIPALITY_MAP_SCALE_MODES.filter((mode) => isMunicipalityMapScaleModeAvailable(mode, year), ).map((mode) => ( {municipalityMapScaleModeLabels[mode]} ))} ) : null} {year === 2026 && !comparisonActive ? ( ) : null} Comparar com setCompare(event.target.value)} className="min-h-11 w-full" > Sem comparação {candidateOptions.map((candidate) => ( {candidate.name} {candidate.party ? ` (${candidate.party})` : ''} — {candidate.candidateNumber} ))}

{isComparePending ? 'Atualizando comparação…' : ''}

{comparisonActive && comparison ? (
+{comparison.candidateName}
+{bundle.candidateName}

Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '} {comparison.candidateName} na frente. Comparação usa diferença absoluta.

) : classed && classing ? ( ) : displayMax > 0 ? ( '100%' : undefined} note={legendNote} noteId={SCALE_NOTE_ID} /> ) : (

Sem dados para este ano no seu escopo.

)} {/* A second encoding, so it is opt-in and its key only appears with it. */} {!comparisonActive ? (
setBubblesEnabled(checked === true)} /> Bolhas por votos em jogo {showBubbles ? ( largestBubbleValue > 0 ? ( ) : (

Sem projeção de votos válidos para os municípios do seu escopo.

) ) : null}
) : null} {comparison && year === 2026 ? (

A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para ver o mapa comparativo.

) : null} {/* zone breakdown below */} {bundle.zoneBreakdown.length > 0 && !comparisonActive ? (

Municípios por zona eleitoral

Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e detalhadas aqui.

    {bundle.zoneBreakdown.map((zone) => (
  • {zone.name} {formatElectionNumber( year === 2026 ? (zone.votes2026ByScenario[estimateScenario] ?? 0) : (zone.votesByYear[String(year)] ?? 0), )}
  • ))}
) : null}
) } /** * Selection/hover live here so a mouseover re-renders only the map and the * readout — not the whole panel (controls, legend, zone list). The Leaflet * paint path itself stays ref-based inside BahiaMap (O(2) restyle). */ const MunicipalityMapSelection = ({ displayValues, rawValues, displayMax, fillByKey, bubbleValues, bubbleFillByKey, scopedKeys, metricLabel, scaleMode, relativeReadingFor, bubbleReadingFor, comparisonActive, municipalitiesByIbgeCode, ariaLabel, }: { displayValues: ChoroplethValues rawValues: ChoroplethValues displayMax: number fillByKey: ChoroplethFills | undefined bubbleValues: ChoroplethValues | undefined bubbleFillByKey: ChoroplethFills | undefined scopedKeys: string[] metricLabel: string scaleMode: MunicipalityMapScaleMode relativeReadingFor: (key: string) => string | null bubbleReadingFor: (key: string) => string | null comparisonActive: boolean municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode'] ariaLabel: string }) => { const router = useRouter() const [selectedFeature, setSelectedFeature] = useState(null) const selectedKeyRef = useRef(null) const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => { selectedKeyRef.current = info?.key ?? null setSelectedFeature(info) }, []) const handleFeatureActivate = useCallback( (key: string) => { if (selectedKeyRef.current !== key) return const navigation = resolveMunicipalityMapNavigation(key, municipalitiesByIbgeCode) if (navigation.kind === 'navigate') { router.push(`/campanha/municipios/${navigation.slug}`) return } if (navigation.kind === 'zones') { document.getElementById('municipality-zone-breakdown')?.scrollIntoView({ behavior: 'smooth', block: 'nearest', }) } }, [municipalitiesByIbgeCode, router], ) const selectedMetricValue = selectedFeature && selectedFeature.key in displayValues ? displayValues[selectedFeature.key] : undefined const selectedRawMetricValue = selectedFeature && selectedFeature.key in rawValues ? rawValues[selectedFeature.key] : undefined const selectedNavigation = useMemo( () => selectedFeature ? resolveMunicipalityMapNavigation(selectedFeature.key, municipalitiesByIbgeCode) : null, [municipalitiesByIbgeCode, selectedFeature], ) return ( <> 0 ? displayMax : undefined} fillByKey={fillByKey} bubbleValues={bubbleValues} bubbleFillByKey={bubbleFillByKey} fillMode={comparisonActive ? 'diverging' : 'sequential'} fitToKeys={scopedKeys} interactiveKeys={scopedKeys} selectedKey={selectedFeature?.key ?? null} onFeatureSelect={handleFeatureSelect} onFeatureActivate={handleFeatureActivate} ariaLabel={ariaLabel} /> ) }