import { useMemo, useState } from 'react'; 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 { LightbulbIcon } from '../ui/icons/LightbulbIcon'; import { IconButton } from '../ui/IconButton'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; 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; onRetryAiSummary?: () => void; } export default function AreaPane({ stats, globalFeatures, loading, hexagonId, isPostcode = false, postcodeData, onViewProperties, onClose, hexagonLocation, filters, onNavigateToSource, aiSummary, aiSummaryLoading, aiSummaryError, onRetryAiSummary, }: 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, setCollapsedGroups] = useState>(new Set()); const toggleGroup = (name: string) => setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); 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 && ( )} {/* AI Summary Card */} {(aiSummary || aiSummaryLoading || aiSummaryError) && (
AI Summary
{aiSummaryError ? (
Failed to generate summary. {onRetryAiSummary && ( )}
) : aiSummaryLoading ? (
) : (

{aiSummary}

)}
)}
{loading && !stats ? ( ) : stats ? (
{/* Histogram color legend */}
Teal bars show the distribution in this selected area
Gray bars show the overall distribution across all areas
Dashed line indicates the global average
{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} )}
); })}
}
); })} {/* Google Street View */} {hexagonLocation && (
Street View