import { useMemo, useState } from 'react'; import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature, } from '../../types'; import type { TravelTimeEntry } from '../../hooks/useTravelTime'; import type { HexagonLocation } from '../../lib/external-search'; import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format'; import { groupFeaturesByCategory } from '../../lib/features'; import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; import EnumBarChart from './EnumBarChart'; import StackedBarChart from './StackedBarChart'; import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; import { InfoIcon } from '../ui/icons'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; import StreetViewEmbed from './StreetViewEmbed'; import HistogramLegend from './HistogramLegend'; import JourneyInstructions from './JourneyInstructions'; interface AreaPaneProps { stats: HexagonStatsResponse | null; globalFeatures: FeatureMeta[]; loading: boolean; hexagonId: string | null; isPostcode?: boolean; postcodeData?: PostcodeFeature | null; onViewProperties: () => void; hexagonLocation: HexagonLocation | null; filters: FeatureFilters; onNavigateToSource?: (slug: string, featureName: string) => void; travelTimeEntries?: TravelTimeEntry[]; } export default function AreaPane({ stats, globalFeatures, loading, hexagonId, isPostcode = false, postcodeData, onViewProperties, hexagonLocation, filters, onNavigateToSource, travelTimeEntries, }: AreaPaneProps) { const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count; const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); const [infoFeature, setInfoFeature] = useState(null); const [collapsedGroups, toggleGroup] = useCollapsibleGroups(); 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]); const globalFeatureByName = useMemo( () => new Map(globalFeatures.map((f) => [f.name, f])), [globalFeatures] ); if (!hexagonId) { return ( } title="No area selected" description="Click a hexagon or postcode to view area statistics" centered /> ); } return ( <>

{isPostcode ? hexagonId : 'Area Statistics'}

{isPostcode && ( Postcode )}
{loading && stats && (
)}
{propertyCount != null && (

{propertyCount.toLocaleString()} properties

)}

Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '} {isPostcode ? 'postcode' : 'area'} {Object.keys(filters).length > 0 ? ' matching all active filters' : ''}

{stats && stats.count > 0 && ( )}
{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; })() && (
Price History
)} {featureGroups.map((group) => { const hasData = group.features.some( (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) ); if (!hasData) 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)) ) ?? [] ); const isExpanded = !collapsedGroups.has(group.name); return (
toggleGroup(group.name)} className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800" /> {isExpanded && (
{stackedCharts ? stackedCharts.map((chart) => { const segments = chart.components .map((name) => ({ name, value: numericByName.get(name)?.mean ?? 0, })) .filter((s) => s.value > 0); const aggregateStats = chart.feature ? numericByName.get(chart.feature) : undefined; const total = aggregateStats ? aggregateStats.mean : segments.reduce((sum, s) => sum + s.value, 0); const featureMeta = chart.feature ? globalFeatureByName.get(chart.feature) : undefined; if (total === 0) return null; return (
{featureMeta ? ( ) : ( {chart.label} )} {formatValue(total)} {chart.unit ? ` ${chart.unit}` : ''}
); }) : group.features .filter((f) => !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; return (
{formatValue(numericStats.mean, feature)}
{numericStats.histogram && (globalHistogram ? ( formatFilterValue(v, feature.raw)} /> ) : ( formatFilterValue(v, feature.raw)} /> ))}
); } if (enumStats) { 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 ? ( ) : ( {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 ? ( ) : ( {chart.label} )}
); })}
)}
); })}
) : null}
{infoFeature && ( setInfoFeature(null)} onNavigateToSource={onNavigateToSource} /> )} ); }