Refactor UI

This commit is contained in:
Andras Schmelczer 2026-02-04 22:27:56 +00:00
parent ce4c0cc08c
commit 34a4d0ba86
32 changed files with 1726 additions and 845 deletions

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
});

View 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>
);
});

View file

@ -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 && (

View file

@ -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>

View file

@ -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

View file

@ -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 (

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}`}
/>
);
}

View 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>
);
}

View 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>
);
}