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; scrollRestoreKey?: string | null; scrollSaveDisabled?: boolean; } function normalizePercentageSegments(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 ( {children} ); } function MetricFeatureLabel({ feature, onShowInfo, label, aboutLabel, }: { feature: FeatureMeta; onShowInfo: (feature: FeatureMeta) => void; label?: string; aboutLabel: string; }) { return (
{label ?? ts(feature.name)} {feature.detail && ( )}
); } function MetricRow({ label, chart, value, valueTitle, className = '', }: { label: ReactNode; chart?: ReactNode; value?: ReactNode; valueTitle?: string; className?: string; }) { return (
{label}
{chart}
{value}
); } 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 (
{t('areaPane.closestStations')} {loading && ( )}
{stations.length > 0 ? (
    {stations.map((station) => ( ))}
) : (
{loading ? t('common.loading') : t('areaPane.noNearbyStations')}
)}
); } function NearbyStationRow({ station }: { station: NearbyStation }) { const icon = getPoiCategoryLogoUrl(station.category, station.icon_category); return (
  • {icon ? ( ) : ( )}
    {station.name}
    {ts(station.category)}
    {formatStationDistance(station.distanceKm)}
  • ); } 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( () => 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(() => { 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(null); const { scrollRef, onScroll } = useRetainedScrollTop({ 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[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(); 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 ( } title={t('common.noAreaSelected')} description={t('common.noAreaSelectedDesc')} centered /> ); } return ( <>

    {isPostcode ? hexagonId : t('areaPane.areaOverview')}

    {loading && ( )}

    {t('areaPane.statsFor', { type: isPostcode ? t('common.postcode').toLowerCase() : t('common.area').toLowerCase(), })}

    {propertyCount == null ? '...' : propertyCount.toLocaleString()}
    {t('common.propertiesPlural')}
    {t('areaPane.statsBasis')}

    {filtersActive ? statsUseFilters ? t('areaPane.filtersAffectStats', { count: activeFilterCount }) : t('areaPane.filtersIgnoredForStats') : t('areaPane.noFiltersAffectStats')}

    {showFlipToggleCallout && (

    {t('areaPane.filteredStatsEmpty')}

    {unfilteredCount != null ? t('areaPane.showAllStatsHint', { count: unfilteredCount }) : t('areaPane.showAllStatsFallback')}

    {filterExclusions.length > 0 && (

    {t('areaPane.closestBlockingFilters')}

      {filterExclusions.map((exclusion) => (
    1. {getExclusionLabel(exclusion)}

      {getExclusionAdjustment(exclusion)}

    2. ))}
    )}
    )}
    {hexagonLocation && stats && ( )} {(() => { const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode; return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? ( ) : null; })()} {loading && !stats ? ( ) : stats ? (
    {hexagonLocation && } {stats.price_history && (() => { const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year))); return uniqueYears.size > 1 ? (
    {t('areaPane.priceHistory')}
    ) : 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( stackedEnumCharts?.flatMap((c) => [c.feature, ...c.components].filter((s): s is string => Boolean(s)) ) ?? [] ); return (
    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 && (
    {showNearbyStations && } {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 (
    {featureMeta ? ( ) : ( {ts(chart.label)} )}
    {formatValue(displayValue)} {chart.unit ? ` ${chart.unit}` : ''} {globalMean != null && (
    {t('areaPane.nationalAvg')}: {formatValue(globalMean)}
    )}
    {crimeSeries && crimeSeries.points.length > 1 && (
    )}
    ); })} {(() => { const stackedFeatureNames = new Set( 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 ( } chart={ crimeSeries && crimeSeries.points.length > 1 ? ( ) : ( numericStats.histogram && (globalHistogram ? ( formatFilterValue( v, feature.suffix === '%' ? { raw: feature.raw, suffix: feature.suffix } : feature.raw ) } integerAxisLabels={feature.step === 1} compact /> ) : ( 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 (
    ); } 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 (
    {featureMeta ? ( ) : ( {ts(chart.label)} )} {total.toLocaleString()}
    [v, chart.valueColors[i]]) )} />
    ); } const components = chart.components .map((name) => { const stats = enumByName.get(name); return stats ? { label: name, stats } : null; }) .filter((c): c is NonNullable => c !== null); if (components.length === 0) return null; return (
    {featureMeta ? ( ) : ( {ts(chart.label)} )}
    ); })}
    )}
    ); })}
    ) : null}
    {infoFeature && ( setInfoFeature(null)} onNavigateToSource={onNavigateToSource} /> )} ); }