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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
47
frontend/src/components/FeatureIcons.tsx
Normal file
47
frontend/src/components/FeatureIcons.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
import { EyeIcon, InfoIcon, PlusIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
// Re-export icons for backwards compatibility
|
||||
export { EyeIcon, InfoIcon, CloseIcon as RemoveIcon } from './ui/Icons';
|
||||
|
||||
interface FeatureActionsProps {
|
||||
feature: FeatureMeta;
|
||||
isPinned: boolean;
|
||||
onTogglePin: (name: string) => void;
|
||||
onShowInfo?: (feature: FeatureMeta) => void;
|
||||
onRemove?: (name: string) => void;
|
||||
onAdd?: (name: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureActions({
|
||||
feature,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}: FeatureActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{feature.detail && onShowInfo && (
|
||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onTogglePin(feature.name)} title={isPinned ? 'Unpin color view' : 'Color map by this feature'} active={isPinned}>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</IconButton>
|
||||
{onAdd && (
|
||||
<IconButton onClick={() => onAdd(feature.name)} title="Add filter">
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{onRemove && (
|
||||
<IconButton onClick={() => onRemove(feature.name)} title="Remove filter">
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/FeatureInfoPopup.tsx
Normal file
37
frontend/src/components/FeatureInfoPopup.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { FeatureMeta } from '../types';
|
||||
import InfoPopup from './InfoPopup';
|
||||
|
||||
interface FeatureInfoPopupProps {
|
||||
feature: FeatureMeta;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
}
|
||||
|
||||
export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: FeatureInfoPopupProps) {
|
||||
return (
|
||||
<InfoPopup
|
||||
title={feature.name}
|
||||
onClose={onClose}
|
||||
sourceLink={
|
||||
feature.source && onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource(feature.source!, feature.name);
|
||||
onClose();
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{feature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
|
||||
)}
|
||||
{feature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{feature.detail}
|
||||
</p>
|
||||
)}
|
||||
</InfoPopup>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { SelectionButtons } from './ui/SelectionButtons';
|
||||
import { ChevronIcon, FilterIcon, LightbulbIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
import { EmptyState } from './ui/EmptyState';
|
||||
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||
import { formatFilterValue } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { FeatureInfoPopup } from './FeatureInfoPopup';
|
||||
import { FeatureActions } from './FeatureIcons';
|
||||
|
||||
interface FiltersProps {
|
||||
features: FeatureMeta[];
|
||||
|
|
@ -18,27 +26,15 @@ interface FiltersProps {
|
|||
onDragChange: (value: [number, number]) => void;
|
||||
onDragEnd: () => void;
|
||||
zoom: number;
|
||||
itemCount: number;
|
||||
usePostcodeView: boolean;
|
||||
pinnedFeature: string | null;
|
||||
onTogglePin: (name: string) => void;
|
||||
onCancelPin: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
}
|
||||
|
||||
function EyeIcon({ filled, className }: { filled: boolean; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className || 'w-3.5 h-3.5'}
|
||||
viewBox="0 0 24 24"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
function FeatureBrowser({
|
||||
|
|
@ -77,32 +73,12 @@ function FeatureBrowser({
|
|||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||
const seen = new Map<string, FeatureMeta[]>();
|
||||
for (const f of filtered) {
|
||||
const g = f.group || 'Other';
|
||||
let arr = seen.get(g);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
seen.set(g, arr);
|
||||
groups.push({ name: g, features: arr });
|
||||
}
|
||||
arr.push(f);
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search features..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400"
|
||||
/>
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{grouped.map((group) => (
|
||||
|
|
@ -125,86 +101,31 @@ function FeatureBrowser({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 mt-0.5">
|
||||
{f.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(f)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Feature info"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onTogglePin(f.name)}
|
||||
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAddFilter(f.name)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Add filter"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={f}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setInfoFeature}
|
||||
onAdd={onAddFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{grouped.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-warm-400 dark:text-warm-500 text-center">
|
||||
{search ? 'No matching features' : 'All features are active'}
|
||||
</div>
|
||||
<EmptyState
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
className="px-3 py-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{infoFeature && (
|
||||
<InfoPopup
|
||||
title={infoFeature.name}
|
||||
<FeatureInfoPopup
|
||||
feature={infoFeature}
|
||||
onClose={() => setInfoFeature(null)}
|
||||
sourceLink={
|
||||
infoFeature.source && onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource(infoFeature.source!, infoFeature.name);
|
||||
setInfoFeature(null);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{infoFeature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
{infoFeature.description}
|
||||
</p>
|
||||
)}
|
||||
{infoFeature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
{infoFeature.detail}
|
||||
</p>
|
||||
)}
|
||||
</InfoPopup>
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -223,12 +144,15 @@ export default memo(function Filters({
|
|||
onDragChange,
|
||||
onDragEnd,
|
||||
zoom,
|
||||
itemCount,
|
||||
usePostcodeView,
|
||||
pinnedFeature,
|
||||
onTogglePin,
|
||||
onCancelPin: _onCancelPin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
onCollapse,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
|
@ -236,6 +160,8 @@ export default memo(function Filters({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||
const draggingRef = useRef(false);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
||||
const handleSeparatorPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -258,8 +184,22 @@ export default memo(function Filters({
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-80 flex flex-col bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
{onCollapse && (
|
||||
<IconButton onClick={onCollapse} title="Collapse filters">
|
||||
<ChevronIcon direction="left" />
|
||||
</IconButton>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex flex-col" style={{ height: `${splitFraction * 100}%` }}>
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -272,32 +212,19 @@ export default memo(function Filters({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Zoom {zoom.toFixed(1)}</span>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z
|
||||
{zoom.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{enabledFeatureList.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-warm-300 dark:text-warm-600 mb-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">
|
||||
No active filters
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">
|
||||
Browse features below and click + to add a filter
|
||||
</span>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No active filters"
|
||||
description="Browse features below and click + to add a filter"
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFeatureList.map((feature) => {
|
||||
|
|
@ -311,41 +238,19 @@ export default memo(function Filters({
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{feature.name}</Label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
className={`p-0.5 rounded ${pinnedFeature === feature.name ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={
|
||||
pinnedFeature === feature.name
|
||||
? 'Unpin color view'
|
||||
: 'Color map by this feature'
|
||||
}
|
||||
>
|
||||
<EyeIcon filled={pinnedFeature === feature.name} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveFilter(feature.name)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
|
||||
title="Remove filter"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm mb-1">
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:underline"
|
||||
onClick={() => onFilterChange(feature.name, [...allValues])}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:underline"
|
||||
onClick={() => onFilterChange(feature.name, [])}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={pinnedFeature === feature.name}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<SelectionButtons
|
||||
onSelectAll={() => onFilterChange(feature.name, [...allValues])}
|
||||
onSelectNone={() => onFilterChange(feature.name, [])}
|
||||
className="mb-1"
|
||||
/>
|
||||
<div className="space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{allValues.map((val) => (
|
||||
<label
|
||||
|
|
@ -389,22 +294,13 @@ export default memo(function Filters({
|
|||
{feature.name}: {formatFilterValue(displayValue[0])} -{' '}
|
||||
{formatFilterValue(displayValue[1])}
|
||||
</Label>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => onTogglePin(feature.name)}
|
||||
className={`p-0.5 rounded ${isPinned ? 'text-teal-600 dark:text-teal-400' : 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'}`}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
>
|
||||
<EyeIcon filled={isPinned} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveFilter(feature.name)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 text-sm px-1"
|
||||
title="Remove filter"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={setActiveInfoFeature}
|
||||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
min={feature.min!}
|
||||
|
|
@ -447,6 +343,73 @@ export default memo(function Filters({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPhilosophy && (
|
||||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Be intentional, not reactive
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Your future home isn't a box of cereal you grab because it's on sale. Don't let a
|
||||
seemingly good deal turn into lifelong regret. Instead of waiting for listings to
|
||||
appear, define what you actually want and go find it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
See the full picture
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Current listings show only a fraction of the market. There are too few to give you a
|
||||
complete picture, yet too many to evaluate one by one. We aggregate millions of
|
||||
historical sales so you can understand what's truly available in any area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Your priorities, your filters
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We all care about different things. Some want peace and quiet; others want to be
|
||||
near the action. Use our filters to define exactly what matters to you and discover
|
||||
postcodes that match.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Find the right place, not just the right listing
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
The best areas to live don't always have properties listed right now. We help you
|
||||
identify where you should be looking, so when something does come up, you're ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Know what's possible
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{activeInfoFeature && (
|
||||
<FeatureInfoPopup
|
||||
feature={activeInfoFeature}
|
||||
onClose={() => setActiveInfoFeature(null)}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
94
frontend/src/components/HoverCard.tsx
Normal file
94
frontend/src/components/HoverCard.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { memo } from 'react';
|
||||
import type { HexagonData, PostcodeData, FeatureFilters } from '../types';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface HoverCardProps {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
isPostcode: boolean;
|
||||
data: HexagonData | PostcodeData | null;
|
||||
filters: FeatureFilters;
|
||||
}
|
||||
|
||||
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) {
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
// Get key stats to show from local data (min_<feature> values)
|
||||
const getDisplayStats = () => {
|
||||
if (!data) return [];
|
||||
|
||||
const results: { name: string; value: string }[] = [];
|
||||
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const minVal = data[`min_${name}`];
|
||||
if (minVal != null && typeof minVal === 'number') {
|
||||
results.push({ name, value: formatValue(minVal) });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const displayStats = getDisplayStats();
|
||||
const count = data?.count;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-warm-200 pointer-events-none z-50 min-w-[180px] max-w-[260px]"
|
||||
style={{
|
||||
left: x,
|
||||
top: y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white dark:bg-warm-800 rotate-45"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: -6,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100 truncate">
|
||||
{isPostcode ? id : 'Area'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Property count */}
|
||||
{count != null && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick stats */}
|
||||
{displayStats.length > 0 && (
|
||||
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
|
||||
{displayStats.map((stat) => (
|
||||
<div key={stat.name} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-warm-500 dark:text-warm-400 truncate">{stat.name}</span>
|
||||
<span className="font-medium text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
{data && (
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
|
||||
Click for details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { useRef, useCallback, type ReactNode } from 'react';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
interface InfoPopupProps {
|
||||
title: string;
|
||||
|
|
@ -25,20 +27,9 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
|
|||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
>
|
||||
<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>
|
||||
<IconButton onClick={onClose} className="shrink-0">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
{children}
|
||||
{sourceLink && (
|
||||
|
|
|
|||
|
|
@ -3,10 +3,18 @@ import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
|||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { IconLayer } from '@deck.gl/layers';
|
||||
import { IconLayer, PolygonLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
|
||||
import type {
|
||||
HexagonData,
|
||||
PostcodeData,
|
||||
ViewState,
|
||||
ViewChangeParams,
|
||||
Bounds,
|
||||
POI,
|
||||
FeatureMeta,
|
||||
} from '../types';
|
||||
import {
|
||||
GRADIENT,
|
||||
normalizedToColor,
|
||||
|
|
@ -14,11 +22,14 @@ import {
|
|||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
emojiToTwemojiUrl,
|
||||
MAP_STYLE_LIGHT,
|
||||
MAP_STYLE_DARK,
|
||||
getMapStyle,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../lib/map-utils';
|
||||
import PostcodeSearch from './PostcodeSearch';
|
||||
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../lib/consts';
|
||||
import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../types';
|
||||
|
||||
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
|
||||
function osmIdToUrl(id: string): string | null {
|
||||
|
|
@ -30,6 +41,8 @@ function osmIdToUrl(id: string): string | null {
|
|||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
postcodeData: PostcodeData[];
|
||||
usePostcodeView: boolean;
|
||||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
viewFeature: string | null;
|
||||
|
|
@ -40,19 +53,16 @@ interface MapProps {
|
|||
features: FeatureMeta[];
|
||||
selectedHexagonId: string | null;
|
||||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (h3: string) => void;
|
||||
onHexagonHover: (h3: string | null) => void;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
filters?: FeatureFilters;
|
||||
searchedPostcode?: SearchedPostcode | null;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}
|
||||
|
||||
const INITIAL_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
|
|
@ -81,6 +91,8 @@ function DeckOverlay({
|
|||
|
||||
export default memo(function Map({
|
||||
data,
|
||||
postcodeData,
|
||||
usePostcodeView,
|
||||
pois,
|
||||
onViewChange,
|
||||
viewFeature,
|
||||
|
|
@ -96,10 +108,14 @@ export default memo(function Map({
|
|||
initialViewState,
|
||||
theme = 'light',
|
||||
screenshotMode = false,
|
||||
filters = {},
|
||||
searchedPostcode,
|
||||
onPostcodeSearched,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -119,17 +135,11 @@ export default memo(function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
// Send exact viewport bounds - server will filter to only return
|
||||
// hexagons/postcodes that intersect this precise AABB
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
const QUANT = 0.01;
|
||||
const bounds: Bounds = {
|
||||
south: Math.floor(raw.south / QUANT) * QUANT,
|
||||
west: Math.floor(raw.west / QUANT) * QUANT,
|
||||
north: Math.ceil(raw.north / QUANT) * QUANT,
|
||||
east: Math.ceil(raw.east / QUANT) * QUANT,
|
||||
};
|
||||
|
||||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
|
|
@ -153,30 +163,17 @@ export default memo(function Map({
|
|||
const handleMapLoad = useCallback(
|
||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
const map = evt.target;
|
||||
if (themeRef.current === 'light') {
|
||||
for (const layer of map.getStyle().layers || []) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
|
||||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
||||
map.setPaintProperty(layer.id, 'text-color', '#222');
|
||||
}
|
||||
for (const layer of map.getStyle().layers || []) {
|
||||
if (layer.id === 'water' || layer.id.startsWith('water')) {
|
||||
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide buildings to reduce visual clutter over hexagons
|
||||
try {
|
||||
map.setLayoutProperty('building', 'visibility', 'none');
|
||||
map.setLayoutProperty('building-top', 'visibility', 'none');
|
||||
map.setLayoutProperty('buildings', 'visibility', 'none');
|
||||
} catch {
|
||||
// layers may not exist in dark style
|
||||
// layer may not exist
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
x: number;
|
||||
|
|
@ -244,9 +241,11 @@ export default memo(function Map({
|
|||
const onHexagonHoverRef = useRef(onHexagonHover);
|
||||
onHexagonHoverRef.current = onHexagonHover;
|
||||
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
||||
if (info.object && 'h3' in info.object) {
|
||||
onHexagonHoverRef.current(info.object.h3);
|
||||
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
|
||||
} else {
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -257,7 +256,54 @@ export default memo(function Map({
|
|||
handlePoiHoverRef.current(info);
|
||||
}, []);
|
||||
|
||||
// Compute count range for postcodes (similar to hexagons)
|
||||
const postcodeCountRange = useMemo(() => {
|
||||
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const d of postcodeData) {
|
||||
const c = d.count as number;
|
||||
if (c < min) min = c;
|
||||
if (c > max) max = c;
|
||||
}
|
||||
if (min === max) return { min, max: min + 1 };
|
||||
return { min, max };
|
||||
}, [postcodeData]);
|
||||
|
||||
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
||||
postcodeCountRangeRef.current = postcodeCountRange;
|
||||
|
||||
// Track selected/hovered postcode for styling
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string | null>(null);
|
||||
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
||||
const selectedPostcodeRef = useRef(selectedPostcode);
|
||||
selectedPostcodeRef.current = selectedPostcode;
|
||||
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
||||
hoveredPostcodeRef.current = hoveredPostcode;
|
||||
|
||||
const handlePostcodeClick = useCallback((info: PickingInfo<PostcodeData>) => {
|
||||
if (info.object && 'postcode' in info.object) {
|
||||
const pc = info.object.postcode;
|
||||
setSelectedPostcode((prev) => (prev === pc ? null : pc));
|
||||
// Also trigger the hexagon click handler with the postcode as identifier
|
||||
onHexagonClickRef.current(pc, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<PostcodeData>) => {
|
||||
if (info.object && 'postcode' in info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setHoveredPostcode(info.object.postcode);
|
||||
setHoverPosition({ x: info.x, y: info.y });
|
||||
onHexagonHoverRef.current(info.object.postcode, info.x, info.y);
|
||||
} else {
|
||||
setHoveredPostcode(null);
|
||||
setHoverPosition(null);
|
||||
onHexagonHoverRef.current(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}|${hoveredPostcode}`;
|
||||
|
||||
const hexLayer = useMemo(
|
||||
() =>
|
||||
|
|
@ -321,11 +367,76 @@ export default memo(function Map({
|
|||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'waterway_label',
|
||||
beforeId: 'water_waterway_label',
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
||||
const postcodeLayer = useMemo(
|
||||
() =>
|
||||
new PolygonLayer<PostcodeData>({
|
||||
id: 'postcode-polygons',
|
||||
data: postcodeData,
|
||||
getPolygon: (d) => d.vertices,
|
||||
getFillColor: (d) => {
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`min_${vf}`];
|
||||
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
|
||||
if (fr) {
|
||||
const minVal = d[`min_${vf}`] as number;
|
||||
const maxVal = d[`max_${vf}`] as number;
|
||||
if (maxVal < fr[0] || minVal > fr[1]) {
|
||||
return [180, 180, 180, 60] as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getLineColor: (d) => {
|
||||
if (d.postcode === selectedPostcodeRef.current)
|
||||
return [255, 255, 255, 255] as [number, number, number, number];
|
||||
if (d.postcode === hoveredPostcodeRef.current)
|
||||
return [29, 228, 195, 200] as [number, number, number, number];
|
||||
return [100, 100, 100, 150] as [number, number, number, number];
|
||||
},
|
||||
getLineWidth: (d) => {
|
||||
if (d.postcode === selectedPostcodeRef.current) return 3;
|
||||
if (d.postcode === hoveredPostcodeRef.current) return 2;
|
||||
return 1;
|
||||
},
|
||||
lineWidthUnits: 'pixels',
|
||||
updateTriggers: {
|
||||
getFillColor: [postcodeColorTrigger],
|
||||
getLineColor: [postcodeColorTrigger],
|
||||
getLineWidth: [postcodeColorTrigger],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'water_waterway_label',
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
||||
const poiLayer = useMemo(
|
||||
() =>
|
||||
new IconLayer<POI>({
|
||||
|
|
@ -346,7 +457,43 @@ export default memo(function Map({
|
|||
[pois, stablePoiHover]
|
||||
);
|
||||
|
||||
const layers = useMemo(() => [hexLayer, poiLayer], [hexLayer, poiLayer]);
|
||||
// Check if the searched postcode has data (passes current filters)
|
||||
const searchedPostcodeHasData = useMemo(() => {
|
||||
if (!searchedPostcode) return false;
|
||||
return postcodeData.some((d) => d.postcode === searchedPostcode.postcode);
|
||||
}, [searchedPostcode, postcodeData]);
|
||||
|
||||
// Highlight layer for searched postcode
|
||||
const searchedPostcodeHighlightLayer = useMemo(() => {
|
||||
if (!searchedPostcode) return null;
|
||||
const hasData = searchedPostcodeHasData;
|
||||
// Use different layers for dashed vs solid lines
|
||||
return new PolygonLayer<{ vertices: [number, number][] }>({
|
||||
id: 'searched-postcode-highlight',
|
||||
data: [{ vertices: searchedPostcode.vertices }],
|
||||
getPolygon: (d) => d.vertices,
|
||||
// Transparent fill - just show outline
|
||||
getFillColor: hasData
|
||||
? [29, 228, 195, 40] // teal tint when has data
|
||||
: [255, 180, 0, 30], // orange tint when filtered out
|
||||
getLineColor: hasData
|
||||
? [29, 228, 195, 255] // solid teal when has data
|
||||
: [255, 180, 0, 200], // orange when filtered out (no matching properties)
|
||||
getLineWidth: hasData ? 4 : 3,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: false,
|
||||
});
|
||||
}, [searchedPostcode, searchedPostcodeHasData]);
|
||||
|
||||
const layers = useMemo(() => {
|
||||
const baseLayers = usePostcodeView ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer];
|
||||
if (searchedPostcodeHighlightLayer) {
|
||||
return [...baseLayers, searchedPostcodeHighlightLayer];
|
||||
}
|
||||
return baseLayers;
|
||||
}, [usePostcodeView, hexLayer, postcodeLayer, poiLayer, searchedPostcodeHighlightLayer]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
|
|
@ -362,8 +509,8 @@ export default memo(function Map({
|
|||
touchPitch={false}
|
||||
keyboard={true}
|
||||
pitchWithRotate={false}
|
||||
minZoom={5}
|
||||
maxBounds={[-12, 49, 4, 62]}
|
||||
minZoom={MAP_MIN_ZOOM}
|
||||
maxBounds={MAP_BOUNDS}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
</MapGL>
|
||||
|
|
@ -378,7 +525,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} />
|
||||
<PostcodeSearch onFlyTo={handleFlyTo} onPostcodeSearched={onPostcodeSearched} />
|
||||
{viewSource === 'eye' && viewFeature && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-white dark:bg-warm-800 rounded-lg shadow-lg px-5 py-3">
|
||||
<span className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
|
|
@ -434,6 +581,20 @@ export default memo(function Map({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && (
|
||||
<HoverCard
|
||||
x={hoverPosition.x}
|
||||
y={hoverPosition.y}
|
||||
id={hoveredHexagonId}
|
||||
isPostcode={usePostcodeView}
|
||||
data={
|
||||
usePostcodeView
|
||||
? postcodeData.find((d) => d.postcode === hoveredHexagonId) || null
|
||||
: data.find((d) => d.h3 === hoveredHexagonId) || null
|
||||
}
|
||||
filters={filters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useState, useRef, useCallback } from 'react';
|
|||
import type { POICategoryGroup } from '../types';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { SelectionButtons } from './ui/SelectionButtons';
|
||||
import { InfoIcon, ChevronIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
interface POIPaneProps {
|
||||
groups: POICategoryGroup[];
|
||||
|
|
@ -93,22 +97,9 @@ export default function POIPane({
|
|||
<div className="w-72 p-4 bg-white dark:bg-navy-950 shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold dark:text-warm-100">Points of Interest</h2>
|
||||
<button
|
||||
onClick={() => setShowInfo(true)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Data source info"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
|
|
@ -148,40 +139,22 @@ export default function POIPane({
|
|||
? 'All categories'
|
||||
: `${selectedCount} selected`}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 flex-shrink-0 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<ChevronIcon
|
||||
direction={dropdownOpen ? 'up' : 'down'}
|
||||
className="w-4 h-4 ml-2 flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="border border-warm-300 dark:border-navy-700 rounded shadow-lg bg-white dark:bg-navy-800">
|
||||
<div className="flex gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<span className="text-xs text-warm-300 dark:text-warm-600">|</span>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<SelectionButtons onSelectAll={selectAll} onSelectNone={selectNone} className="text-xs" />
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search categories..."
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500"
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto py-1">
|
||||
|
|
@ -198,21 +171,9 @@ export default function POIPane({
|
|||
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-y border-warm-100 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => toggleCollapse(group.name)}
|
||||
className="p-0.5 text-warm-400 hover:text-warm-600"
|
||||
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface SearchedPostcode {
|
||||
postcode: string;
|
||||
vertices: [number, number][];
|
||||
}
|
||||
|
||||
export default function PostcodeSearch({
|
||||
onFlyTo,
|
||||
onPostcodeSearched,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onPostcodeSearched?: (postcode: SearchedPostcode | null) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -18,27 +25,27 @@ export default function PostcodeSearch({
|
|||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}`
|
||||
);
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`);
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
if (json.status === 200 && json.result) {
|
||||
onFlyTo(json.result.latitude, json.result.longitude, 14);
|
||||
setQuery('');
|
||||
} else {
|
||||
setError('Postcode not found');
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
vertices: [number, number][];
|
||||
} = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onPostcodeSearched?.({ postcode: json.postcode, vertices: json.vertices });
|
||||
setQuery('');
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[query, onFlyTo]
|
||||
[query, onFlyTo, onPostcodeSearched]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { Property } from '../types';
|
||||
import { formatDuration, formatAge } from '../lib/format';
|
||||
import { formatDuration, formatAge, formatNumber } from '../lib/format';
|
||||
import { getNum } from '../lib/property-fields';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { SearchInput } from './ui/SearchInput';
|
||||
import { PaneHeader } from './ui/PaneHeader';
|
||||
import { PaneEmptyState } from './ui/EmptyState';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -11,9 +15,6 @@ interface PropertiesPaneProps {
|
|||
onLoadMore: () => void;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
isHoveredPreview?: boolean;
|
||||
hoverMode?: boolean;
|
||||
onHoverModeChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
type SortBy = 'price' | 'size' | 'energy';
|
||||
|
|
@ -26,9 +27,6 @@ export function PropertiesPane({
|
|||
onLoadMore,
|
||||
onClose,
|
||||
onNavigateToSource,
|
||||
isHoveredPreview,
|
||||
hoverMode,
|
||||
onHoverModeChange,
|
||||
}: PropertiesPaneProps) {
|
||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -56,130 +54,52 @@ export function PropertiesPane({
|
|||
}, [properties, sortBy, search]);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400">
|
||||
Click a hexagon to view properties
|
||||
</div>
|
||||
);
|
||||
return <PaneEmptyState message="Click a hexagon to view properties" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 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-lg font-semibold dark:text-warm-100">Properties</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>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowInfo(true)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-0.5 rounded"
|
||||
title="Data source info"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onHoverModeChange && (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400">
|
||||
{search.trim()
|
||||
<PaneHeader
|
||||
title="Properties"
|
||||
subtitle={
|
||||
search.trim()
|
||||
? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded`
|
||||
: `Showing ${properties.length} of ${total} properties`}
|
||||
</p>
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
|
||||
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
|
||||
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
|
||||
recent sale price from the Land Registry.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
</div>
|
||||
: `Showing ${properties.length} of ${total} properties`
|
||||
}
|
||||
onClose={onClose}
|
||||
onInfoClick={() => setShowInfo(true)}
|
||||
/>
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Property data combines Energy Performance Certificates (EPC) with HM Land Registry
|
||||
Price Paid records, fuzzy-matched by address within each postcode. Includes floor
|
||||
area, energy ratings, construction age, and tenure from EPC surveys, plus the most
|
||||
recent sale price from the Land Registry.
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2 border-b border-warm-200 dark:border-navy-700 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by address or postcode..."
|
||||
className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||
className="p-2"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
|
|
@ -216,20 +136,7 @@ export function PropertiesPane({
|
|||
);
|
||||
}
|
||||
|
||||
function getNum(property: Property, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const v = property[key];
|
||||
if (v !== undefined && v !== null && typeof v === 'number') return v;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const fmt = (value: number | undefined, decimals = 0): string => {
|
||||
if (value === undefined) return '';
|
||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
};
|
||||
|
||||
const price = getNum(property, 'Last known price', 'latest_price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
|
||||
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
|
||||
|
|
@ -251,11 +158,11 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
|
||||
{price !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{fmt(price)}
|
||||
£{formatNumber(price)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
(£{fmt(pricePerSqm)}/m²)
|
||||
(£{formatNumber(pricePerSqm)}/m²)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -281,12 +188,12 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {fmt(floorArea)}m²
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span> {formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {fmt(rooms)}
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
||||
</div>
|
||||
)}
|
||||
{age !== undefined && (
|
||||
|
|
@ -310,12 +217,12 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{councilTax !== undefined ? (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
||||
{fmt(councilTax)}/yr
|
||||
{formatNumber(councilTax)}/yr
|
||||
</div>
|
||||
) : councilTaxD !== undefined ? (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
||||
{fmt(councilTaxD)}/yr
|
||||
{formatNumber(councilTaxD)}/yr
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
89
frontend/src/components/ui/CheckboxList.tsx
Normal file
89
frontend/src/components/ui/CheckboxList.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
interface CheckboxListProps {
|
||||
items: string[];
|
||||
selected: string[] | Set<string>;
|
||||
onChange: (selected: string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckboxList({ items, selected, onChange, className = '' }: CheckboxListProps) {
|
||||
const selectedSet = selected instanceof Set ? selected : new Set(selected);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(item: string) => {
|
||||
const newSelected = selectedSet.has(item)
|
||||
? [...selectedSet].filter((v) => v !== item)
|
||||
: [...selectedSet, item];
|
||||
onChange(newSelected);
|
||||
},
|
||||
[selectedSet, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-0.5 ${className}`}>
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className="flex items-center gap-1.5 text-sm cursor-pointer dark:text-warm-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(item)}
|
||||
onChange={() => handleToggle(item)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
{item}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxListWithSetProps {
|
||||
items: string[];
|
||||
selected: Set<string>;
|
||||
onChange: (selected: Set<string>) => void;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
export function CheckboxListWithSet({
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
className = '',
|
||||
itemClassName = '',
|
||||
}: CheckboxListWithSetProps) {
|
||||
const handleToggle = useCallback(
|
||||
(item: string) => {
|
||||
const newSet = new Set(selected);
|
||||
if (newSet.has(item)) {
|
||||
newSet.delete(item);
|
||||
} else {
|
||||
newSet.add(item);
|
||||
}
|
||||
onChange(newSet);
|
||||
},
|
||||
[selected, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className={`flex items-center gap-2 cursor-pointer dark:text-warm-300 ${itemClassName}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item)}
|
||||
onChange={() => handleToggle(item)}
|
||||
className="rounded accent-teal-600"
|
||||
/>
|
||||
<span className="text-sm flex-1">{item}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/ui/EmptyState.tsx
Normal file
31
frontend/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, className = '' }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center py-8 text-center ${className}`}
|
||||
>
|
||||
{icon && <div className="mb-2">{icon}</div>}
|
||||
<span className="text-sm font-medium text-warm-400 dark:text-warm-500">{title}</span>
|
||||
{description && (
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 mt-1">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Centered message variant for panes
|
||||
export function PaneEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/IconButton.tsx
Normal file
22
frontend/src/components/ui/IconButton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { ReactNode, MouseEvent } from 'react';
|
||||
|
||||
interface IconButtonProps {
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButton({ onClick, title, children, active, className }: IconButtonProps) {
|
||||
const baseClasses = 'p-0.5 rounded';
|
||||
const colorClasses = active
|
||||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300';
|
||||
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${baseClasses} ${colorClasses} ${className || ''}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/ui/Icons.tsx
Normal file
92
frontend/src/components/ui/Icons.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Shared icon components with consistent sizing and styling
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CloseIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path strokeLinecap="round" d="M12 16v-4m0-4h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeIcon({ filled, className = 'w-3.5 h-3.5' }: IconProps & { filled: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronIcon({
|
||||
direction,
|
||||
className = 'w-4 h-4',
|
||||
}: IconProps & { direction: 'left' | 'right' | 'up' | 'down' }) {
|
||||
const paths: Record<string, string> = {
|
||||
left: 'M15 19l-7-7 7-7',
|
||||
right: 'M9 5l7 7-7 7',
|
||||
up: 'M18 15l-6-6-6 6',
|
||||
down: 'M6 9l6 6 6-6',
|
||||
};
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={paths[direction]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterIcon({ className = 'w-8 h-8' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LightbulbIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/PaneHeader.tsx
Normal file
35
frontend/src/components/ui/PaneHeader.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { CloseIcon, InfoIcon } from './Icons';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
interface PaneHeaderProps {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
onClose?: () => void;
|
||||
onInfoClick?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PaneHeader({ title, subtitle, onClose, onInfoClick, children }: PaneHeaderProps) {
|
||||
return (
|
||||
<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">{title}</h2>
|
||||
{onInfoClick && (
|
||||
<IconButton onClick={onInfoClick} title="Data source info">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<IconButton onClick={onClose} title="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && <div className="text-sm text-warm-600 dark:text-warm-400 mt-1">{subtitle}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/SearchInput.tsx
Normal file
23
frontend/src/components/ui/SearchInput.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
className = '',
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/ui/SelectionButtons.tsx
Normal file
24
frontend/src/components/ui/SelectionButtons.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
interface SelectionButtonsProps {
|
||||
onSelectAll: () => void;
|
||||
onSelectNone: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SelectionButtons({ onSelectAll, onSelectNone, className = '' }: SelectionButtonsProps) {
|
||||
return (
|
||||
<div className={`flex gap-2 text-sm ${className}`}>
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
onClick={onSelectAll}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
onClick={onSelectNone}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/TabButton.tsx
Normal file
22
frontend/src/components/ui/TabButton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface TabButtonProps {
|
||||
label: string;
|
||||
count?: number;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function TabButton({ label, count, isActive, onClick }: TabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
isActive
|
||||
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && count > 0 && ` (${count})`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue