473 lines
21 KiB
TypeScript
473 lines
21 KiB
TypeScript
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<FeatureMeta | null>(null);
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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 (
|
|
<EmptyState
|
|
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
|
title="No area selected"
|
|
description="Click a hexagon or postcode to view area statistics"
|
|
centered
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<div>
|
|
<h2 className="text-sm font-semibold dark:text-warm-100">
|
|
{isPostcode ? hexagonId : 'Area Statistics'}
|
|
</h2>
|
|
{isPostcode && (
|
|
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
|
)}
|
|
</div>
|
|
{loading && stats && (
|
|
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
|
)}
|
|
</div>
|
|
<IconButton onClick={onClose} title="Close">
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</div>
|
|
{propertyCount != null && (
|
|
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
|
{propertyCount.toLocaleString()} properties
|
|
</p>
|
|
)}
|
|
{!isPostcode && stats && (
|
|
<button
|
|
onClick={onViewProperties}
|
|
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
|
>
|
|
View {stats.count.toLocaleString()} Properties
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{hexagonLocation && stats && (
|
|
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
|
)}
|
|
|
|
{/* AI Summary Card */}
|
|
{(aiSummary || aiSummaryLoading || aiSummaryError) && (
|
|
<div className="px-3 pt-3 pb-1">
|
|
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
|
<div className="flex items-center gap-1.5 mb-1.5">
|
|
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
|
|
</div>
|
|
{aiSummaryError ? (
|
|
<div className="text-xs text-warm-600 dark:text-warm-400">
|
|
<span>Failed to generate summary. </span>
|
|
{onRetryAiSummary && (
|
|
<button
|
|
onClick={onRetryAiSummary}
|
|
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : aiSummaryLoading ? (
|
|
<div className="space-y-1.5">
|
|
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
|
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">
|
|
{aiSummary}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading && !stats ? (
|
|
<LoadingSkeleton />
|
|
) : stats ? (
|
|
<div>
|
|
{/* Histogram color legend */}
|
|
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
|
<span className="text-warm-700 dark:text-warm-300">
|
|
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
|
<span className="text-warm-700 dark:text-warm-300">
|
|
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
|
<span className="text-warm-700 dark:text-warm-300">
|
|
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{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 (
|
|
<div key={group.name}>
|
|
<CollapsibleGroupHeader
|
|
name={group.name}
|
|
expanded={isExpanded}
|
|
onToggle={() => 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 && <div className="px-3 py-2 space-y-3">
|
|
{/* 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;
|
|
})() && (
|
|
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
|
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
|
<PriceHistoryChart points={stats.price_history} />
|
|
</div>
|
|
)}
|
|
{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 (
|
|
<div
|
|
key={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: chart.label }}
|
|
onShowInfo={setInfoFeature}
|
|
className="mr-2"
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
|
{chart.label}
|
|
</span>
|
|
)}
|
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
|
{formatValue(total)}
|
|
{chart.unit ? ` ${chart.unit}` : ''}
|
|
</span>
|
|
</div>
|
|
<StackedBarChart segments={segments} total={total} />
|
|
</div>
|
|
);
|
|
})
|
|
: // 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 (
|
|
<div
|
|
key={feature.name}
|
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
>
|
|
<div className="flex justify-between items-baseline">
|
|
<FeatureLabel
|
|
feature={feature}
|
|
onShowInfo={setInfoFeature}
|
|
className="mr-2"
|
|
/>
|
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
|
{formatValue(numericStats.mean, feature)}
|
|
</span>
|
|
</div>
|
|
{numericStats.histogram && (
|
|
globalHistogram ? (
|
|
<DualHistogram
|
|
localCounts={numericStats.histogram.counts}
|
|
globalCounts={globalHistogram.counts}
|
|
p1={numericStats.histogram.p1}
|
|
p99={numericStats.histogram.p99}
|
|
globalMean={globalMean}
|
|
formatLabel={formatFilterValue}
|
|
/>
|
|
) : (
|
|
<DualHistogram
|
|
localCounts={numericStats.histogram.counts}
|
|
globalCounts={numericStats.histogram.counts}
|
|
p1={numericStats.histogram.p1}
|
|
p99={numericStats.histogram.p99}
|
|
formatLabel={formatFilterValue}
|
|
/>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (enumStats) {
|
|
return (
|
|
<div
|
|
key={feature.name}
|
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
>
|
|
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
|
<EnumBarChart counts={enumStats.counts} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
key={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"
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// 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<typeof c> => c !== null);
|
|
|
|
if (components.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
key={chart.label}
|
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
>
|
|
<div className="mb-1.5">
|
|
{featureMeta ? (
|
|
<FeatureLabel
|
|
feature={{ ...featureMeta, name: chart.label }}
|
|
onShowInfo={setInfoFeature}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
|
{chart.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<StackedEnumChart
|
|
components={components}
|
|
valueOrder={chart.valueOrder}
|
|
valueColors={chart.valueColors}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
{/* Google Street View */}
|
|
{hexagonLocation && (
|
|
<div>
|
|
<div 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">
|
|
Street View
|
|
</div>
|
|
<div className="px-3 py-2">
|
|
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
|
<iframe
|
|
className="w-full"
|
|
style={{ height: 240, border: 0 }}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer-when-downgrade"
|
|
src={`https://maps.google.com/maps?layer=c&cbll=${hexagonLocation.lat},${hexagonLocation.lon}&cbp=11,0,0,0,0&output=svembed`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{infoFeature && (
|
|
<FeatureInfoPopup
|
|
feature={infoFeature}
|
|
onClose={() => setInfoFeature(null)}
|
|
onNavigateToSource={onNavigateToSource}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|