import { useMemo, useState } from 'react'; import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature, } from '../../types'; 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, CloseIcon } from '../ui/icons'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { IconButton } from '../ui/IconButton'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; import AISummaryCard from './AISummaryCard'; import StreetViewEmbed from './StreetViewEmbed'; import HistogramLegend from './HistogramLegend'; interface AreaPaneProps { stats: HexagonStatsResponse | null; globalFeatures: FeatureMeta[]; loading: boolean; hexagonId: string | null; isPostcode?: boolean; postcodeData?: PostcodeFeature | null; onViewProperties: () => void; onClose: () => void; hexagonLocation: HexagonLocation | null; filters: FeatureFilters; onNavigateToSource?: (slug: string, featureName: string) => void; aiSummary?: string; aiSummaryLoading?: boolean; aiSummaryError?: string | null; } export default function AreaPane({ stats, globalFeatures, loading, hexagonId, isPostcode = false, postcodeData, onViewProperties, onClose, hexagonLocation, filters, onNavigateToSource, aiSummary, aiSummaryLoading, aiSummaryError, }: AreaPaneProps) { // For postcodes, use local data for count 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 [aiSummaryExpanded, setAiSummaryExpanded] = useState(true); 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

)} {!isPostcode && stats && ( )}
{hexagonLocation && stats && ( )}
setAiSummaryExpanded(!aiSummaryExpanded)} /> {loading && !stats ? ( ) : stats ? (
{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]; // Features that are part of a stacked enum config (rendered as compact charts) const stackedEnumFeatureNames = new Set( (stackedEnumCharts?.flatMap((c) => [c.feature, ...c.components].filter(Boolean) ) as string[]) ?? [] ); const isExpanded = !collapsedGroups.has(group.name); return (
toggleGroup(group.name)} className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" /> {isExpanded && (
{/* Price History in Property group */} {group.name === 'Property' && stats.price_history && (() => { // Only show chart if there are at least 2 unique years const uniqueYears = new Set( stats.price_history.map((p) => Math.floor(p.year)) ); return uniqueYears.size > 1; })() && (
Price History
)} {stackedCharts ? // Render stacked charts for this group stackedCharts.map((chart) => { const segments = chart.components .map((name) => ({ name, value: numericByName.get(name)?.mean ?? 0, })) .filter((s) => s.value > 0); // Use aggregate feature stats if available, otherwise sum components 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}` : ''}
); }) : // Default: render each feature individually (skip stacked enum features) 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 ? ( ) : ( ))}
); } if (enumStats) { return (
); } return null; })} {/* Stacked enum charts */} {stackedEnumCharts?.map((chart) => { const featureMeta = chart.feature ? globalFeatureByName.get(chart.feature) : undefined; // Single component: render as a stacked bar (like crime charts) 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]]) )} />
); } // Multi-component: render as compact multi-row chart (like risk features) 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} )}
); })}
)}
); })} {hexagonLocation && }
) : null}
{infoFeature && ( setInfoFeature(null)} onNavigateToSource={onNavigateToSource} /> )}
); }