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

@ -13,6 +13,7 @@
"@deck.gl/layers": "^9.0.0",
"@deck.gl/mapbox": "^9.2.6",
"@deck.gl/react": "^9.0.0",
"@protomaps/basemaps": "^5.7.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.0",
"class-variance-authority": "^0.7.0",
@ -1714,6 +1715,14 @@
"integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==",
"license": "MIT"
},
"node_modules/@protomaps/basemaps": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.0.tgz",
"integrity": "sha512-vIInnzVSxHuOcvj1BFGkCjlFxG/9a1GV23t98kGEVcPUM7aEqTnf6loUHTRJYX5eCz+WCO16N0aibr1SLg830Q==",
"bin": {
"generate_style": "src/cli.ts"
}
},
"node_modules/@puppeteer/browsers": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz",

View file

@ -2,7 +2,7 @@
"name": "property-map-frontend",
"version": "1.0.0",
"scripts": {
"dev": "webpack serve --mode development --port 3030",
"dev": "webpack serve --mode development --port 3000",
"build": "webpack --mode production && node scripts/prerender.mjs",
"build:no-prerender": "webpack --mode production",
"prerender": "node scripts/prerender.mjs",
@ -18,6 +18,7 @@
"@deck.gl/layers": "^9.0.0",
"@deck.gl/mapbox": "^9.2.6",
"@deck.gl/react": "^9.0.0",
"@protomaps/basemaps": "^5.7.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.0",
"class-variance-authority": "^0.7.0",

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { trackPageview } from './usePlausible';
import Map from './components/Map';
import type { SearchedPostcode } from './components/PostcodeSearch';
import Filters from './components/Filters';
import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane';
@ -10,12 +11,15 @@ import DataSourcesPage from './components/DataSourcesPage';
import FAQPage from './components/FAQPage';
import HomePage from './components/HomePage';
import Header, { type Page } from './components/Header';
import { ChevronIcon } from './components/ui/Icons';
import { TabButton } from './components/ui/TabButton';
import type {
FeatureMeta,
FeatureGroup,
FeatureFilters,
Bounds,
HexagonData,
PostcodeData,
ViewChangeParams,
ApiResponse,
POI,
@ -26,8 +30,9 @@ import type {
HexagonPropertiesResponse,
HexagonStatsResponse,
} from './types';
import { fetchWithRetry, getApiBaseUrl, buildFilterString } from './lib/api';
import { fetchWithRetry, getApiBaseUrl, buildFilterString, apiUrl, logNonAbortError } from './lib/api';
import { parseUrlState, DEFAULT_VIEW } from './lib/url-state';
import { POSTCODE_ZOOM_THRESHOLD } from './lib/map-utils';
import { useTheme } from './hooks/useTheme';
import { useUrlSync } from './hooks/useUrlSync';
@ -53,6 +58,7 @@ export default function App() {
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [postcodeData, setPostcodeData] = useState<PostcodeData[]>([]);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
@ -86,9 +92,11 @@ export default function App() {
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(
null
);
const [selectedHexagon, setSelectedHexagon] = useState<{
id: string;
type: 'hexagon' | 'postcode';
resolution: number;
} | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
@ -100,14 +108,11 @@ export default function App() {
const [areaStats, setAreaStats] = useState<HexagonStatsResponse | null>(null);
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false);
const [rightPaneCollapsed, setRightPaneCollapsed] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [hoveredAreaStats, setHoveredAreaStats] = useState<HexagonStatsResponse | null>(null);
const [hoveredProperties, setHoveredProperties] = useState<Property[] | null>(null);
const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0);
const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false);
const [hoverMode, setHoverMode] = useState(true);
const hoverAbortRef = useRef<AbortController | null>(null);
const hoverDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [searchedPostcode, setSearchedPostcode] = useState<SearchedPostcode | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const [activePage, setActivePage] = useState<Page>(() => {
@ -162,20 +167,6 @@ export default function App() {
const viewFeature = activeFeature || pinnedFeature;
const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null;
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (!meta) return null;
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
if (activeFeature === viewFeature && dragValue) return dragValue;
const filterVal = filters[viewFeature];
if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number];
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features, activeFeature, dragValue, filters]);
const filterRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
if (activeFeature && dragValue) return dragValue;
@ -196,7 +187,7 @@ export default function App() {
};
fetchWithRetry<{ groups: FeatureGroup[] }>(
`${getApiBaseUrl()}/api/features`,
apiUrl('features'),
(json) => {
const flat: FeatureMeta[] = json.groups.flatMap((g) =>
g.features.map((f) => ({ ...f, group: g.name }))
@ -209,7 +200,7 @@ export default function App() {
);
fetchWithRetry<POICategoriesResponse>(
`${getApiBaseUrl()}/api/poi-categories`,
apiUrl('poi-categories'),
(json) => {
setPOICategoryGroups(json.groups);
poisLoaded = true;
@ -226,6 +217,8 @@ export default function App() {
[filters, features]
);
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
useEffect(() => {
if (!bounds) return;
@ -244,6 +237,23 @@ export default function App() {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterParam();
if (usePostcodeView) {
// Fetch postcode polygons for high zoom levels
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
if (viewFeature) {
params.set('fields', viewFeature);
} else {
params.set('fields', '');
}
const res = await fetch(apiUrl('postcodes', params), {
signal: abortControllerRef.current.signal,
});
const json: { features: PostcodeData[] } = await res.json();
setPostcodeData(json.features || []);
setRawData([]); // Clear hexagon data
} else {
// Fetch hexagons for lower zoom levels
const params = new URLSearchParams({
resolution: resolution.toString(),
bounds: boundsStr,
@ -254,15 +264,15 @@ export default function App() {
} else {
params.set('fields', '');
}
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
const res = await fetch(apiUrl('hexagons', params), {
signal: abortControllerRef.current.signal,
});
const json: ApiResponse = await res.json();
setRawData(json.features || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch data:', err);
setPostcodeData([]); // Clear postcode data
}
} catch (err) {
logNonAbortError('Failed to fetch data', err);
} finally {
setLoading(false);
}
@ -273,10 +283,57 @@ export default function App() {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds, filters, buildFilterParam, viewFeature]);
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView]);
const data = dragData ?? rawData;
// Compute actual min/max from visible data for the viewed feature
// Uses postcodeData when in postcode view, otherwise hexagon/drag data
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null;
// When actively dragging, only use dragData (not rawData which has old filters)
// If dragData hasn't loaded yet, return null to trigger fallback
if (activeFeature && !dragData) return null;
// Choose the appropriate data source based on zoom level
const sourceData = usePostcodeView ? postcodeData : data;
if (sourceData.length === 0) return null;
// Only use min_<feature> values since that's what hexagon coloring uses
let min = Infinity;
let max = -Infinity;
for (const item of sourceData) {
const val = item[`min_${viewFeature}`];
if (typeof val === 'number' && !isNaN(val)) {
min = Math.min(min, val);
max = Math.max(max, val);
}
}
if (min === Infinity || max === -Infinity) return null;
return [min, max];
}, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature]);
// Color range for the legend and hex coloring - uses actual data range when available
const colorRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
if (!meta) return null;
// For enum features: use [0, numValues-1]
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
return [0, meta.values.length - 1];
}
// Use actual data range when available (shows actual min/max on the map)
if (dataRange) return dataRange;
// During drag when data hasn't loaded yet, use dragValue as preview
if (activeFeature && dragValue) return dragValue;
// Fallback to full feature range
if (meta.min != null && meta.max != null) return [meta.min, meta.max];
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
useEffect(() => {
if (!bounds || selectedPOICategories.size === 0) {
setPois([]);
@ -300,15 +357,13 @@ export default function App() {
categories: categoriesStr,
bounds: boundsStr,
});
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
const res = await fetch(apiUrl('pois', params), {
signal: poiAbortControllerRef.current.signal,
});
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch POIs:', err);
}
logNonAbortError('Failed to fetch POIs', err);
}
}, DEBOUNCE_MS);
@ -396,16 +451,12 @@ export default function App() {
if (filtersStr) params.set('filters', filtersStr);
params.set('fields', name);
fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
fetch(apiUrl('hexagons', params), {
signal: dragAbortRef.current.signal,
})
.then((res) => res.json())
.then((json: ApiResponse) => setDragData(json.features || []))
.catch((err) => {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch drag data:', err);
}
});
.catch((err) => logNonAbortError('Failed to fetch drag data', err));
},
[filters, features, bounds, resolution]
);
@ -446,7 +497,7 @@ export default function App() {
if (fields) {
params.set('fields', fields.join(','));
}
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal });
const response = await fetch(apiUrl('hexagon-stats', params), { signal });
return (await response.json()) as HexagonStatsResponse;
},
[filters, features]
@ -466,7 +517,7 @@ export default function App() {
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
const response = await fetch(apiUrl('hexagon-properties', params));
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
@ -486,99 +537,48 @@ export default function App() {
);
const handleHexagonClick = useCallback(
(h3: string) => {
if (selectedHexagon?.h3 === h3) {
(id: string, isPostcode = false) => {
if (selectedHexagon?.id === id) {
setSelectedHexagon(null);
setProperties([]);
setAreaStats(null);
} else {
setSelectedHexagon({ h3, resolution });
const type = isPostcode ? 'postcode' : 'hexagon';
setSelectedHexagon({ id, type, resolution });
setPropertiesOffset(0);
setRightPaneTab('area');
if (isPostcode) {
// For postcodes, we don't have a stats API yet, so skip
setAreaStats(null);
setLoadingAreaStats(false);
} else {
setLoadingAreaStats(true);
fetchHexagonStats(h3, resolution)
fetchHexagonStats(id, resolution)
.then((stats) => setAreaStats(stats))
.catch((error) => {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch area stats:', error);
}
})
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
.finally(() => setLoadingAreaStats(false));
}
}
},
[selectedHexagon, resolution, fetchHexagonStats]
);
const handleHexagonHover = useCallback(
(h3: string | null) => {
const handleHexagonHover = useCallback((h3: string | null) => {
setHoveredHexagon(h3);
if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) {
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
if (hoverAbortRef.current) hoverAbortRef.current.abort();
setHoveredAreaStats(null);
setHoveredProperties(null);
setHoveredPropertiesTotal(0);
return;
}
if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current);
hoverDebounceRef.current = setTimeout(async () => {
if (hoverAbortRef.current) hoverAbortRef.current.abort();
hoverAbortRef.current = new AbortController();
const signal = hoverAbortRef.current.signal;
try {
if (rightPaneTab === 'area') {
setLoadingHoveredAreaStats(true);
const hoverFields = Object.keys(filters);
const stats = await fetchHexagonStats(
h3,
resolution,
signal,
hoverFields.length > 0 ? hoverFields : undefined
);
if (!signal.aborted) setHoveredAreaStats(stats);
} else if (rightPaneTab === 'properties') {
const params = new URLSearchParams({
h3,
resolution: resolution.toString(),
limit: '3',
offset: '0',
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.append('filters', filterStr);
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, {
signal,
});
const data: HexagonPropertiesResponse = await response.json();
if (!signal.aborted) {
setHoveredProperties(data.properties);
setHoveredPropertiesTotal(data.total);
}
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch hover data:', error);
}
} finally {
if (!signal.aborted) setLoadingHoveredAreaStats(false);
}
}, DEBOUNCE_MS);
},
[hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats]
);
}, []);
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon) {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}, [selectedHexagon, fetchHexagonProperties]);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon) {
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
@ -593,6 +593,8 @@ export default function App() {
<div className="h-screen w-screen">
<Map
data={data}
postcodeData={postcodeData}
usePostcodeView={usePostcodeView}
pois={pois}
onViewChange={handleViewChange}
viewFeature={viewFeature}
@ -657,6 +659,20 @@ export default function App() {
</div>
</div>
)}
<div
className={`flex ${leftPaneCollapsed ? 'w-10' : 'w-96'} bg-white dark:bg-navy-950 shadow-lg overflow-hidden`}
>
{leftPaneCollapsed ? (
<button
onClick={() => setLeftPaneCollapsed(false)}
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
title="Expand filters"
>
<ChevronIcon direction="right" className="w-5 h-5" />
<span className="text-xs font-medium writing-mode-vertical">Filters</span>
</button>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<Filters
features={features}
filters={filters}
@ -670,6 +686,8 @@ export default function App() {
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
itemCount={usePostcodeView ? postcodeData.length : data.length}
usePostcodeView={usePostcodeView}
pinnedFeature={pinnedFeature}
onTogglePin={handleTogglePin}
onCancelPin={handleCancelPin}
@ -678,10 +696,16 @@ export default function App() {
}}
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={() => setPendingInfoFeature(null)}
onCollapse={() => setLeftPaneCollapsed(true)}
/>
</div>
)}
</div>
<div className="flex-1 relative">
<Map
data={data}
postcodeData={postcodeData}
usePostcodeView={usePostcodeView}
pois={pois}
onViewChange={handleViewChange}
viewFeature={viewFeature}
@ -690,12 +714,15 @@ export default function App() {
viewSource={viewSource}
onCancelPin={handleCancelPin}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
selectedHexagonId={selectedHexagon?.id || null}
hoveredHexagonId={hoveredHexagon}
onHexagonClick={handleHexagonClick}
onHexagonHover={handleHexagonHover}
initialViewState={initialViewState}
theme={theme}
filters={filters}
searchedPostcode={searchedPostcode}
onPostcodeSearched={setSearchedPostcode}
/>
{loading && (
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
@ -704,72 +731,71 @@ export default function App() {
)}
<DataSources onNavigate={() => navigateTo('data-sources')} />
</div>
<div className="w-72 bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col">
<div
className={`${rightPaneCollapsed ? 'w-10' : 'w-72'} bg-white dark:bg-navy-950 shadow-lg z-10 flex flex-col`}
>
{rightPaneCollapsed ? (
<button
onClick={() => setRightPaneCollapsed(false)}
className="w-full h-full flex flex-col items-center justify-center gap-2 text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-warm-50 dark:hover:bg-navy-800"
title="Expand panel"
>
<ChevronIcon direction="left" className="w-5 h-5" />
<span className="text-xs font-medium writing-mode-vertical">
{rightPaneTab === 'area'
? 'Area'
: rightPaneTab === 'properties'
? 'Properties'
: 'POIs'}
</span>
</button>
) : (
<>
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'area'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
onClick={() => setRightPaneCollapsed(true)}
className="px-2 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 border-r border-warm-200 dark:border-navy-700"
title="Collapse panel"
>
<ChevronIcon direction="right" className="w-4 h-4" />
</button>
<TabButton
label="Area"
count={areaStats?.count}
isActive={rightPaneTab === 'area'}
onClick={() => setRightPaneTab('area')}
>
Area {areaStats ? `(${areaStats.count})` : ''}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
/>
<TabButton
label="Properties"
count={propertiesTotal > 0 ? propertiesTotal : undefined}
isActive={rightPaneTab === 'properties'}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-teal-500 font-semibold dark:text-warm-100'
: 'text-warm-600 dark:text-warm-400'
}`}
/>
<TabButton
label="POIs"
count={pois.length > 0 ? pois.length : undefined}
isActive={rightPaneTab === 'pois'}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
/>
</div>
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'area' ? (
<AreaPane
stats={hoverMode && hoveredAreaStats ? hoveredAreaStats : areaStats}
stats={areaStats}
globalFeatures={features}
loading={
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
? loadingHoveredAreaStats
: loadingAreaStats
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
postcodeData={
selectedHexagon?.type === 'postcode'
? postcodeData.find((d) => d.postcode === selectedHexagon.id) || null
: null
}
hexagonId={
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
? hoveredHexagon
: selectedHexagon?.h3 || null
}
isHoveredPreview={
!!(
hoverMode &&
hoveredAreaStats &&
hoveredHexagon &&
hoveredHexagon !== selectedHexagon?.h3
)
}
hoverMode={hoverMode}
onHoverModeChange={setHoverMode}
onViewProperties={handleViewPropertiesFromArea}
onClose={handleCloseProperties}
hexagonLocation={(() => {
const hexId =
hoverMode && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3
? hoveredHexagon
: selectedHexagon?.h3;
const hexId = selectedHexagon?.id;
const hex = hexId ? data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number')
return null;
@ -780,28 +806,19 @@ export default function App() {
};
})()}
filters={filters}
onNavigateToSource={(slug, featureName) => {
navigateTo('data-sources', slug, featureName);
}}
/>
) : rightPaneTab === 'properties' ? (
<PropertiesPane
properties={hoverMode && hoveredProperties ? hoveredProperties : properties}
total={hoverMode && hoveredProperties ? hoveredPropertiesTotal : propertiesTotal}
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={
hoverMode && hoveredProperties ? hoveredHexagon : selectedHexagon?.h3 || null
}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
onNavigateToSource={(slug) => navigateTo('data-sources', slug)}
isHoveredPreview={
!!(
hoverMode &&
hoveredProperties &&
hoveredHexagon &&
hoveredHexagon !== selectedHexagon?.h3
)
}
hoverMode={hoverMode}
onHoverModeChange={setHoverMode}
/>
) : (
<POIPane
@ -813,6 +830,8 @@ export default function App() {
/>
)}
</div>
</>
)}
</div>
</div>
)}

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>
<IconButton onClick={onClose} title="Close">
<CloseIcon />
</IconButton>
</div>
</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">
<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"
>
<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"
<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"
/>
</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>
)}
{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);
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('');
} else {
setError('Postcode not found');
}
} 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,97 +54,21 @@ 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>
: `Showing ${properties.length} of ${total} properties`
}
onClose={onClose}
onInfoClick={() => setShowInfo(true)}
/>
{showInfo && (
<InfoPopup
title="Property Data"
@ -171,15 +93,13 @@ export function PropertiesPane({
</p>
</InfoPopup>
)}
</div>
<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>
);
}

View file

@ -0,0 +1,81 @@
import { useRef, useEffect, useCallback } from 'react';
import { isAbortError, logNonAbortError } from '../lib/api';
const DEFAULT_DEBOUNCE_MS = 150;
interface UseDebouncedFetchOptions {
debounceMs?: number;
}
interface UseDebouncedFetchResult {
fetch: (url: string, onSuccess: (data: unknown) => void) => void;
cancel: () => void;
}
export function useDebouncedFetch(
options: UseDebouncedFetchOptions = {}
): UseDebouncedFetchResult {
const { debounceMs = DEFAULT_DEBOUNCE_MS } = options;
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const cancel = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const fetchFn = useCallback(
(url: string, onSuccess: (data: unknown) => void) => {
// Clear any pending debounce
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
// Abort any in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const res = await fetch(url, { signal: abortControllerRef.current.signal });
const json = await res.json();
onSuccess(json);
} catch (err) {
logNonAbortError(`Failed to fetch ${url}`, err);
}
}, debounceMs);
},
[debounceMs]
);
// Cleanup on unmount
useEffect(() => {
return () => {
cancel();
};
}, [cancel]);
return { fetch: fetchFn, cancel };
}
// Typed version with generic
export function useTypedDebouncedFetch<T>(
options: UseDebouncedFetchOptions = {}
): {
fetch: (url: string, onSuccess: (data: T) => void) => void;
cancel: () => void;
} {
const result = useDebouncedFetch(options);
return {
fetch: result.fetch as (url: string, onSuccess: (data: T) => void) => void,
cancel: result.cancel,
};
}

View file

@ -0,0 +1,27 @@
import { useState, useCallback } from 'react';
interface UseInfoPopupResult<T> {
item: T | null;
isOpen: boolean;
open: (item: T) => void;
close: () => void;
}
export function useInfoPopup<T>(): UseInfoPopupResult<T> {
const [item, setItem] = useState<T | null>(null);
const open = useCallback((newItem: T) => {
setItem(newItem);
}, []);
const close = useCallback(() => {
setItem(null);
}, []);
return {
item,
isOpen: item !== null,
open,
close,
};
}

View file

@ -0,0 +1,55 @@
import { useState, useMemo } from 'react';
interface UseSearchResult<T> {
query: string;
setQuery: (query: string) => void;
filtered: T[];
}
export function useSearch<T>(
items: T[],
getSearchableText: (item: T) => string
): UseSearchResult<T> {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
if (!query.trim()) return items;
const lower = query.toLowerCase();
return items.filter((item) => getSearchableText(item).toLowerCase().includes(lower));
}, [items, query, getSearchableText]);
return { query, setQuery, filtered };
}
// Variant for searching groups with nested items
export function useGroupSearch<G extends { name: string }, T>(
groups: G[],
getItems: (group: G) => T[],
getSearchableText: (item: T) => string,
createGroup: (group: G, filteredItems: T[]) => G
): { query: string; setQuery: (query: string) => void; filtered: G[] } {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
if (!query.trim()) return groups;
const lower = query.toLowerCase();
return groups
.map((group) => {
// If group name matches, return whole group
if (group.name.toLowerCase().includes(lower)) return group;
// Otherwise filter items
const items = getItems(group);
const matchingItems = items.filter((item) =>
getSearchableText(item).toLowerCase().includes(lower)
);
if (matchingItems.length === 0) return null;
return createGroup(group, matchingItems);
})
.filter((g): g is G => g !== null);
}, [groups, query, getItems, getSearchableText, createGroup]);
return { query, setQuery, filtered };
}

View file

@ -50,3 +50,9 @@ h3 {
opacity: 1;
transform: translateY(0);
}
/* Vertical text for collapsed pane labels */
.writing-mode-vertical {
writing-mode: vertical-rl;
text-orientation: mixed;
}

View file

@ -3,6 +3,25 @@ import type { FeatureMeta, FeatureFilters } from '../types';
const INITIAL_RETRY_MS = 1000;
const MAX_RETRY_MS = 10000;
// Error handling utilities
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
export function logNonAbortError(label: string, error: unknown): void {
if (!isAbortError(error)) {
console.error(`${label}:`, error);
}
}
// API URL helper
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
const base = getApiBaseUrl();
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
const query = params?.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`;
}
export async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,

View file

@ -0,0 +1,76 @@
import type { ViewState } from '../types';
// =============================================================================
// Map Bounds & Zoom
// =============================================================================
/** Geographic bounds constraining map panning [west, south, east, north] */
export const MAP_BOUNDS: [number, number, number, number] = [-12, 49, 4, 62];
/** Minimum zoom level (can't zoom out further) */
export const MAP_MIN_ZOOM = 5;
/** Maximum zoom level for tile fetching (map extrapolates beyond this) */
export const TILE_MAX_ZOOM = 15;
/** Initial map view state */
export const INITIAL_VIEW_STATE: ViewState = {
longitude: -1.5,
latitude: 53.5,
zoom: 6,
pitch: 0,
};
// =============================================================================
// Zoom Thresholds
// =============================================================================
/** Zoom level at which we switch from H3 hexagons to postcode polygons */
export const POSTCODE_ZOOM_THRESHOLD = 15;
/**
* Zoom to H3 resolution mapping thresholds.
* Returns the H3 resolution to use for a given zoom level.
*/
export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 7.5, resolution: 5 },
{ maxZoom: 9.5, resolution: 6 },
{ maxZoom: 10.5, resolution: 8 },
{ maxZoom: 12, resolution: 9 },
{ maxZoom: Infinity, resolution: 10 },
] as const;
// =============================================================================
// Color Gradients
// =============================================================================
/** Feature value gradient (green → yellow → red → purple) */
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
{ t: 0.33, color: [241, 196, 15] },
{ t: 0.66, color: [231, 76, 60] },
{ t: 1, color: [142, 68, 173] },
];
/** Property density gradient (teal → blue → purple) */
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] },
{ t: 0.5, color: [20, 140, 180] },
{ t: 1, color: [88, 28, 140] },
];
// =============================================================================
// External URLs
// =============================================================================
/** Protomaps font glyphs URL */
export const GLYPHS_URL = 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf';
/** Protomaps sprite base URL */
export const SPRITE_URL_BASE = 'https://protomaps.github.io/basemaps-assets/sprites/v4';
/** Twemoji CDN base URL */
export const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
/** OpenStreetMap attribution HTML */
export const OSM_ATTRIBUTION = '© <a href="https://openstreetmap.org">OpenStreetMap</a>';

View file

@ -0,0 +1,36 @@
import type { FeatureMeta } from '../types';
export interface FeatureGroup {
name: string;
features: FeatureMeta[];
}
export function groupFeaturesByCategory(features: FeatureMeta[]): FeatureGroup[] {
const groups: FeatureGroup[] = [];
const seen = new Map<string, FeatureMeta[]>();
for (const feature of features) {
const groupName = feature.group || 'Other';
let arr = seen.get(groupName);
if (!arr) {
arr = [];
seen.set(groupName, arr);
groups.push({ name: groupName, features: arr });
}
arr.push(feature);
}
return groups;
}
// Feature lookup utilities
export function getFeatureByName(
name: string,
features: FeatureMeta[]
): FeatureMeta | undefined {
return features.find((f) => f.name === name);
}
export function createFeatureMap(features: FeatureMeta[]): Map<string, FeatureMeta> {
return new Map(features.map((f) => [f.name, f]));
}

View file

@ -22,3 +22,27 @@ export function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString();
}
// Format number with optional decimals, used in PropertyCard
export function formatNumber(value: number | undefined, decimals = 0): string {
if (value === undefined) return '';
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
}
// Calculate weighted mean from histogram
export function calculateHistogramMean(histogram: {
min: number;
bin_width: number;
counts: number[];
}): number | undefined {
if (!histogram.counts.length) return undefined;
const totalCount = histogram.counts.reduce((a, b) => a + b, 0);
if (totalCount === 0) return undefined;
let weightedSum = 0;
for (let i = 0; i < histogram.counts.length; i++) {
const binCenter = histogram.min + (i + 0.5) * histogram.bin_width;
weightedSum += binCenter * histogram.counts[i];
}
return weightedSum / totalCount;
}

View file

@ -1,29 +1,47 @@
import type { ViewState, Bounds } from '../types';
import type { StyleSpecification } from 'maplibre-gl';
import { layers, namedFlavor } from '@protomaps/basemaps';
import {
GLYPHS_URL,
SPRITE_URL_BASE,
TILE_MAX_ZOOM,
OSM_ATTRIBUTION,
FEATURE_GRADIENT,
DENSITY_GRADIENT,
ZOOM_TO_RESOLUTION_THRESHOLDS,
TWEMOJI_BASE,
} from './consts';
// Self-hosted tile styles from server
export const MAP_STYLE_LIGHT = '/api/tiles/style.json?theme=light';
export const MAP_STYLE_DARK = '/api/tiles/style.json?theme=dark';
// Re-export constants for backwards compatibility
export { FEATURE_GRADIENT as GRADIENT, DENSITY_GRADIENT, POSTCODE_ZOOM_THRESHOLD } from './consts';
export const GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },
{ t: 0.33, color: [241, 196, 15] },
{ t: 0.66, color: [231, 76, 60] },
{ t: 1, color: [142, 68, 173] },
];
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [130, 234, 220] },
{ t: 0.5, color: [20, 140, 180] },
{ t: 1, color: [88, 28, 140] },
];
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
const flavor = namedFlavor(theme);
// Use absolute URL for tiles - required by MapLibre
const tileUrl = `${window.location.origin}/api/tiles/{z}/{x}/{y}`;
return {
version: 8,
glyphs: GLYPHS_URL,
sprite: `${SPRITE_URL_BASE}/${theme}`,
sources: {
protomaps: {
type: 'vector',
tiles: [tileUrl],
maxzoom: TILE_MAX_ZOOM,
attribution: OSM_ATTRIBUTION,
},
},
layers: layers('protomaps', flavor, { lang: 'en' }),
} as StyleSpecification;
}
export function normalizedToColor(t: number): [number, number, number] {
if (t <= 0) return GRADIENT[0].color;
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
if (t <= 0) return FEATURE_GRADIENT[0].color;
if (t >= 1) return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
for (let i = 0; i < GRADIENT.length - 1; i++) {
const lo = GRADIENT[i];
const hi = GRADIENT[i + 1];
for (let i = 0; i < FEATURE_GRADIENT.length - 1; i++) {
const lo = FEATURE_GRADIENT[i];
const hi = FEATURE_GRADIENT[i + 1];
if (t >= lo.t && t <= hi.t) {
const frac = (t - lo.t) / (hi.t - lo.t);
return [
@ -33,7 +51,7 @@ export function normalizedToColor(t: number): [number, number, number] {
];
}
}
return GRADIENT[GRADIENT.length - 1].color;
return FEATURE_GRADIENT[FEATURE_GRADIENT.length - 1].color;
}
export function countToColor(t: number): [number, number, number] {
@ -55,17 +73,11 @@ export function countToColor(t: number): [number, number, number] {
return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color;
}
/** Zoom threshold at which we switch from hexagons to postcode polygons */
export const POSTCODE_ZOOM_THRESHOLD = 15;
export function zoomToResolution(zoom: number): number {
if (zoom < 6) return 5;
if (zoom < 7) return 6;
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
if (zoom < 15) return 11;
return 12;
for (const { maxZoom, resolution } of ZOOM_TO_RESOLUTION_THRESHOLDS) {
if (zoom < maxZoom) return resolution;
}
return ZOOM_TO_RESOLUTION_THRESHOLDS[ZOOM_TO_RESOLUTION_THRESHOLDS.length - 1].resolution;
}
export function getBoundsFromViewState(
@ -103,8 +115,6 @@ export function getBoundsFromViewState(
return { south, west, north, east };
}
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
export function emojiToTwemojiUrl(emoji: string): string {
const codePoint = emoji.codePointAt(0);
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`;

View file

@ -0,0 +1,38 @@
import type { Property } from '../types';
// Field aliases: maps human-readable names to snake_case names
// The server may return either depending on source
const FIELD_ALIASES: Record<string, string[]> = {
price: ['Last known price', 'latest_price'],
pricePerSqm: ['Price per sqm', 'price_per_sqm'],
floorArea: ['Total floor area (sqm)', 'total_floor_area'],
rooms: ['Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms'],
constructionAge: ['Approximate construction age', 'construction_age_band'],
councilTax: ['Council tax (£/yr)'],
councilTaxD: ['Council tax Band D (£/yr)'],
};
export function getPropertyNumber(
property: Property,
field: keyof typeof FIELD_ALIASES
): number | undefined {
const keys = FIELD_ALIASES[field];
if (!keys) return undefined;
for (const key of keys) {
const v = property[key];
if (v !== undefined && v !== null && typeof v === 'number') {
return v;
}
}
return undefined;
}
// Generic getter for any field names (for dynamic lookups)
export 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;
}

View file

@ -29,6 +29,13 @@ export interface HexagonData {
[key: string]: string | number | null;
}
export interface PostcodeData {
postcode: string;
vertices: [number, number][];
count: number;
[key: string]: string | number | [number, number][] | null;
}
export interface Bounds {
south: number;
west: number;