Update UI

This commit is contained in:
Andras Schmelczer 2026-02-01 11:07:58 +00:00
parent 2ac37ece97
commit 5f311233e4
10 changed files with 663 additions and 408 deletions

View file

@ -44,7 +44,7 @@ const DATA_SOURCES = [
{
name: 'TfL Journey Times',
origin: 'Transport for London',
use: 'Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King\'s Cross, etc.) via public transport and cycling.',
use: "Journey time calculations from postcodes to central London destinations (Bank, Waterloo, King's Cross, etc.) via public transport and cycling.",
url: 'https://api-portal.tfl.gov.uk/',
license: 'Powered by TfL Open Data',
},
@ -92,15 +92,12 @@ export default function DataSourcesPage() {
<div className="max-w-5xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-warm-900 mb-2">Data Sources</h1>
<p className="text-warm-600 mb-6">
This application combines {DATA_SOURCES.length} open datasets covering property prices, energy
performance, transport, demographics, crime, environment, and more.
This application combines {DATA_SOURCES.length} open datasets covering property prices,
energy performance, transport, demographics, crime, environment, and more.
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCES.map((source) => (
<div
key={source.name}
className="bg-white rounded-lg border border-warm-200 p-5"
>
<div key={source.name} className="bg-white rounded-lg border border-warm-200 p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900">{source.name}</h2>
<span className="shrink-0 text-xs bg-warm-100 text-warm-600 px-2 py-1 rounded">
@ -125,11 +122,11 @@ export default function DataSourcesPage() {
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">Attribution</h2>
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
Attribution
</h2>
<ul className="space-y-1.5 text-sm">
<li>
Contains HM Land Registry data &copy; Crown copyright and database right 2025.
</li>
<li>Contains HM Land Registry data &copy; Crown copyright and database right 2025.</li>
<li>
Contains public sector information licensed under the{' '}
<a
@ -142,9 +139,7 @@ export default function DataSourcesPage() {
</a>
.
</li>
<li>
Contains OS data &copy; Crown copyright and database rights 2025.
</li>
<li>Contains OS data &copy; Crown copyright and database rights 2025.</li>
<li>Powered by TfL Open Data.</li>
<li>
Contains data from{' '}

View file

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useState, useRef, useEffect, useMemo } from 'react';
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import type { FeatureMeta, FeatureFilters } from '../types';
@ -21,6 +21,83 @@ interface FiltersProps {
onCancelPin: () => void;
}
function FilterDropdown({
availableFeatures,
onAddFilter,
}: {
availableFeatures: FeatureMeta[];
onAddFilter: (name: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [open]);
const grouped = useMemo(() => {
const groups: { name: string; features: FeatureMeta[] }[] = [];
const seen = new Map<string, FeatureMeta[]>();
for (const f of availableFeatures) {
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;
}, [availableFeatures]);
return (
<div ref={ref} className="relative">
<button
className="w-full p-2 border rounded text-sm bg-white text-left text-warm-500 hover:border-warm-400"
onClick={() => setOpen(!open)}
>
+ Add filter...
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white border rounded shadow-lg max-h-80 overflow-y-auto">
{grouped.map((group) => (
<div key={group.name}>
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 sticky top-0">
{group.name}
</div>
{group.features.map((f) => (
<button
key={f.name}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-teal-50 hover:text-teal-700"
onClick={() => {
onAddFilter(f.name);
setOpen(false);
}}
>
{f.name}
</button>
))}
</div>
))}
</div>
)}
</div>
);
}
function formatValue(value: number): string {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
@ -54,22 +131,7 @@ export default memo(function Filters({
{/* Add filter dropdown */}
{availableFeatures.length > 0 && (
<select
className="w-full p-2 border rounded text-sm bg-white"
value=""
onChange={(e) => {
if (e.target.value) onAddFilter(e.target.value);
}}
>
<option value="" disabled>
+ Add filter...
</option>
{availableFeatures.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
<FilterDropdown availableFeatures={availableFeatures} onAddFilter={onAddFilter} />
)}
{/* Active filters */}
@ -149,20 +211,17 @@ export default memo(function Filters({
className={`p-0.5 rounded ${isPinned ? 'text-teal-600' : 'text-warm-400 hover:text-warm-700'}`}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth={2}>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill={isPinned ? '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>
</button>
{isPinned && (
<button
onClick={onCancelPin}
className="text-warm-400 hover:text-warm-700 text-xs px-0.5"
title="Clear color view"
>
x
</button>
)}
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-warm-400 hover:text-warm-700 text-sm px-1"
@ -184,7 +243,6 @@ export default memo(function Filters({
</div>
);
})}
</div>
);
});

View file

@ -182,28 +182,29 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
const ctaRef = useFadeInRef();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto bg-warm-50 relative"
>
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 relative">
<HexCanvas scrollProgress={scrollProgress} />
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-20 pb-24">
<div ref={heroRef} className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 rounded-2xl p-8 -mx-2"
>
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
</p>
<h1 className="text-5xl font-extrabold text-navy-950 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood<br />
in England &amp; Wales.<br />
Every neighbourhood
<br />
in England &amp; Wales.
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
</h1>
<p className="text-xl text-warm-600 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime
threshold you&apos;ll accept. Narrowit shows you every area that
qualifies &mdash; instantly.
Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll
accept. Narrowit shows you every area that qualifies &mdash; instantly.
</p>
<div className="flex items-center gap-4">
<button
@ -225,19 +226,22 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 border border-warm-200/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">The old way</h3>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
The old way
</h3>
<p className="text-warm-700 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on
another site. Look up commute times. Realise it&apos;s too
expensive. Start over. Repeat 40 times.
Pick a postcode. Google the schools. Check crime stats on another site. Look up
commute times. Realise it&apos;s too expensive. Start over. Repeat 40 times.
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">With Narrowit</h3>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
With Narrowit
</h3>
<p className="text-warm-700 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is
a place worth looking at. Drill into any one to see
individual properties, prices, and energy ratings.
Tell the map what you need. Every hexagon that lights up is a place worth
looking at. Drill into any one to see individual properties, prices, and energy
ratings.
</p>
</div>
</div>
@ -308,9 +312,7 @@ export default function HomePage({ onOpenDashboard }: { onOpenDashboard: () => v
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 mb-3">
Ready to narrow it down?
</h2>
<h2 className="text-3xl font-bold text-navy-950 mb-3">Ready to narrow it down?</h2>
<p className="text-warm-500 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go.
</p>

View file

@ -3,26 +3,24 @@ 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, PolygonLayer } from '@deck.gl/layers';
import { IconLayer, TextLayer } 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, PostcodeData } from '../types';
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
interface MapProps {
data: HexagonData[];
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
viewFeature: string | null;
viewRange: [number, number] | null;
colorRange: [number, number] | null;
filterRange: [number, number] | null;
viewSource: 'drag' | 'eye' | null;
onCancelPin: () => void;
features: FeatureMeta[];
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
initialViewState?: ViewState;
postcodeData: PostcodeData[];
selectedPostcode: string | null;
onPostcodeClick: (postcode: string) => void;
}
// Twemoji CDN base URL
@ -44,7 +42,7 @@ const INITIAL_VIEW: ViewState = {
pitch: 0,
};
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
// Gradient stops for normalized [0,1] values
const GRADIENT: { t: number; color: [number, number, number] }[] = [
@ -78,7 +76,8 @@ function zoomToResolution(zoom: number): number {
if (zoom < 9.5) return 8;
if (zoom < 11) return 9;
if (zoom < 13) return 10;
return 11;
if (zoom < 15) return 11;
return 12;
}
function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
@ -195,10 +194,7 @@ function PostcodeSearch({
);
return (
<form
onSubmit={handleSubmit}
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
>
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
<div className="flex shadow-lg rounded overflow-hidden">
<input
type="text"
@ -219,9 +215,7 @@ function PostcodeSearch({
</button>
</div>
{error && (
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">
{error}
</span>
<span className="text-xs text-red-600 bg-white/90 rounded px-2 py-0.5 shadow">{error}</span>
)}
</form>
);
@ -255,7 +249,13 @@ function MapLegend({
className="text-warm-400 hover:text-warm-700 ml-2"
title="Clear color view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@ -281,16 +281,14 @@ export default memo(function Map({
pois,
onViewChange,
viewFeature,
viewRange,
colorRange,
filterRange,
viewSource,
onCancelPin,
features,
selectedHexagonId,
onHexagonClick,
initialViewState,
postcodeData,
selectedPostcode,
onPostcodeClick,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW);
@ -328,7 +326,13 @@ export default memo(function Map({
east: Math.ceil(raw.east / QUANT) * QUANT,
};
onViewChange({ resolution, bounds, zoom: viewState.zoom, latitude: viewState.latitude, longitude: viewState.longitude });
onViewChange({
resolution,
bounds,
zoom: viewState.zoom,
latitude: viewState.latitude,
longitude: viewState.longitude,
});
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
@ -349,6 +353,12 @@ export default memo(function Map({
map.setPaintProperty(layer.id, 'text-halo-width', 2);
map.setPaintProperty(layer.id, 'text-color', '#222');
}
// Make water more prominent
for (const layer of map.getStyle().layers || []) {
if (layer.id === 'water' || layer.id.startsWith('water')) {
map.setPaintProperty(layer.id, 'fill-color', '#6baed6');
}
}
map.setLayoutProperty('building', 'visibility', 'none');
map.setLayoutProperty('building-top', 'visibility', 'none');
},
@ -399,8 +409,10 @@ export default memo(function Map({
// Use refs for values that change during drag so layers aren't recreated
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const viewRangeRef = useRef(viewRange);
viewRangeRef.current = viewRange;
const colorRangeRef = useRef(colorRange);
colorRangeRef.current = colorRange;
const filterRangeRef = useRef(filterRange);
filterRangeRef.current = filterRange;
const colorFeatureMetaRef = useRef(colorFeatureMeta);
colorFeatureMetaRef.current = colorFeatureMeta;
const countRangeRef = useRef(countRange);
@ -408,32 +420,14 @@ export default memo(function Map({
const selectedHexagonIdRef = useRef(selectedHexagonId);
selectedHexagonIdRef.current = selectedHexagonId;
// Postcode refs
const selectedPostcodeRef = useRef(selectedPostcode);
selectedPostcodeRef.current = selectedPostcode;
// Stable click handler using ref
const onHexagonClickRef = useRef(onHexagonClick);
onHexagonClickRef.current = onHexagonClick;
const handleHexagonClick = useCallback(
(info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
},
[]
);
const onPostcodeClickRef = useRef(onPostcodeClick);
onPostcodeClickRef.current = onPostcodeClick;
const handlePostcodeClick = useCallback(
(info: PickingInfo<PostcodeData>) => {
if (info.object && 'postcode' in info.object) {
onPostcodeClickRef.current(info.object.postcode);
}
},
[]
);
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClickRef.current(info.object.h3);
}
}, []);
// Stable hover handler using ref
const handlePoiHoverRef = useRef(handlePoiHover);
@ -443,7 +437,7 @@ export default memo(function Map({
}, []);
// Derive a trigger value from color-affecting state — avoids useEffect+setState double-render
const colorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}`;
// Hexagon layer — only recreated when data or color trigger changes
const hexLayer = useMemo(
@ -454,29 +448,36 @@ export default memo(function Map({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const vr = viewRangeRef.current;
const clr = colorRangeRef.current;
const fr = filterRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) {
if (vf && clr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0];
const max = vr[1];
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
// Gray out hexagons outside range
if (maxVal < min || minVal > max) {
return [180, 180, 180, 60] as [number, number, number, number];
// Gray out hexagons outside filter range
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 = max - min;
// Color using full slider range
const range = clr[1] - clr[0];
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - min) / range;
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 = countRangeRef.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];
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
number,
number,
number,
number,
];
},
getLineColor: (d) =>
(d.h3 === selectedHexagonIdRef.current ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [
@ -498,84 +499,11 @@ export default memo(function Map({
highPrecision: true,
onClick: handleHexagonClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: "waterway_label",
beforeId: 'waterway_label',
}),
[data, colorTrigger, handleHexagonClick]
);
// Postcode count range
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;
// Postcode color trigger
const postcodeColorTrigger = `${viewFeature}|${viewRange?.[0]}|${viewRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${selectedPostcode}`;
// Postcode polygon layer
const postcodeLayer = useMemo(
() =>
new PolygonLayer<PostcodeData>({
id: 'postcode-polygons',
data: postcodeData,
getPolygon: (d) => d.polygon,
getFillColor: (d) => {
const vf = viewFeatureRef.current;
const vr = viewRangeRef.current;
const cfm = colorFeatureMetaRef.current;
if (vf && vr && cfm) {
const val = d[`min_${vf}`];
if (val == null) return [128, 128, 128, 80] as [number, number, number, number];
const min = vr[0];
const max = vr[1];
const minVal = d[`min_${vf}`] as number;
const maxVal = d[`max_${vf}`] as number;
if (maxVal < min || minVal > max) {
return [180, 180, 180, 60] as [number, number, number, number];
}
const range = max - min;
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
const t = ((val as number) - min) / 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) =>
(d.postcode === selectedPostcodeRef.current
? [255, 255, 255, 255]
: [160, 160, 160, 200]) as [number, number, number, number],
getLineWidth: (d) => (d.postcode === selectedPostcodeRef.current ? 2 : 1),
lineWidthUnits: 'pixels' as const,
stroked: true,
filled: true,
pickable: true,
updateTriggers: {
getFillColor: [postcodeColorTrigger],
getLineColor: [postcodeColorTrigger],
getLineWidth: [postcodeColorTrigger],
},
onClick: handlePostcodeClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: "waterway_label",
}),
[postcodeData, postcodeColorTrigger, handlePostcodeClick]
);
// POI layer — independent, only recreated when POI data changes
const poiLayer = useMemo(
() =>
@ -597,9 +525,41 @@ export default memo(function Map({
[pois, stablePoiHover]
);
// Postcode labels on high-res hexagons (resolution 11+, zoom >= 13)
const postcodeData = useMemo(
() => data.filter((d) => d.postcode && d.lat != null && d.lon != null),
[data]
);
const showPostcodes = viewState.zoom >= 13;
const postcodeLayer = useMemo(
() =>
showPostcodes
? new TextLayer<HexagonData>({
id: 'postcode-labels',
data: postcodeData,
getPosition: (d) => [d.lon as number, d.lat as number],
getText: (d) => d.postcode as string,
getSize: 11,
getColor: [30, 30, 30, 220],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
outlineWidth: 2,
outlineColor: [255, 255, 255, 200],
billboard: false,
sizeUnits: 'pixels',
sizeMinPixels: 10,
sizeMaxPixels: 14,
})
: null,
[postcodeData, showPostcodes]
);
const layers = useMemo(
() => (postcodeData.length > 0 ? [postcodeLayer, poiLayer] : [hexLayer, poiLayer]),
[postcodeData.length, postcodeLayer, hexLayer, poiLayer]
() => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])],
[hexLayer, poiLayer, postcodeLayer]
);
// Tooltip uses refs to avoid being a layer dependency
@ -611,15 +571,9 @@ export default memo(function Map({
({ object }: { object?: any }) => {
if (!object) return null;
// Handle both hexagon and postcode objects
const isPostcode = 'postcode' in object;
const isHexagon = 'h3' in object;
if (!isPostcode && !isHexagon) return null;
if (!('h3' in object)) return null;
const lines: string[] = [];
if (isPostcode) {
lines.push(`<strong>${object.postcode}</strong>`);
}
lines.push(`<div>${(object.count as number).toLocaleString()} properties</div>`);
for (const f of featuresRef.current) {
@ -664,10 +618,10 @@ export default memo(function Map({
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
<PostcodeSearch onFlyTo={handleFlyTo} />
{viewFeature && viewRange && colorFeatureMeta && (
{viewFeature && colorRange && colorFeatureMeta && (
<MapLegend
featureLabel={colorFeatureMeta.name}
range={viewRange}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
/>

View file

@ -1,21 +1,22 @@
import { useState, useRef, useEffect } from 'react';
import { Label } from './ui/label';
import { useState, useRef, useEffect, useCallback } from 'react';
import type { POICategoryGroup } from '../types';
interface POIPaneProps {
categories: string[];
groups: POICategoryGroup[];
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
}
export default function POIPane({
categories,
groups,
selectedCategories,
onCategoriesChange,
poiCount,
}: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
@ -29,6 +30,8 @@ export default function POIPane({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
@ -40,17 +43,55 @@ export default function POIPane({
};
const selectAll = () => {
onCategoriesChange(new Set(categories));
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
onCategoriesChange(new Set());
};
const filteredCategories = categories.filter((cat) =>
cat.toLowerCase().includes(searchTerm.toLowerCase())
const toggleGroup = useCallback(
(groupName: string) => {
const group = groups.find((g) => g.name === groupName);
if (!group) return;
const allSelected = group.categories.every((c) => selectedCategories.has(c));
const newSet = new Set(selectedCategories);
if (allSelected) {
group.categories.forEach((c) => newSet.delete(c));
} else {
group.categories.forEach((c) => newSet.add(c));
}
onCategoriesChange(newSet);
},
[groups, selectedCategories, onCategoriesChange]
);
const toggleCollapse = (groupName: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupName)) {
next.delete(groupName);
} else {
next.add(groupName);
}
return next;
});
};
const lowerSearch = searchTerm.toLowerCase();
// Filter groups and categories by search term
const filteredGroups = groups
.map((group) => {
if (!searchTerm) return group;
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
if (groupMatches) return group;
if (matchingCats.length === 0) return null;
return { ...group, categories: matchingCats };
})
.filter(Boolean) as POICategoryGroup[];
const selectedCount = selectedCategories.size;
return (
@ -58,7 +99,6 @@ export default function POIPane({
<h2 className="text-xl font-bold">Points of Interest</h2>
<div className="space-y-2" ref={dropdownRef}>
<Label>Categories</Label>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-warm-300 rounded hover:border-warm-400 bg-white"
@ -66,7 +106,7 @@ export default function POIPane({
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === categories.length
: selectedCount === allCategories.length
? 'All categories'
: `${selectedCount} selected`}
</span>
@ -101,20 +141,69 @@ export default function POIPane({
/>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{filteredCategories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-warm-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) =>
selectedCategories.has(c)
).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = collapsedGroups.has(group.name) && !searchTerm;
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 border-y border-warm-100">
<button
onClick={() => toggleCollapse(group.name)}
className="p-0.5 text-warm-400 hover:text-warm-600"
>
<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>
</button>
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={allInGroupSelected}
ref={(el) => {
if (el) el.indeterminate = someInGroupSelected;
}}
onChange={() => toggleGroup(group.name)}
className="rounded"
/>
<span className="text-xs font-semibold text-warm-700">{group.name}</span>
</label>
<span className="text-xs text-warm-400">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed &&
group.categories.map((category) => (
<label
key={category}
className="flex items-center gap-2 px-3 pl-8 py-1.5 hover:bg-warm-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedCategories.has(category)}
onChange={() => toggleCategory(category)}
className="rounded"
/>
<span className="text-sm flex-1">{category}</span>
</label>
))}
</div>
);
})}
</div>
</div>
)}

View file

@ -6,7 +6,6 @@ interface PropertiesPaneProps {
total: number;
loading: boolean;
hexagonId: string | null;
postcodeId?: string | null;
onLoadMore: () => void;
onClose: () => void;
}
@ -18,7 +17,6 @@ export function PropertiesPane({
total,
loading,
hexagonId,
postcodeId,
onLoadMore,
onClose,
}: PropertiesPaneProps) {
@ -38,11 +36,10 @@ export function PropertiesPane({
});
}, [properties, sortBy]);
const selectionId = hexagonId || postcodeId;
if (!selectionId) {
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-warm-500">
Click a hexagon or postcode to view properties
Click a hexagon to view properties
</div>
);
}
@ -52,9 +49,7 @@ export function PropertiesPane({
{/* Header */}
<div className="p-4 border-b border-warm-200">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">
{postcodeId ? `Properties in ${postcodeId}` : 'Properties in Hexagon'}
</h2>
<h2 className="text-lg font-semibold">Properties in Hexagon</h2>
<button
onClick={onClose}
className="text-warm-500 hover:text-warm-700 text-2xl leading-none"
@ -111,9 +106,8 @@ function formatDuration(d: string): string {
return d;
}
function formatAge(value: number): string {
// construction_age_band is a midpoint year, e.g. 1935
if (value >= 1000) return `~${Math.round(value)}`;
function formatAge(value: number, approximate = true): string {
if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`;
return Math.round(value).toString();
}
@ -136,7 +130,11 @@ function PropertyCard({ property }: { property: Property }) {
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');
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)', 'number_habitable_rooms');
const rooms = getNum(
property,
'Rooms (including bedrooms & bathrooms)',
'number_habitable_rooms'
);
const age = getNum(property, 'Approximate construction age', 'construction_age_band');
return (
@ -150,10 +148,7 @@ function PropertyCard({ property }: { property: Property }) {
<div className="mt-2 text-lg font-bold text-teal-700">
£{fmt(price)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600">
{' '}
(£{fmt(pricePerSqm)}/m²)
</span>
<span className="text-sm font-normal text-warm-600"> (£{fmt(pricePerSqm)}/m²)</span>
)}
</div>
)}
@ -187,7 +182,7 @@ function PropertyCard({ property }: { property: Property }) {
)}
{age !== undefined && (
<div>
<span className="text-warm-500">Built:</span> {formatAge(age)}
<span className="text-warm-500">Built:</span> {formatAge(age, property.is_construction_date_approximate ?? true)}
</div>
)}
{property.current_energy_rating && (
@ -197,8 +192,7 @@ function PropertyCard({ property }: { property: Property }) {
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500">EPC potential:</span>{' '}
{property.potential_energy_rating}
<span className="text-warm-500">EPC potential:</span> {property.potential_energy_rating}
</div>
)}
</div>