perfect-postcode/frontend/src/components/map/AreaPane.tsx
Andras Schmelczer f59d01227b
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 15s
CI / Check (push) Failing after 1m58s
SPlit up
2026-06-12 21:51:37 +01:00

850 lines
37 KiB
TypeScript

import { useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type {
FeatureFilters,
FeatureGroup,
FeatureMeta,
FilterExclusion,
HexagonStatsResponse,
} from '../../types';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations';
import {
formatValue,
formatFilterValue,
calculateHistogramMean,
roundedPercentages,
} from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
import {
getActiveAmenityFilterFeatureNames,
isPoiFilterFeatureName,
} from '../../lib/poi-distance-filter';
import {
PARTY_FEATURE_COLORS,
STACKED_GROUPS,
STACKED_ENUM_GROUPS,
STACKED_SEGMENT_COLORS,
} from '../../lib/consts';
import { useNearbyStations } from '../../hooks/useNearbyStations';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
import EnumBarChart from './EnumBarChart';
import StackedBarChart from './StackedBarChart';
import StackedEnumChart from './StackedEnumChart';
import PriceHistoryChart from './PriceHistoryChart';
import CrimeYearChart from './CrimeYearChart';
import ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon, TransitIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
import { FeatureLabel } from '../ui/FeatureLabel';
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
import StreetViewEmbed from './StreetViewEmbed';
import JourneyInstructions from './JourneyInstructions';
interface AreaPaneProps {
stats: HexagonStatsResponse | null;
globalFeatures: FeatureMeta[];
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
statsUseFilters: boolean;
onStatsUseFiltersChange: (useFilters: boolean) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
shareCode?: string;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
scrollTopRef?: MutableRefObject<number>;
scrollRestoreKey?: string | null;
scrollSaveDisabled?: boolean;
}
function normalizePercentageSegments<T extends { value: number }>(segments: T[]): T[] {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const normalizedValues = roundedPercentages(
segments.map((segment) => segment.value),
total,
1
);
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
}
function filterValueFormat(feature?: FeatureMeta) {
if (!feature) return undefined;
return {
prefix: feature.prefix,
suffix: feature.suffix,
raw: feature.raw,
};
}
const STATION_GROUP_NAME = 'Transport';
const STATION_GROUP_NAMES = new Set([STATION_GROUP_NAME, 'Public Transport']);
function MetricTextLabel({ children }: { children: ReactNode }) {
return (
<span className="block min-w-0 flex-1 break-words text-[13px] font-medium leading-snug text-warm-900 dark:text-warm-100">
{children}
</span>
);
}
function MetricFeatureLabel({
feature,
onShowInfo,
label,
aboutLabel,
}: {
feature: FeatureMeta;
onShowInfo: (feature: FeatureMeta) => void;
label?: string;
aboutLabel: string;
}) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<MetricTextLabel>{label ?? ts(feature.name)}</MetricTextLabel>
{feature.detail && (
<button
type="button"
onClick={() => onShowInfo(feature)}
className="-m-1 shrink-0 rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:hover:bg-navy-800 dark:hover:text-warm-200"
title={aboutLabel}
aria-label={aboutLabel}
>
<InfoIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
function MetricRow({
label,
chart,
value,
valueTitle,
className = '',
}: {
label: ReactNode;
chart?: ReactNode;
value?: ReactNode;
valueTitle?: string;
className?: string;
}) {
return (
<div
className={`grid min-h-10 grid-cols-[minmax(0,1fr)_6.5rem_minmax(3.5rem,auto)] items-center gap-3 py-1.5 ${className}`}
>
<div className="min-w-0">{label}</div>
<div className="w-[6.5rem] justify-self-end">{chart}</div>
<div
className="min-w-[3.5rem] max-w-[7rem] truncate text-right text-sm font-semibold leading-tight tabular-nums text-navy-950 dark:text-warm-50"
title={valueTitle}
>
{value}
</div>
</div>
);
}
function NearbyStationsCard({ location }: { location: HexagonLocation }) {
const { t } = useTranslation();
const origin = useMemo(
() => ({ lat: location.lat, lon: location.lon }),
[location.lat, location.lon]
);
const { stations, loading } = useNearbyStations(origin);
return (
<div className="py-1.5">
<div className="flex items-center gap-2 py-1">
<TransitIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<MetricTextLabel>{t('areaPane.closestStations')}</MetricTextLabel>
{loading && (
<span className="ml-auto h-3 w-3 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
)}
</div>
{stations.length > 0 ? (
<ol className="divide-y divide-warm-100 dark:divide-navy-800">
{stations.map((station) => (
<NearbyStationRow key={station.id} station={station} />
))}
</ol>
) : (
<div className="py-2 text-sm text-warm-500 dark:text-warm-400">
{loading ? t('common.loading') : t('areaPane.noNearbyStations')}
</div>
)}
</div>
);
}
function NearbyStationRow({ station }: { station: NearbyStation }) {
const icon = getPoiCategoryLogoUrl(station.category, station.icon_category);
return (
<li className="flex items-center gap-2 px-3 py-2">
{icon ? (
<img
src={icon}
alt=""
aria-hidden="true"
loading="lazy"
className="h-5 w-5 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
/>
) : (
<TransitIcon className="h-5 w-5 shrink-0 text-warm-400 dark:text-warm-500" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-warm-900 dark:text-warm-100">
{station.name}
</div>
<div className="text-xs text-warm-500 dark:text-warm-400">{ts(station.category)}</div>
</div>
<span className="shrink-0 text-sm font-semibold tabular-nums text-teal-700 dark:text-teal-400">
{formatStationDistance(station.distanceKm)}
</span>
</li>
);
}
export default function AreaPane({
stats,
globalFeatures,
loading,
hexagonId,
isPostcode = false,
hexagonLocation,
filters,
unfilteredCount,
statsUseFilters,
onStatsUseFiltersChange,
onNavigateToSource,
travelTimeEntries,
shareCode,
isGroupExpanded,
onToggleGroup,
scrollTopRef,
scrollRestoreKey,
scrollSaveDisabled,
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = stats?.count;
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
const activeAmenityFeatureNames = useMemo(
() => getActiveAmenityFilterFeatureNames(filters),
[filters]
);
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const paneFeatureGroups = useMemo<FeatureGroup[]>(
() =>
featureGroups
.map((group) => {
if (group.name !== 'Amenities') return group;
const features = group.features.filter((feature) => {
if (isPoiFilterFeatureName(feature.name)) {
return activeAmenityFeatureNames.has(feature.name);
}
return activeFilterNames.has(feature.name);
});
return { ...group, features };
})
.filter((group) => group.name !== 'Amenities' || group.features.length > 0),
[activeAmenityFeatureNames, activeFilterNames, featureGroups]
);
const displayFeatureGroups = useMemo<FeatureGroup[]>(() => {
if (
!hexagonLocation ||
paneFeatureGroups.some((group) => STATION_GROUP_NAMES.has(group.name))
) {
return paneFeatureGroups;
}
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
}, [paneFeatureGroups, hexagonLocation]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey: scrollRestoreKey ?? hexagonId,
scrollTopRef,
suspendSave: scrollSaveDisabled ?? (loading && stats == null),
});
const numericByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
}, [stats]);
const enumByName = useMemo(() => {
if (!stats) return new Map();
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
}, [stats]);
// Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary").
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
// metric-row renderer can look it up using the feature name it already has.
const crimeByYearByFeatureName = useMemo(() => {
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
for (const entry of stats?.crime_by_year ?? []) {
map.set(entry.name, entry);
map.set(`${entry.name} (avg/yr)`, entry);
}
return map;
}, [stats]);
const globalFeatureByName = useMemo(
() => new Map(globalFeatures.map((f) => [f.name, f])),
[globalFeatures]
);
const travelEntryByField = useMemo(() => {
const map = new Map<string, TravelTimeEntry>();
for (const entry of travelTimeEntries ?? []) {
map.set(travelFieldKey(entry), entry);
}
return map;
}, [travelTimeEntries]);
const filterExclusions = stats?.filter_exclusions ?? [];
const getExclusionLabel = (exclusion: FilterExclusion) => {
const travelEntry = travelEntryByField.get(exclusion.name);
if (travelEntry) return t('areaPane.travelTo', { destination: travelEntry.label });
return ts(exclusion.name);
};
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.minute')}`;
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
};
const getExclusionAdjustment = (exclusion: FilterExclusion) => {
if (exclusion.direction === 'missing_value') {
return t('areaPane.missingFilterValue');
}
if (exclusion.direction === 'allow_value') {
return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') });
}
if (exclusion.value == null) return '';
const value = formatExclusionValue(exclusion, exclusion.value);
return exclusion.direction === 'lower_min'
? t('areaPane.lowerMinTo', { value })
: t('areaPane.raiseMaxTo', { value });
};
if (!hexagonId) {
return (
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={t('common.noAreaSelected')}
description={t('common.noAreaSelectedDesc')}
centered
/>
);
}
return (
<>
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
</h2>
{loading && (
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
)}
</div>
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('common.propertiesPlural')}
</div>
</div>
</div>
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
{t('areaPane.statsBasis')}
</span>
<div className="grid min-w-0 flex-1 basis-52 grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
disabled={!filtersActive}
aria-pressed={statsUseFilters && filtersActive}
onClick={() => onStatsUseFiltersChange(true)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
statsUseFilters && filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters || !filtersActive}
onClick={() => onStatsUseFiltersChange(false)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
!statsUseFilters || !filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.allPropertiesOption')}
</button>
</div>
</div>
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{filtersActive
? statsUseFilters
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.filtersIgnoredForStats')
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{showFlipToggleCallout && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
<p className="mt-1">
{unfilteredCount != null
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
: t('areaPane.showAllStatsFallback')}
</p>
<button
type="button"
onClick={() => onStatsUseFiltersChange(false)}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('areaPane.showAllStats')}
</button>
{filterExclusions.length > 0 && (
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
<ol className="mt-1.5 space-y-1.5">
{filterExclusions.map((exclusion) => (
<li
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
>
<div className="break-words font-medium leading-snug">
{getExclusionLabel(exclusion)}
</div>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
{getExclusionAdjustment(exclusion)}
</p>
</li>
))}
</ol>
</div>
)}
</div>
)}
</div>
</div>
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{(() => {
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
<JourneyInstructions
postcode={journeyPostcode}
entries={travelTimeEntries}
label={!isPostcode ? journeyPostcode : undefined}
shareCode={shareCode}
/>
) : null;
})()}
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
return uniqueYears.size > 1 ? (
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
<PriceHistoryChart points={stats.price_history} />
</div>
) : null;
})()}
{displayFeatureGroups.map((group) => {
const showNearbyStations =
hexagonLocation != null && STATION_GROUP_NAMES.has(group.name);
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
const expanded = isGroupExpanded(group.name);
if (!hasData && !showNearbyStations && stats.count === 0) return null;
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
const stackedEnumFeatureNames = new Set<string>(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
) ?? []
);
return (
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
/>
{expanded && (
<div className="divide-y divide-warm-100 px-3 py-1 dark:divide-navy-800">
{showNearbyStations && <NearbyStationsCard location={hexagonLocation} />}
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const isPercentageComposition = chart.unit === '%' && !chart.feature;
const displaySegments = isPercentageComposition
? normalizePercentageSegments(segments)
: segments;
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = isPercentageComposition
? 100
: rateStats
? rateStats.mean
: total;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
if (total === 0) return null;
const crimeSeries = chart.feature
? crimeByYearByFeatureName.get(chart.feature)
: undefined;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
wrap
/>
) : (
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
{ts(chart.label)}
</span>
)}
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
</div>
</div>
<StackedBarChart
segments={displaySegments}
total={total}
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: STACKED_SEGMENT_COLORS
}
/>
{crimeSeries && crimeSeries.points.length > 1 && (
<div className="mt-2">
<CrimeYearChart
points={crimeSeries.points}
latestAvailableYear={stats?.crime_latest_year}
/>
</div>
)}
</div>
);
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
Boolean(s)
)
) ?? []
);
return group.features
.filter(
(f) =>
!stackedFeatureNames.has(f.name) &&
!stackedEnumFeatureNames.has(f.name)
)
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
return (
<MetricRow
key={feature.name}
label={
<MetricFeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
aboutLabel={t('filters.aboutData')}
/>
}
chart={
crimeSeries && crimeSeries.points.length > 1 ? (
<CrimeYearChart
points={crimeSeries.points}
latestAvailableYear={stats?.crime_latest_year}
/>
) : (
numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
meanLabel={t('areaPane.nationalAvg')}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
integerAxisLabels={feature.step === 1}
compact
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
integerAxisLabels={feature.step === 1}
compact
/>
))
)
}
value={formatValue(numericStats.mean, feature)}
valueTitle={
globalMean != null
? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
: undefined
}
/>
);
}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
wrap
/>
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
wrap
/>
) : (
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
{ts(chart.label)}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div>
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
</div>
);
}
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
if (components.length === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
wrap
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{ts(chart.label)}
</span>
)}
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
) : null}
</div>
</div>
{infoFeature && (
<FeatureInfoPopup
feature={infoFeature}
onClose={() => setInfoFeature(null)}
onNavigateToSource={onNavigateToSource}
/>
)}
</>
);
}