Refactor UI
This commit is contained in:
parent
ce4c0cc08c
commit
34a4d0ba86
32 changed files with 1726 additions and 845 deletions
|
|
@ -1,37 +1,28 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData } from '../types';
|
||||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue } from '../lib/format';
|
||||
import { formatValue, calculateHistogramMean } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
import { FeatureInfoPopup } from './FeatureInfoPopup';
|
||||
import { PaneEmptyState } from './ui/EmptyState';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
globalFeatures: FeatureMeta[];
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
isHoveredPreview: boolean;
|
||||
hoverMode: boolean;
|
||||
onHoverModeChange: (enabled: boolean) => void;
|
||||
isPostcode?: boolean;
|
||||
postcodeData?: PostcodeData | null;
|
||||
onViewProperties: () => void;
|
||||
onClose: () => void;
|
||||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
}
|
||||
|
||||
function groupFeatures(globalFeatures: FeatureMeta[]): { name: string; features: FeatureMeta[] }[] {
|
||||
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const feature of globalFeatures) {
|
||||
const groupName = feature.group || 'Other';
|
||||
if (!seen.has(groupName)) {
|
||||
seen.add(groupName);
|
||||
groups.push({ name: groupName, features: [] });
|
||||
}
|
||||
groups.find((group) => group.name === groupName)!.features.push(feature);
|
||||
}
|
||||
return groups;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -39,15 +30,18 @@ export default function AreaPane({
|
|||
globalFeatures,
|
||||
loading,
|
||||
hexagonId,
|
||||
isHoveredPreview,
|
||||
hoverMode,
|
||||
onHoverModeChange,
|
||||
isPostcode = false,
|
||||
postcodeData,
|
||||
onViewProperties,
|
||||
onClose,
|
||||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
}: AreaPaneProps) {
|
||||
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
|
||||
// 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();
|
||||
|
|
@ -65,78 +59,31 @@ export default function AreaPane({
|
|||
);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
|
||||
Click a hexagon to view area statistics
|
||||
</div>
|
||||
);
|
||||
return <PaneEmptyState message="Click a hexagon or postcode to view area statistics" />;
|
||||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
|
||||
{isHoveredPreview && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
Preview
|
||||
</span>
|
||||
<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>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onHoverModeChange(!hoverMode)}
|
||||
className={`p-1 rounded ${
|
||||
hoverMode
|
||||
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
title={
|
||||
hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<IconButton onClick={onClose} title="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
{stats && (
|
||||
{propertyCount != null && (
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{stats.count.toLocaleString()} properties
|
||||
{propertyCount.toLocaleString()} properties
|
||||
</p>
|
||||
)}
|
||||
{stats && (
|
||||
{!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"
|
||||
|
|
@ -174,19 +121,9 @@ export default function AreaPane({
|
|||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
let globalMean: number | undefined;
|
||||
if (globalHistogram && globalHistogram.counts.length > 0) {
|
||||
const totalCount = globalHistogram.counts.reduce((a, b) => a + b, 0);
|
||||
if (totalCount > 0) {
|
||||
let weightedSum = 0;
|
||||
for (let i = 0; i < globalHistogram.counts.length; i++) {
|
||||
const binCenter =
|
||||
globalHistogram.min + (i + 0.5) * globalHistogram.bin_width;
|
||||
weightedSum += binCenter * globalHistogram.counts[i];
|
||||
}
|
||||
globalMean = weightedSum / totalCount;
|
||||
}
|
||||
}
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -194,9 +131,20 @@ export default function AreaPane({
|
|||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{feature.name}
|
||||
</span>
|
||||
<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>
|
||||
|
|
@ -231,9 +179,20 @@ export default function AreaPane({
|
|||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
|
|
@ -248,6 +207,14 @@ export default function AreaPane({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{infoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue