Scope client state to where it is used

- MunicipalityMapSelection leaf owns hover/selection: a mouseover re-renders
  only the map + readout, not the whole panel (controls, legend, zone list);
  Leaflet paint stays ref-based (B6)
- municípios list: MunicipalityEstimateScenarioProvider now wraps only the
  results — scenario switches no longer re-render the filter controls
  (dashboard already scoped it to the map)
- LeaderContactsPanel is a server component again: header + contacts table
  render on the server; only LeaderContactForm (new client file) hydrates
- ledgered with rationale: map-bundle slimming deferred (gzip crushes the
  repeating keys; instant year/scenario switching is a core analysis flow);
  props-to-state effect mirrors reviewed and accepted as the correct
  server-echo reconciliation pattern
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-23 23:26:28 -03:00
parent 05a47b881c
commit 590d311364
5 changed files with 361 additions and 300 deletions

View file

@ -18,6 +18,8 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | planned (Phase 2) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~15-25KB on the wire; instant year/scenario switching is a core analysis flow worth the bytes | M | P3 | deferred 2026-07-23 with rationale (revisit only if RSC payload profiling says otherwise) | |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
## Smell Inventory

View file

@ -133,19 +133,21 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
)
// Shared transition: the filters navigate, the results dim ("Feel the action").
const filteredMain = (
// The estimate-scenario provider wraps ONLY the results — a scenario switch
// re-renders the overview/list cells, never the filter controls.
const main = (
<CampaignListPendingBoundary>
{filters}
<CampaignListResults>{listBody}</CampaignListResults>
<CampaignListResults>
{isStaffView ? (
<MunicipalityEstimateScenarioProvider>{listBody}</MunicipalityEstimateScenarioProvider>
) : (
listBody
)}
</CampaignListResults>
</CampaignListPendingBoundary>
)
const main = isStaffView ? (
<MunicipalityEstimateScenarioProvider>{filteredMain}</MunicipalityEstimateScenarioProvider>
) : (
filteredMain
)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">

View file

@ -0,0 +1,232 @@
'use client'
import { UserPlusIcon } from 'lucide-react'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
createLeaderSupporterFormAction,
type LeaderSupporterFormState,
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) =>
buildErrorProps(fieldErrors, field, 'leader-contact')
export type LeaderContactFormProps = {
municipalityOptions: RelationOption[]
defaultMunicipalityId: number | null
showMunicipalitySelect: boolean
registrationConsentConfigured: boolean
}
export const LeaderContactForm = ({
municipalityOptions,
defaultMunicipalityId,
showMunicipalitySelect,
registrationConsentConfigured,
}: LeaderContactFormProps) => {
const [state, formAction, pending] = useActionState<LeaderSupporterFormState, FormData>(
createLeaderSupporterFormAction,
{},
)
const [city, setCity] = useState<string>((state.values?.city as string | undefined) ?? '')
const values = state.values
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
}, [state.message, state.status])
const phoneField = errorProps(state.fieldErrors, 'phone')
const nameField = errorProps(state.fieldErrors, 'name')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
const consentError = fieldError(state.fieldErrors, 'consentAccepted')
return (
<section
aria-labelledby="leader-contact-form-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<UserPlusIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<h2 id="leader-contact-form-title" className="text-base font-medium">
Novo contato
</h2>
</div>
<form
key={state.status === 'success' ? `success-${state.revision ?? 0}` : 'draft'}
action={formAction}
className="flex flex-col gap-4"
>
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível cadastrar</AlertTitle>
<AlertDescription>
{state.message ? <p>{state.message}</p> : null}
{state.fieldErrors?.form?.map((error) => (
<p key={error}>{error}</p>
))}
</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
<FormattedInput
id="leader-contact-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
autoFocus
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
<Input
id="leader-contact-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
aria-invalid={nameField.invalid}
aria-describedby={nameField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{nameField.error ? (
<FieldError id="leader-contact-name-error">{nameField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="leader-contact-city">Município</FieldLabel>
<input type="hidden" name="city" value={city} />
<StrictCombobox
id="leader-contact-city"
options={municipalityComboboxOptions()}
value={city}
onValueChange={setCity}
error={cityField.error}
/>
{cityField.error ? (
<FieldError id="leader-contact-city-error">{cityField.error}</FieldError>
) : null}
</Field>
{showMunicipalitySelect ? (
<Field data-invalid={municipalityField.invalid}>
<FieldLabel htmlFor="leader-contact-municipality">Município *</FieldLabel>
<NativeSelect
id="leader-contact-municipality"
name="municipality"
required
defaultValue={values?.municipality ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
{municipalityOptions.map((municipality) => (
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
{municipality.name}
</NativeSelectOption>
))}
</NativeSelect>
{municipalityField.error ? (
<FieldError id="leader-contact-municipality-error">
{municipalityField.error}
</FieldError>
) : null}
</Field>
) : defaultMunicipalityId ? (
<input type="hidden" name="municipality" value={String(defaultMunicipalityId)} />
) : null}
</FieldGroup>
<section
className="rounded-[6px] border bg-muted/30 p-4"
aria-labelledby="leader-contact-consent-title"
>
<h3 id="leader-contact-consent-title" className="font-medium">
Consentimento de cadastro
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador.
</p>
{!registrationConsentConfigured ? (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
</AlertDescription>
</Alert>
) : (
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(consentError)}>
<Checkbox
id="leader-contact-consent"
name="consentAccepted"
value="true"
required
aria-invalid={Boolean(consentError)}
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
/>
<FieldContent>
<FieldLabel htmlFor="leader-contact-consent">
A pessoa autorizou o cadastro *
</FieldLabel>
<FieldDescription>
Você confirma que explicou o uso dos dados conforme o texto versionado da
campanha.
</FieldDescription>
{consentError ? (
<FieldError id="leader-contact-consent-error">{consentError}</FieldError>
) : null}
</FieldContent>
</Field>
)}
</section>
<Button
type="submit"
className="min-h-11 w-full sm:w-auto sm:self-end"
disabled={pending || !registrationConsentConfigured}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cadastrando…' : 'Cadastrar contato'}
</Button>
</form>
</section>
)
}

View file

@ -1,31 +1,8 @@
'use client'
import { PhoneIcon } from 'lucide-react'
import { PhoneIcon, UserPlusIcon } from 'lucide-react'
import { useActionState, useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
createLeaderSupporterFormAction,
type LeaderSupporterFormState,
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { LeaderContactForm } from '@/components/campaign/LeaderContactForm'
import type { RelationOption } from '@/components/campaign/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import {
Table,
TableBody,
@ -34,19 +11,14 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import type { LeaderContactListItem } from '@/utilities/leaderContactsPageData'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
import { municipalityComboboxOptions } from '@/utilities/territoryComboboxOptions'
import { formatBrazilianPhoneInput } from '@/utilities/phone'
const dateFormatter = new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'short',
})
const errorProps = (fieldErrors: Record<string, string[]> | undefined, field: string) =>
buildErrorProps(fieldErrors, field, 'leader-contact')
type LeaderContactsPanelProps = {
userName: string
municipalityOptions: RelationOption[]
@ -56,204 +28,6 @@ type LeaderContactsPanelProps = {
contacts: LeaderContactListItem[]
}
const LeaderContactForm = ({
municipalityOptions,
defaultMunicipalityId,
showMunicipalitySelect,
registrationConsentConfigured,
}: Pick<
LeaderContactsPanelProps,
| 'municipalityOptions'
| 'defaultMunicipalityId'
| 'showMunicipalitySelect'
| 'registrationConsentConfigured'
>) => {
const [state, formAction, pending] = useActionState<LeaderSupporterFormState, FormData>(
createLeaderSupporterFormAction,
{},
)
const [city, setCity] = useState<string>((state.values?.city as string | undefined) ?? '')
const values = state.values
useEffect(() => {
if (state.status !== 'success') return
toast.success(state.message)
}, [state.message, state.status])
const phoneField = errorProps(state.fieldErrors, 'phone')
const nameField = errorProps(state.fieldErrors, 'name')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
const consentError = fieldError(state.fieldErrors, 'consentAccepted')
return (
<section
aria-labelledby="leader-contact-form-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex items-center gap-2">
<UserPlusIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<h2 id="leader-contact-form-title" className="text-base font-medium">
Novo contato
</h2>
</div>
<form
key={state.status === 'success' ? `success-${state.revision ?? 0}` : 'draft'}
action={formAction}
className="flex flex-col gap-4"
>
{(state.message || state.fieldErrors?.form) && state.status !== 'success' ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Não foi possível cadastrar</AlertTitle>
<AlertDescription>
{state.message ? <p>{state.message}</p> : null}
{state.fieldErrors?.form?.map((error) => (
<p key={error}>{error}</p>
))}
</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
<FormattedInput
id="leader-contact-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
autoFocus
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
<Input
id="leader-contact-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
aria-invalid={nameField.invalid}
aria-describedby={nameField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{nameField.error ? (
<FieldError id="leader-contact-name-error">{nameField.error}</FieldError>
) : null}
</Field>
<Field data-invalid={cityField.invalid}>
<FieldLabel htmlFor="leader-contact-city">Município</FieldLabel>
<input type="hidden" name="city" value={city} />
<StrictCombobox
id="leader-contact-city"
options={municipalityComboboxOptions()}
value={city}
onValueChange={setCity}
error={cityField.error}
/>
{cityField.error ? (
<FieldError id="leader-contact-city-error">{cityField.error}</FieldError>
) : null}
</Field>
{showMunicipalitySelect ? (
<Field data-invalid={municipalityField.invalid}>
<FieldLabel htmlFor="leader-contact-municipality">Município *</FieldLabel>
<NativeSelect
id="leader-contact-municipality"
name="municipality"
required
defaultValue={values?.municipality ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
<NativeSelectOption value="">Selecione a Praça</NativeSelectOption>
{municipalityOptions.map((municipality) => (
<NativeSelectOption key={municipality.id} value={String(municipality.id)}>
{municipality.name}
</NativeSelectOption>
))}
</NativeSelect>
{municipalityField.error ? (
<FieldError id="leader-contact-municipality-error">
{municipalityField.error}
</FieldError>
) : null}
</Field>
) : defaultMunicipalityId ? (
<input type="hidden" name="municipality" value={String(defaultMunicipalityId)} />
) : null}
</FieldGroup>
<section
className="rounded-[6px] border bg-muted/30 p-4"
aria-labelledby="leader-contact-consent-title"
>
<h3 id="leader-contact-consent-title" className="font-medium">
Consentimento de cadastro
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Confirme que a pessoa autorizou o uso dos dados para o cadastro como apoiador.
</p>
{!registrationConsentConfigured ? (
<Alert variant="destructive" className="mt-3">
<AlertTitle>Consentimento não configurado</AlertTitle>
<AlertDescription>
O texto ainda não foi cadastrado no admin. Não é possível concluir o cadastro.
</AlertDescription>
</Alert>
) : (
<Field orientation="horizontal" className="mt-3" data-invalid={Boolean(consentError)}>
<Checkbox
id="leader-contact-consent"
name="consentAccepted"
value="true"
required
aria-invalid={Boolean(consentError)}
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
/>
<FieldContent>
<FieldLabel htmlFor="leader-contact-consent">
A pessoa autorizou o cadastro *
</FieldLabel>
<FieldDescription>
Você confirma que explicou o uso dos dados conforme o texto versionado da
campanha.
</FieldDescription>
{consentError ? (
<FieldError id="leader-contact-consent-error">{consentError}</FieldError>
) : null}
</FieldContent>
</Field>
)}
</section>
<Button
type="submit"
className="min-h-11 w-full sm:w-auto sm:self-end"
disabled={pending || !registrationConsentConfigured}
>
{pending ? <Spinner data-icon="inline-start" /> : null}
{pending ? 'Cadastrando…' : 'Cadastrar contato'}
</Button>
</form>
</section>
)
}
const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] }) => (
<section
aria-labelledby="leader-contacts-list-title"
@ -312,6 +86,11 @@ const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] })
</section>
)
/**
* Leader home (lockdown role). Server component: the header and the contacts
* table render on the server; only the registration form hydrates
* (`LeaderContactForm` is the client island).
*/
export const LeaderContactsPanel = ({
userName,
municipalityOptions,

View file

@ -56,8 +56,6 @@ export const MunicipalityMapPanel = ({
)
const estimateScenario = scenarioContext?.scenario ?? localEstimateScenario
const setEstimateScenario = scenarioContext?.setScenario ?? setLocalEstimateScenario
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
const selectedKeyRef = useRef<string | null>(null)
const comparison = bundle.comparison
const comparisonActive = comparison !== null && year !== 2026
@ -132,47 +130,6 @@ export const MunicipalityMapPanel = ({
})
}
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, bundle.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',
})
}
},
[bundle.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, bundle.municipalitiesByIbgeCode)
: null,
[bundle.municipalitiesByIbgeCode, selectedFeature],
)
return (
<section
aria-labelledby="municipality-map-title"
@ -301,16 +258,15 @@ export const MunicipalityMapPanel = ({
</p>
) : null}
<BahiaMap
mode="municipality"
values={displayValues}
scaleMax={displayMax > 0 ? displayMax : undefined}
fillMode={comparisonActive ? 'diverging' : 'sequential'}
fitToKeys={scopedKeys}
interactiveKeys={scopedKeys}
selectedKey={selectedFeature?.key ?? null}
onFeatureSelect={handleFeatureSelect}
onFeatureActivate={handleFeatureActivate}
<MunicipalityMapSelection
displayValues={displayValues}
rawValues={rawValues}
displayMax={displayMax}
scopedKeys={scopedKeys}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
comparisonActive={comparisonActive}
municipalitiesByIbgeCode={bundle.municipalitiesByIbgeCode}
ariaLabel={
comparisonActive && comparison
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
@ -318,16 +274,7 @@ export const MunicipalityMapPanel = ({
}
/>
<MapFeatureReadout
feature={selectedFeature}
metricValue={selectedMetricValue}
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>
{/* zone breakdown below */}
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
@ -363,3 +310,102 @@ export const MunicipalityMapPanel = ({
</section>
)
}
/**
* 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,
scopedKeys,
metricLabel,
scaleMode,
comparisonActive,
municipalitiesByIbgeCode,
ariaLabel,
}: {
displayValues: Record<string, number>
rawValues: Record<string, number>
displayMax: number
scopedKeys: string[]
metricLabel: string
scaleMode: MunicipalityMapScaleMode
comparisonActive: boolean
municipalitiesByIbgeCode: MunicipalityMapBundle['municipalitiesByIbgeCode']
ariaLabel: string
}) => {
const router = useRouter()
const [selectedFeature, setSelectedFeature] = useState<BahiaMapFeatureInfo | null>(null)
const selectedKeyRef = useRef<string | null>(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 (
<>
<BahiaMap
mode="municipality"
values={displayValues}
scaleMax={displayMax > 0 ? displayMax : undefined}
fillMode={comparisonActive ? 'diverging' : 'sequential'}
fitToKeys={scopedKeys}
interactiveKeys={scopedKeys}
selectedKey={selectedFeature?.key ?? null}
onFeatureSelect={handleFeatureSelect}
onFeatureActivate={handleFeatureActivate}
ariaLabel={ariaLabel}
/>
<MapFeatureReadout
feature={selectedFeature}
metricValue={selectedMetricValue}
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={scaleMode}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>
</>
)
}