diff --git a/src/components/campaign/NucleusDetailMap.tsx b/src/components/campaign/NucleusDetailMap.tsx
new file mode 100644
index 00000000..b9fa95dc
--- /dev/null
+++ b/src/components/campaign/NucleusDetailMap.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import { useMemo } from 'react'
+
+import { BahiaMap } from '@/components/campaign/BahiaMap'
+
+type NucleusDetailMapProps = {
+ codareas: string[]
+ territoryCodes: string[]
+ territoryLabel: string
+}
+
+export const NucleusDetailMap = ({
+ codareas,
+ territoryCodes,
+ territoryLabel,
+}: NucleusDetailMapProps) => {
+ const highlightKeys = codareas.length > 0 ? codareas : territoryCodes
+ const mode = codareas.length > 0 ? 'municipality' : 'territory'
+ const values = useMemo(
+ () => Object.fromEntries(highlightKeys.map((key) => [key, 1])),
+ [highlightKeys],
+ )
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/NucleusDetailMapDynamic.tsx b/src/components/campaign/NucleusDetailMapDynamic.tsx
new file mode 100644
index 00000000..e44be794
--- /dev/null
+++ b/src/components/campaign/NucleusDetailMapDynamic.tsx
@@ -0,0 +1,25 @@
+'use client'
+
+import { createCampaignClientDynamic } from '@/components/campaign/createCampaignClientDynamic'
+
+const NucleusDetailMap = createCampaignClientDynamic<{
+ codareas: string[]
+ territoryCodes: string[]
+ territoryLabel: string
+}>(() => import('@/components/campaign/NucleusDetailMap'), 'NucleusDetailMap', 'h-[280px]')
+
+export const NucleusDetailMapDynamic = ({
+ codareas,
+ territoryCodes,
+ territoryLabel,
+}: {
+ codareas: string[]
+ territoryCodes: string[]
+ territoryLabel: string
+}) => (
+
+)
diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx
index a7a2329f..8974ea40 100644
--- a/src/components/campaign/NucleusListOverview.tsx
+++ b/src/components/campaign/NucleusListOverview.tsx
@@ -1,6 +1,7 @@
import Link from 'next/link'
import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness'
+import { NucleusOverviewMapDynamic } from '@/components/campaign/NucleusOverviewMapDynamic'
import { CampaignMetricStrip, type CampaignMetric } from '@/components/campaign/CampaignMetricStrip'
import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
@@ -141,6 +142,8 @@ export const NucleusListOverview = ({
+
Demais indicadores
diff --git a/src/components/campaign/NucleusOverviewMap.tsx b/src/components/campaign/NucleusOverviewMap.tsx
new file mode 100644
index 00000000..b40989c5
--- /dev/null
+++ b/src/components/campaign/NucleusOverviewMap.tsx
@@ -0,0 +1,15 @@
+'use client'
+
+import { ChoroplethMapPanel } from '@/components/campaign/ChoroplethMapPanel'
+import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
+
+export const NucleusOverviewMap = ({ choropleth }: { choropleth: NucleusChoroplethBundle }) => (
+
+)
diff --git a/src/components/campaign/NucleusOverviewMapDynamic.tsx b/src/components/campaign/NucleusOverviewMapDynamic.tsx
new file mode 100644
index 00000000..5d6b2660
--- /dev/null
+++ b/src/components/campaign/NucleusOverviewMapDynamic.tsx
@@ -0,0 +1,16 @@
+'use client'
+
+import { createCampaignClientDynamic } from '@/components/campaign/createCampaignClientDynamic'
+import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
+
+const NucleusOverviewMap = createCampaignClientDynamic<{ choropleth: NucleusChoroplethBundle }>(
+ () => import('@/components/campaign/NucleusOverviewMap'),
+ 'NucleusOverviewMap',
+ 'h-[360px]',
+)
+
+export const NucleusOverviewMapDynamic = ({
+ choropleth,
+}: {
+ choropleth: NucleusChoroplethBundle
+}) =>
diff --git a/src/components/campaign/createCampaignClientDynamic.tsx b/src/components/campaign/createCampaignClientDynamic.tsx
new file mode 100644
index 00000000..53fbbfea
--- /dev/null
+++ b/src/components/campaign/createCampaignClientDynamic.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import dynamic from 'next/dynamic'
+import type { ComponentType } from 'react'
+
+import { Skeleton } from '@/components/ui/skeleton'
+
+export const createCampaignClientDynamic =
(
+ importModule: () => Promise>>,
+ exportName: string,
+ skeletonHeightClassName: string,
+) =>
+ dynamic(() => importModule().then((module) => module[exportName]), {
+ ssr: false,
+ loading: () => (
+
+ ),
+ })
diff --git a/src/lib/bahiaGeometries.ts b/src/lib/bahiaGeometries.ts
index c2793de8..f0f35854 100644
--- a/src/lib/bahiaGeometries.ts
+++ b/src/lib/bahiaGeometries.ts
@@ -1,76 +1,37 @@
/**
- * Static Bahia municipality and identity-territory geometries for the campaign map.
+ * Lazy loaders for Bahia map geometries (B3 / B5 F1).
*
- * TopoJSON files are generated by `pnpm build:geometries` from IBGE Malhas (municípios)
- * and dissolved by the official Territórios de Identidade composition in
- * bahiaTerritories. See scripts/build-bahia-geometries.mjs for full provenance.
- *
- * Framework-free: safe to import from client components in B3 (Leaflet surfaces).
+ * TopoJSON decode runs only when a map surface mounts — not on the default `/campanha` graph.
+ * Submodules: `bahiaMunicipalityGeometries.ts`, `bahiaTerritoryGeometries.ts`.
*/
-import { feature } from 'topojson-client'
-import type { Feature, MultiPolygon, Polygon } from 'geojson'
-import type { GeometryCollection, Topology } from 'topojson-specification'
+export type {
+ BahiaMunicipalityFeature,
+ BahiaMunicipalityProperties,
+ BahiaTerritoryFeature,
+ BahiaTerritoryProperties,
+ MunicipalityGeometryModule,
+ TerritoryGeometryModule,
+} from '@/lib/bahiaGeometriesTypes'
-import municipalityTopologyJson from '@/lib/geometries/bahia-municipalities.topo.json'
-import territoryTopologyJson from '@/lib/geometries/bahia-identity-territories.topo.json'
+import type {
+ MunicipalityGeometryModule,
+ TerritoryGeometryModule,
+} from '@/lib/bahiaGeometriesTypes'
-export type BahiaMunicipalityProperties = {
- codarea: string
- name: string
+let municipalityModulePromise: Promise | null = null
+let territoryModulePromise: Promise | null = null
+
+export const loadMunicipalityGeometryModule = (): Promise => {
+ municipalityModulePromise ??= import('@/lib/bahiaMunicipalityGeometries').then(
+ (module) => module.municipalityGeometryModule,
+ )
+ return municipalityModulePromise
}
-export type BahiaTerritoryProperties = {
- code: string
- name: string
+export const loadTerritoryGeometryModule = (): Promise => {
+ territoryModulePromise ??= import('@/lib/bahiaTerritoryGeometries').then(
+ (module) => module.territoryGeometryModule,
+ )
+ return territoryModulePromise
}
-
-export type BahiaMunicipalityFeature = Feature<
- Polygon | MultiPolygon,
- BahiaMunicipalityProperties
->
-export type BahiaTerritoryFeature = Feature
-
-type MunicipalityTopology = Topology<{
- municipalities: GeometryCollection
-}>
-
-type TerritoryTopology = Topology<{
- territories: GeometryCollection
-}>
-
-export const bahiaMunicipalitiesTopology =
- municipalityTopologyJson as unknown as MunicipalityTopology
-
-export const bahiaIdentityTerritoriesTopology =
- territoryTopologyJson as unknown as TerritoryTopology
-
-const municipalityFeatures = feature(
- bahiaMunicipalitiesTopology,
- bahiaMunicipalitiesTopology.objects.municipalities,
-).features as BahiaMunicipalityFeature[]
-
-const territoryFeatures = feature(
- bahiaIdentityTerritoriesTopology,
- bahiaIdentityTerritoriesTopology.objects.territories,
-).features as BahiaTerritoryFeature[]
-
-const municipalityByCodarea = new Map(
- municipalityFeatures.map((entry) => [entry.properties.codarea, entry]),
-)
-
-const territoryByCode = new Map(
- territoryFeatures.map((entry) => [entry.properties.code, entry]),
-)
-
-export const getMunicipalityFeature = (
- codarea: string,
-): BahiaMunicipalityFeature | undefined => municipalityByCodarea.get(codarea)
-
-export const getTerritoryFeature = (code: string): BahiaTerritoryFeature | undefined =>
- territoryByCode.get(code)
-
-export const bahiaMunicipalityFeatures: readonly BahiaMunicipalityFeature[] =
- municipalityFeatures
-
-export const bahiaTerritoryFeatures: readonly BahiaTerritoryFeature[] = territoryFeatures
diff --git a/src/lib/bahiaGeometriesTypes.ts b/src/lib/bahiaGeometriesTypes.ts
new file mode 100644
index 00000000..eb9c37f3
--- /dev/null
+++ b/src/lib/bahiaGeometriesTypes.ts
@@ -0,0 +1,39 @@
+import type { Feature, MultiPolygon, Polygon } from 'geojson'
+import type { GeometryCollection, Topology } from 'topojson-specification'
+
+export type BahiaMunicipalityProperties = {
+ codarea: string
+ name: string
+}
+
+export type BahiaTerritoryProperties = {
+ code: string
+ name: string
+}
+
+export type BahiaMunicipalityFeature = Feature<
+ Polygon | MultiPolygon,
+ BahiaMunicipalityProperties
+>
+
+export type BahiaTerritoryFeature = Feature
+
+export type MunicipalityTopology = Topology<{
+ municipalities: GeometryCollection
+}>
+
+export type TerritoryTopology = Topology<{
+ territories: GeometryCollection
+}>
+
+export type MunicipalityGeometryModule = {
+ topology: MunicipalityTopology
+ features: readonly BahiaMunicipalityFeature[]
+ getMunicipalityFeature: (codarea: string) => BahiaMunicipalityFeature | undefined
+}
+
+export type TerritoryGeometryModule = {
+ topology: TerritoryTopology
+ features: readonly BahiaTerritoryFeature[]
+ getTerritoryFeature: (code: string) => BahiaTerritoryFeature | undefined
+}
diff --git a/src/lib/bahiaMunicipalityGeometries.ts b/src/lib/bahiaMunicipalityGeometries.ts
new file mode 100644
index 00000000..a982d406
--- /dev/null
+++ b/src/lib/bahiaMunicipalityGeometries.ts
@@ -0,0 +1,25 @@
+import { feature } from 'topojson-client'
+
+import municipalityTopologyJson from '@/lib/geometries/bahia-municipalities.topo.json'
+import type {
+ BahiaMunicipalityFeature,
+ MunicipalityGeometryModule,
+ MunicipalityTopology,
+} from '@/lib/bahiaGeometriesTypes'
+
+const topology = municipalityTopologyJson as unknown as MunicipalityTopology
+
+const municipalityFeatures = feature(
+ topology,
+ topology.objects.municipalities,
+).features as BahiaMunicipalityFeature[]
+
+const municipalityByCodarea = new Map(
+ municipalityFeatures.map((entry) => [entry.properties.codarea, entry]),
+)
+
+export const municipalityGeometryModule: MunicipalityGeometryModule = {
+ topology,
+ features: municipalityFeatures,
+ getMunicipalityFeature: (codarea: string) => municipalityByCodarea.get(codarea),
+}
diff --git a/src/lib/bahiaTerritoryGeometries.ts b/src/lib/bahiaTerritoryGeometries.ts
new file mode 100644
index 00000000..713443bb
--- /dev/null
+++ b/src/lib/bahiaTerritoryGeometries.ts
@@ -0,0 +1,25 @@
+import { feature } from 'topojson-client'
+
+import territoryTopologyJson from '@/lib/geometries/bahia-identity-territories.topo.json'
+import type {
+ BahiaTerritoryFeature,
+ TerritoryGeometryModule,
+ TerritoryTopology,
+} from '@/lib/bahiaGeometriesTypes'
+
+const topology = territoryTopologyJson as unknown as TerritoryTopology
+
+const territoryFeatures = feature(
+ topology,
+ topology.objects.territories,
+).features as BahiaTerritoryFeature[]
+
+const territoryByCode = new Map(
+ territoryFeatures.map((entry) => [entry.properties.code, entry]),
+)
+
+export const territoryGeometryModule: TerritoryGeometryModule = {
+ topology,
+ features: territoryFeatures,
+ getTerritoryFeature: (code: string) => territoryByCode.get(code),
+}
diff --git a/src/lib/choroplethColorScale.ts b/src/lib/choroplethColorScale.ts
new file mode 100644
index 00000000..9c85b37b
--- /dev/null
+++ b/src/lib/choroplethColorScale.ts
@@ -0,0 +1,18 @@
+/** Sequential fill for campaign choropleth maps (primary red scale). */
+export const choroplethFillColor = (value: number, max: number): string => {
+ if (value <= 0 || max <= 0) return '#f4f4f5'
+
+ const ratio = Math.min(1, value / max)
+ // Light muted rose → campaign primary #c51414
+ const start = { r: 254, g: 226, b: 226 }
+ const end = { r: 197, g: 20, b: 20 }
+ 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})`
+}
+
+export const choroplethMaxValue = (values: Record): number => {
+ const entries = Object.values(values).filter((value) => value > 0)
+ return entries.length > 0 ? Math.max(...entries) : 0
+}
diff --git a/src/utilities/campaignDashboardPageData.ts b/src/utilities/campaignDashboardPageData.ts
index 7642660c..6bf6387d 100644
--- a/src/utilities/campaignDashboardPageData.ts
+++ b/src/utilities/campaignDashboardPageData.ts
@@ -3,6 +3,7 @@ import type { Payload } from 'payload'
import { isSupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser } from '@/payload-types'
import { loadUpcomingActionPlansPreview } from '@/utilities/actionPlanUpcomingPreview'
+import { loadNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethPageData'
import {
buildGeneralDashboardViewModel,
buildScopedDashboardViewModel,
@@ -11,6 +12,7 @@ import {
} from '@/utilities/campaignDashboardViewModels'
import { getBahiaWeekRange } from '@/utilities/campaignTime'
import { loadCoordinatorSummaries } from '@/utilities/nucleusCoordinatorOptions'
+import { toNucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
import { nucleusVoteGoalsSelect } from '@/utilities/nucleusViewModels'
import { requireRelationshipId } from '@/utilities/relationship'
import { toVoteGoalsViewModel } from '@/utilities/voteGoals'
@@ -168,6 +170,17 @@ export const getCampaignDashboardPageData = async (
])
const rawNuclei = nucleusResult.docs as unknown as RawDashboardNucleus[]
+ const choroplethPromise =
+ user.role === 'geral'
+ ? loadNucleusChoroplethBundle(
+ payload,
+ user,
+ rawNuclei.map((nucleus) => ({
+ ...toNucleusElectionGeographyInput(nucleus),
+ confirmedVoteEstimate: nucleus.confirmedVoteEstimate ?? null,
+ })),
+ )
+ : undefined
const coordinatorIds = [
...new Set(
rawNuclei.flatMap((nucleus) =>
@@ -207,12 +220,14 @@ export const getCampaignDashboardPageData = async (
)
if (user.role === 'geral') {
+ const choropleth = await choroplethPromise!
return buildGeneralDashboardViewModel(
nuclei,
leaderships,
updateResult.totalDocs,
now,
upcomingActionPlans,
+ choropleth,
)
}
diff --git a/src/utilities/campaignDashboardViewModels.ts b/src/utilities/campaignDashboardViewModels.ts
index a8f5b220..252991ae 100644
--- a/src/utilities/campaignDashboardViewModels.ts
+++ b/src/utilities/campaignDashboardViewModels.ts
@@ -2,6 +2,7 @@ import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { SupportStatus } from '@/lib/schemas/leadership'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
+import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
import { sumVoteGoals, type VoteGoalsViewModel } from '@/utilities/voteGoals'
@@ -62,6 +63,7 @@ export type GeneralDashboardViewModel = {
withoutRecentUpdate: DashboardQueueItem[]
pendingEstimate: DashboardQueueItem[]
}
+ choropleth: NucleusChoroplethBundle
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
}
@@ -178,6 +180,7 @@ export const buildGeneralDashboardViewModel = (
updatesThisWeek: number,
now: Date,
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[],
+ choropleth: NucleusChoroplethBundle,
): GeneralDashboardViewModel => {
const coordinatedCount = nuclei.filter(({ coordinators }) => coordinators.length > 0).length
const confirmedCount = nuclei.filter(
@@ -213,6 +216,7 @@ export const buildGeneralDashboardViewModel = (
)
.map(toQueueItem),
},
+ choropleth,
upcomingActionPlans,
}
}
diff --git a/src/utilities/nucleusChoropleth.ts b/src/utilities/nucleusChoropleth.ts
new file mode 100644
index 00000000..2085fa9e
--- /dev/null
+++ b/src/utilities/nucleusChoropleth.ts
@@ -0,0 +1,165 @@
+import { codeForMunicipality } from '@/lib/bahiaMunicipalityCodes'
+import {
+ bahiaIdentityTerritoryRecords,
+ isBahiaIdentityTerritory,
+ territoryForCity,
+ territoriesForCities,
+} from '@/lib/bahiaTerritories'
+import { resolveNucleusElectionGeography, type NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
+import {
+ emptyNucleusChoroplethBundle,
+ type ChoroplethMetric,
+ type ChoroplethValues,
+ type NucleusChoroplethBundle,
+ type NucleusChoroplethNucleus,
+} from '@/utilities/nucleusChoroplethTypes'
+
+export type {
+ ChoroplethMetric,
+ ChoroplethValues,
+ NucleusChoroplethBundle,
+ NucleusChoroplethNucleus,
+} from '@/utilities/nucleusChoroplethTypes'
+export { choroplethMetricLabels, emptyNucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
+
+const territoryCodeByName = new Map(
+ bahiaIdentityTerritoryRecords.map((record) => [record.name, record.code]),
+)
+
+type ResolvedChoroplethNucleus = {
+ nucleus: NucleusChoroplethNucleus
+ cities: string[]
+ territoryCodes: string[]
+}
+
+const emptyMetricValues = (): Record => ({
+ ...emptyNucleusChoroplethBundle().municipality,
+})
+
+const addToRecord = (record: ChoroplethValues, key: string, amount: number) => {
+ record[key] = (record[key] ?? 0) + amount
+}
+
+export const codareasForCities = (cities: readonly string[]): string[] => [
+ ...new Set(
+ cities.flatMap((city) => {
+ const codarea = codeForMunicipality(city)
+ return codarea ? [codarea] : []
+ }),
+ ),
+]
+
+export const territoryCodesForNucleus = (
+ nucleus: Pick,
+): string[] => {
+ const fromRegions = nucleus.regions.flatMap((region) => {
+ if (!isBahiaIdentityTerritory(region)) return []
+ const code = territoryCodeByName.get(region)
+ return code ? [code] : []
+ })
+ if (fromRegions.length > 0) return [...new Set(fromRegions)]
+
+ return territoriesForCities(nucleus.cities).flatMap((name) => {
+ const code = territoryCodeByName.get(name)
+ return code ? [code] : []
+ })
+}
+
+const effectiveCitiesForNucleus = (nucleus: NucleusElectionGeographyInput): string[] => {
+ const geography = resolveNucleusElectionGeography(nucleus)
+ return geography ? [...geography.zonesByCity.keys()] : []
+}
+
+export const resolveChoroplethNuclei = (
+ nuclei: readonly NucleusChoroplethNucleus[],
+): ResolvedChoroplethNucleus[] =>
+ nuclei.map((nucleus) => ({
+ nucleus,
+ cities: effectiveCitiesForNucleus(nucleus),
+ territoryCodes: territoryCodesForNucleus(nucleus),
+ }))
+
+const uniqueCitiesFromResolved = (resolved: readonly ResolvedChoroplethNucleus[]): string[] => {
+ const cities = new Set()
+ for (const { cities: nucleusCities } of resolved) {
+ for (const city of nucleusCities) {
+ cities.add(city)
+ }
+ }
+ return [...cities]
+}
+
+export const buildNucleusDetailMapHighlight = (
+ nucleus: NucleusElectionGeographyInput,
+): { codareas: string[]; territoryCodes: string[] } => {
+ const cities = effectiveCitiesForNucleus(nucleus)
+ return {
+ codareas: codareasForCities(cities),
+ territoryCodes: territoryCodesForNucleus(nucleus),
+ }
+}
+
+export const choroplethCityNames = (nuclei: readonly NucleusChoroplethNucleus[]): string[] =>
+ uniqueCitiesFromResolved(resolveChoroplethNuclei(nuclei))
+
+export const buildNucleusChoroplethBundleFromResolved = (
+ resolved: readonly ResolvedChoroplethNucleus[],
+ baselineVotesByCity: ReadonlyMap = new Map(),
+): NucleusChoroplethBundle => {
+ const municipality = emptyMetricValues()
+ const territory = emptyMetricValues()
+
+ for (const { nucleus, cities, territoryCodes } of resolved) {
+ if (cities.length > 0) {
+ const cityShare =
+ nucleus.confirmedVoteEstimate != null ? nucleus.confirmedVoteEstimate / cities.length : 0
+
+ for (const city of cities) {
+ const codarea = codeForMunicipality(city)
+ if (!codarea) continue
+ addToRecord(municipality.nucleusCount, codarea, 1)
+ if (nucleus.confirmedVoteEstimate != null) {
+ addToRecord(municipality.confirmedEstimate, codarea, cityShare)
+ }
+ }
+ }
+
+ if (territoryCodes.length > 0) {
+ const territoryShare =
+ nucleus.confirmedVoteEstimate != null
+ ? nucleus.confirmedVoteEstimate / territoryCodes.length
+ : 0
+
+ for (const code of territoryCodes) {
+ addToRecord(territory.nucleusCount, code, 1)
+ if (nucleus.confirmedVoteEstimate != null) {
+ addToRecord(territory.confirmedEstimate, code, territoryShare)
+ }
+ }
+ }
+ }
+
+ for (const city of uniqueCitiesFromResolved(resolved)) {
+ const baselineVotes = baselineVotesByCity.get(city) ?? 0
+ if (baselineVotes <= 0) continue
+
+ const codarea = codeForMunicipality(city)
+ if (codarea) {
+ addToRecord(municipality.baseline2022Votes, codarea, baselineVotes)
+ }
+
+ const territoryName = territoryForCity(city)
+ const territoryCode = territoryName ? territoryCodeByName.get(territoryName) : undefined
+ if (territoryCode) {
+ addToRecord(territory.baseline2022Votes, territoryCode, baselineVotes)
+ }
+ }
+
+ return { municipality, territory }
+}
+
+export const buildNucleusChoroplethBundle = (
+ nuclei: readonly NucleusChoroplethNucleus[],
+ baselineVotesByCity: ReadonlyMap = new Map(),
+): NucleusChoroplethBundle =>
+ buildNucleusChoroplethBundleFromResolved(resolveChoroplethNuclei(nuclei), baselineVotesByCity)
diff --git a/src/utilities/nucleusChoroplethPageData.ts b/src/utilities/nucleusChoroplethPageData.ts
new file mode 100644
index 00000000..d9d35e6a
--- /dev/null
+++ b/src/utilities/nucleusChoroplethPageData.ts
@@ -0,0 +1,29 @@
+import type { Payload } from 'payload'
+
+import type { CampaignUser, User } from '@/payload-types'
+import { loadBaseline2022VotesByCityNames } from '@/utilities/nucleusElectoralBaseline'
+import {
+ buildNucleusChoroplethBundleFromResolved,
+ resolveChoroplethNuclei,
+ type NucleusChoroplethBundle,
+ type NucleusChoroplethNucleus,
+} from '@/utilities/nucleusChoropleth'
+
+type ChoroplethReader = CampaignUser | User
+
+export const loadNucleusChoroplethBundle = async (
+ payload: Pick,
+ user: ChoroplethReader,
+ nuclei: readonly NucleusChoroplethNucleus[],
+): Promise => {
+ const resolved = resolveChoroplethNuclei(nuclei)
+ const cityNames = [
+ ...new Set(resolved.flatMap(({ cities }) => cities)),
+ ]
+ const baselineVotesByCity =
+ cityNames.length > 0
+ ? await loadBaseline2022VotesByCityNames(payload, user, cityNames)
+ : new Map()
+
+ return buildNucleusChoroplethBundleFromResolved(resolved, baselineVotesByCity)
+}
diff --git a/src/utilities/nucleusChoroplethTypes.ts b/src/utilities/nucleusChoroplethTypes.ts
new file mode 100644
index 00000000..f30f23a0
--- /dev/null
+++ b/src/utilities/nucleusChoroplethTypes.ts
@@ -0,0 +1,33 @@
+import type { NucleusElectionGeographyInput } from '@/utilities/nucleusElectoralBaseline'
+
+export type ChoroplethMetric = 'nucleusCount' | 'confirmedEstimate' | 'baseline2022Votes'
+
+export type ChoroplethValues = Record
+
+export type NucleusChoroplethNucleus = NucleusElectionGeographyInput & {
+ confirmedVoteEstimate: number | null
+}
+
+export type NucleusChoroplethBundle = {
+ municipality: Record
+ territory: Record
+}
+
+export const choroplethMetricLabels: Record = {
+ nucleusCount: 'Núcleos',
+ confirmedEstimate: 'Estimativa confirmada',
+ baseline2022Votes: 'Votos Solla 2022',
+}
+
+export const emptyNucleusChoroplethBundle = (): NucleusChoroplethBundle => ({
+ municipality: {
+ nucleusCount: {},
+ confirmedEstimate: {},
+ baseline2022Votes: {},
+ },
+ territory: {
+ nucleusCount: {},
+ confirmedEstimate: {},
+ baseline2022Votes: {},
+ },
+})
diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts
index 0073cd0c..bdfe714a 100644
--- a/src/utilities/nucleusElectoralBaseline.ts
+++ b/src/utilities/nucleusElectoralBaseline.ts
@@ -678,3 +678,34 @@ export const loadNucleusListElectionOverview = async (
conversion,
}
}
+
+/** Solla 2022 federal T1 votes indexed by canonical city name (choropleth B3). */
+export const loadBaseline2022VotesByCityNames = async (
+ payload: Pick,
+ user: ElectionDataReader,
+ cityNames: readonly string[],
+): Promise