Move into folders
This commit is contained in:
parent
ee73ab77fd
commit
5cbb180c57
24 changed files with 181 additions and 185 deletions
|
|
@ -1,296 +0,0 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types';
|
||||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue, calculateHistogramMean } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import { STACKED_GROUPS } from '../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from './ui/icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
import { FeatureInfoPopup } from './FeatureInfoPopup';
|
||||
import { EmptyState } from './ui/EmptyState';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
globalFeatures: FeatureMeta[];
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
isPostcode?: boolean;
|
||||
postcodeData?: PostcodeData | null;
|
||||
onViewProperties: () => void;
|
||||
onClose: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
stats,
|
||||
globalFeatures,
|
||||
loading,
|
||||
hexagonId,
|
||||
isPostcode = false,
|
||||
postcodeData,
|
||||
onViewProperties,
|
||||
onClose,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
}: AreaPaneProps) {
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(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]);
|
||||
|
||||
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>
|
||||
<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>
|
||||
<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} />
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{stats.price_history && stats.price_history.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{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];
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
{group.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{chart.label}
|
||||
</span>
|
||||
{featureMeta?.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(featureMeta)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
group.features.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">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
<>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
{globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
globalMean={globalMean}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{infoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue