Improve map

This commit is contained in:
Andras Schmelczer 2026-01-31 12:49:56 +00:00
parent 400f733956
commit 51967fa880
7 changed files with 794 additions and 353 deletions

View file

@ -0,0 +1,49 @@
export default function DataSources() {
const sources = [
{
name: 'Land Registry',
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
},
{
name: 'EPC',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
},
{
name: 'ArcGIS Postcodes',
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
},
{
name: 'OpenStreetMap',
url: 'https://www.openstreetmap.org/copyright',
},
{
name: 'IoD 2025',
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
},
{
name: 'TfL API',
url: 'https://api-portal.tfl.gov.uk/',
},
];
return (
<div className="absolute bottom-2 right-2 bg-white/90 backdrop-blur-sm px-3 py-2 rounded shadow-lg text-xs max-w-xs">
<div className="font-semibold mb-1 text-gray-700">Data Sources</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{sources.map((source, idx) => (
<span key={source.name}>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{source.name}
</a>
{idx < sources.length - 1 && <span className="text-gray-400"></span>}
</span>
))}
</div>
</div>
);
}

View file

@ -1,18 +1,19 @@
import { useState, useRef, useEffect } from 'react';
import { Slider } from './ui/slider';
import { Label } from './ui/label';
import type { FeatureMeta, FeatureFilters, POICategoriesMap } from '../types';
import type { FeatureMeta, FeatureFilters } from '../types';
interface FiltersProps {
features: FeatureMeta[];
filters: FeatureFilters;
activeFeature: string | null;
onFiltersChange: (filters: FeatureFilters) => void;
onActiveFeatureChange: (feature: string | null) => void;
dragValue: [number, number] | null;
enabledFeatures: Set<string>;
onAddFilter: (name: string) => void;
onRemoveFilter: (name: string) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
zoom: number;
poiCategories: POICategoriesMap;
selectedPOICategories: Set<string>;
onPOICategoriesChange: (categories: Set<string>) => void;
}
function formatValue(value: number): string {
@ -26,47 +27,17 @@ export default function Filters({
features,
filters,
activeFeature,
onFiltersChange,
onActiveFeatureChange,
dragValue,
enabledFeatures,
onAddFilter,
onRemoveFilter,
onDragStart,
onDragChange,
onDragEnd,
zoom,
poiCategories,
selectedPOICategories,
onPOICategoriesChange,
}: FiltersProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const toggleCategory = (key: string) => {
const newSet = new Set(selectedPOICategories);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
onPOICategoriesChange(newSet);
};
const selectAll = () => {
onPOICategoriesChange(new Set(Object.keys(poiCategories)));
};
const selectNone = () => {
onPOICategoriesChange(new Set());
};
const categoryKeys = Object.keys(poiCategories);
const selectedCount = selectedPOICategories.size;
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
return (
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto max-h-screen">
@ -74,9 +45,31 @@ export default function Filters({
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
{features.map((feature) => {
const range = filters[feature.name] || [feature.min, feature.max];
{/* 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.label}
</option>
))}
</select>
)}
{/* Active filters */}
{enabledFeatureList.map((feature) => {
const isActive = activeFeature === feature.name;
const displayValue =
isActive && dragValue ? dragValue : filters[feature.name] || [feature.min, feature.max];
const step = (feature.max - feature.min) / 100;
return (
@ -84,19 +77,26 @@ export default function Filters({
key={feature.name}
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-blue-400 bg-blue-50' : ''}`}
>
<Label className="text-xs">
{feature.label}: {formatValue(range[0])} - {formatValue(range[1])}
</Label>
<div className="flex items-center justify-between">
<Label className="text-xs">
{feature.label}: {formatValue(displayValue[0])} - {formatValue(displayValue[1])}
</Label>
<button
onClick={() => onRemoveFilter(feature.name)}
className="text-slate-400 hover:text-slate-700 text-sm px-1"
title="Remove filter"
>
x
</button>
</div>
<Slider
min={feature.min}
max={feature.max}
step={step}
value={[range[0], range[1]]}
onValueChange={([min, max]) => {
onFiltersChange({ ...filters, [feature.name]: [min, max] });
}}
onPointerDown={() => onActiveFeatureChange(feature.name)}
onPointerUp={() => onActiveFeatureChange(null)}
value={[displayValue[0], displayValue[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
/>
</div>
);
@ -104,82 +104,33 @@ export default function Filters({
<div className="p-3 bg-slate-100 rounded text-xs">
<div className="mb-2 font-medium">Color Scale</div>
<div
className="h-4 rounded"
style={{
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
></div>
<div className="flex justify-between mt-1">
<span>Low</span>
<span>High</span>
</div>
</div>
<div className="space-y-2" ref={dropdownRef}>
<Label>Points of Interest</Label>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-slate-300 rounded hover:border-slate-400 bg-white"
>
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === categoryKeys.length
? '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>
</button>
{dropdownOpen && (
<div className="border border-slate-300 rounded shadow-lg bg-white">
<div className="flex gap-2 px-3 py-2 border-b border-slate-200">
<button
onClick={selectAll}
className="text-xs text-blue-600 hover:text-blue-800"
>
All
</button>
<span className="text-xs text-slate-300">|</span>
<button
onClick={selectNone}
className="text-xs text-blue-600 hover:text-blue-800"
>
None
</button>
{activeFeature ? (
<>
<div
className="h-4 rounded"
style={{
background:
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
}}
></div>
<div className="flex justify-between mt-1">
<span>Low</span>
<span>High</span>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{categoryKeys.map((key) => {
const { emoji, label, count } = poiCategories[key];
return (
<label
key={key}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedPOICategories.has(key)}
onChange={() => toggleCategory(key)}
className="rounded"
/>
<span className="text-sm flex-1">
{emoji} {label}
</span>
<span className="text-xs text-slate-400">{count.toLocaleString()}</span>
</label>
);
})}
</>
) : (
<>
<div
className="h-4 rounded"
style={{
background: 'linear-gradient(to right, rgb(209, 226, 243), rgb(33, 102, 172))',
}}
></div>
<div className="flex justify-between mt-1">
<span>Few</span>
<span>Many</span>
</div>
</div>
</>
)}
</div>
</div>

View file

@ -13,169 +13,24 @@ interface MapProps {
pois: POI[];
onViewChange: (params: ViewChangeParams) => void;
activeFeature: string | null;
dragValue: [number, number] | null;
features: FeatureMeta[];
selectedHexagonId: string | null;
onHexagonClick: (h3: string) => void;
}
// Twemoji CDN base URL
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
// Map category to Twemoji codepoint (emoji unicode -> hex)
const POI_EMOJI_CODES: Record<string, string> = {
// Education
school: '1f3eb', // 🏫
preschool: '1f476', // 👶
college_university: '1f393', // 🎓
library: '1f4da', // 📚
// Healthcare
doctor: '1f3e5', // 🏥
dentist: '1f9b7', // 🦷
pharmacy: '1f48a', // 💊
hospital: '1f3e5',
public_health_clinic: '1f3e5',
veterinary: '1f43e', // 🐾
nursing_home: '1f3e0', // 🏠
social_facility: '1f91d', // 🤝
// Transport
train_station: '1f689', // 🚉
bus_station: '1f68c', // 🚌
bus_stop: '1f68f', // 🚏
metro_station: '1f687', // 🚇
light_rail_station: '1f687',
tram_stop: '1f68a', // 🚊
ferry_terminal: '26f4', // ⛴
airport: '2708', // ✈
// Parks & Leisure
park: '1f333', // 🌳
national_park: '1f3de', // 🏞
nature_reserve: '1f33f', // 🌿
dog_park: '1f415', // 🐕
playground: '1f3a0', // 🎠
garden: '1f33a', // 🌺
sports_centre: '1f3c3', // 🏃
swimming_pool: '1f3ca', // 🏊
gym: '1f4aa', // 💪
golf_course: '26f3', // ⛳
marina: '26f5', // ⛵
// Emergency
police_department: '1f694', // 🚔
fire_department: '1f692', // 🚒
// Supermarkets & Grocery
supermarket: '1f6d2', // 🛒
grocery_store: '1f6d2',
convenience_store: '1f3ea', // 🏪
bakery: '1f35e', // 🍞
butcher: '1f969', // 🥩
greengrocer: '1f966', // 🥦
deli: '1f9c0', // 🧀
// Shopping
department_store: '1f3ec', // 🏬
clothing_store: '1f455', // 👕
shoe_store: '1f45f', // 👟
electronics_store: '1f4f1', // 📱
hardware_store: '1f527', // 🔧
furniture_store: '1fa91', // 🪑
bookshop: '1f4d6', // 📖
newsagent: '1f4f0', // 📰
charity_shop: '1f49c', // 💜
shopping_centre: '1f6cd', // 🛍
optician: '1f453', // 👓
off_licence: '1f37a', // 🍺
// Food & Drink
restaurant: '1f37d', // 🍽
cafe: '2615', // ☕
pub: '1f37b', // 🍻
bar: '1f378', // 🍸
fast_food: '1f354', // 🍔
food_court: '1f372', // 🍲
ice_cream: '1f366', // 🍦
beer_garden: '1f37a', // 🍺
// Personal Care
hairdresser: '1f487', // 💇
beauty_salon: '1f484', // 💄
laundry: '1f9fa', // 🧺
dry_cleaning: '1f455', // 👕
// Finance
bank: '1f3e6', // 🏦
atm: '1f4b3', // 💳
bureau_de_change: '1f4b1', // 💱
// Entertainment & Culture
cinema: '1f3ac', // 🎬
theatre: '1f3ad', // 🎭
nightclub: '1f483', // 💃
community_centre: '1f3db', // 🏛
arts_centre: '1f3a8', // 🎨
museum: '1f3db', // 🏛
gallery: '1f5bc', // 🖼
attraction: '2b50', // ⭐
zoo: '1f418', // 🐘
theme_park: '1f3a2', // 🎢
viewpoint: '1f301', // 🌁
// Accommodation
hotel: '1f3e8', // 🏨
hostel: '1f6cf', // 🛏
guest_house: '1f3e1', // 🏡
campsite: '26fa', // ⛺
caravan_site: '1f699', // 🚙
// Religion
place_of_worship: '1f6d0', // 🛐
// Government & Public
town_hall: '1f3db', // 🏛
courthouse: '2696', // ⚖
post_office: '1f4ee', // 📮
prison: '1f513', // 🔓
public_toilets: '1f6bb', // 🚻
// Automotive
petrol_station: '26fd', // ⛽
ev_charging: '1f50c', // 🔌
car_dealer: '1f697', // 🚗
car_repair: '1f527', // 🔧
parking: '1f17f', // 🅿
bicycle_parking: '1f6b2', // 🚲
// Recycling & Waste
recycling: '267b', // ♻
waste_disposal: '1f5d1', // 🗑
};
function getPOIIconUrl(category: string): string {
const code = POI_EMOJI_CODES[category] || '1f4cd'; // 📍 default
return `${TWEMOJI_BASE}${code}.png`;
// Convert emoji to Twemoji URL
function emojiToTwemojiUrl(emoji: string): string {
// Convert emoji to Unicode codepoint hex
const codePoint = emoji.codePointAt(0);
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin
const hex = codePoint.toString(16);
return `${TWEMOJI_BASE}${hex}.png`;
}
// Tooltip emojis (these render fine in HTML)
const TOOLTIP_EMOJIS: Record<string, string> = {
school: '🏫', preschool: '👶', college_university: '🎓', library: '📚',
doctor: '🏥', dentist: '🦷', pharmacy: '💊', hospital: '🏥',
public_health_clinic: '🏥', veterinary: '🐾', nursing_home: '🏠', social_facility: '🤝',
train_station: '🚉', bus_station: '🚌', bus_stop: '🚏', metro_station: '🚇',
light_rail_station: '🚇', tram_stop: '🚊', ferry_terminal: '⛴️', airport: '✈️',
park: '🌳', national_park: '🏞️', nature_reserve: '🌿', dog_park: '🐕',
playground: '🎠', garden: '🌺', sports_centre: '🏃', swimming_pool: '🏊',
gym: '💪', golf_course: '⛳', marina: '⛵',
police_department: '🚔', fire_department: '🚒',
supermarket: '🛒', grocery_store: '🛒', convenience_store: '🏪',
bakery: '🍞', butcher: '🥩', greengrocer: '🥦', deli: '🧀',
department_store: '🏬', clothing_store: '👕', shoe_store: '👟',
electronics_store: '📱', hardware_store: '🔧', furniture_store: '🪑',
bookshop: '📖', newsagent: '📰', charity_shop: '💜', shopping_centre: '🛍️',
optician: '👓', off_licence: '🍺',
restaurant: '🍽️', cafe: '☕', pub: '🍻', bar: '🍸',
fast_food: '🍔', food_court: '🍲', ice_cream: '🍦', beer_garden: '🍺',
hairdresser: '💇', beauty_salon: '💄', laundry: '🧺', dry_cleaning: '👕',
bank: '🏦', atm: '💳', bureau_de_change: '💱',
cinema: '🎬', theatre: '🎭', nightclub: '💃', community_centre: '🏛️',
arts_centre: '🎨', museum: '🏛️', gallery: '🖼️', attraction: '⭐',
zoo: '🐘', theme_park: '🎢', viewpoint: '🌁',
hotel: '🏨', hostel: '🛏️', guest_house: '🏡', campsite: '⛺', caravan_site: '🚙',
place_of_worship: '🛐',
town_hall: '🏛️', courthouse: '⚖️', post_office: '📮', prison: '🔓', public_toilets: '🚻',
petrol_station: '⛽', ev_charging: '🔌', car_dealer: '🚗', car_repair: '🔧',
parking: '🅿️', bicycle_parking: '🚲',
recycling: '♻️', waste_disposal: '🗑️',
};
function getTooltipEmoji(category: string): string {
return TOOLTIP_EMOJIS[category] || '📍';
}
const INITIAL_VIEW: ViewState = {
longitude: -1.5,
@ -280,7 +135,16 @@ function DeckOverlay({
return null;
}
export default function Map({ data, pois, onViewChange, activeFeature, features }: MapProps) {
// Sequential blue scale for count-based coloring
function countToColor(t: number): [number, number, number] {
// light blue (209, 226, 243) -> dark blue (33, 102, 172)
const r = Math.round(209 + (33 - 209) * t);
const g = Math.round(226 + (102 - 226) * t);
const b = Math.round(243 + (172 - 243) * t);
return [r, g, b];
}
export default function Map({ data, pois, onViewChange, activeFeature, dragValue, features, selectedHexagonId, onHexagonClick }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
@ -347,9 +211,31 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
}
}, []);
// Determine which feature to use for coloring
const colorFeatureName = activeFeature || (features.length > 0 ? features[0].name : null);
const colorFeatureMeta = features.find((f) => f.name === colorFeatureName) || null;
// Compute count range for count-based coloring
const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1 };
let min = Infinity;
let max = -Infinity;
for (const d of data) {
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 };
}, [data]);
// Determine color mode
const colorFeatureMeta = activeFeature ? features.find((f) => f.name === activeFeature) || null : null;
const handleHexagonClick = useCallback(
(info: PickingInfo<HexagonData>) => {
if (info.object && 'h3' in info.object) {
onHexagonClick(info.object.h3);
}
},
[onHexagonClick]
);
const layers = useMemo(
() => [
@ -358,21 +244,33 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
data,
getHexagon: (d) => d.h3,
getFillColor: (d) => {
if (!colorFeatureName || !colorFeatureMeta) return [128, 128, 128] as [number, number, number];
const val = d[`min_${colorFeatureName}`];
if (val == null) return [128, 128, 128] as [number, number, number];
const range = colorFeatureMeta.max - colorFeatureMeta.min;
if (range === 0) return GRADIENT[0].color;
const t = ((val as number) - colorFeatureMeta.min) / range;
return normalizedToColor(t);
if (activeFeature && dragValue && colorFeatureMeta) {
// Drag mode: color by feature value using gradient
const val = d[`min_${activeFeature}`];
if (val == null) return [128, 128, 128] as [number, number, number];
const range = dragValue[1] - dragValue[0];
if (range === 0) return GRADIENT[0].color;
const t = ((val as number) - dragValue[0]) / range;
return normalizedToColor(Math.max(0, Math.min(1, t)));
}
// Normal mode: color by count using blue scale
const c = d.count as number;
const t = (c - countRange.min) / (countRange.max - countRange.min);
return countToColor(Math.max(0, Math.min(1, t)));
},
getLineColor: (d) => (d.h3 === selectedHexagonId ? [255, 255, 255, 255] : [0, 0, 0, 0]) as [number, number, number, number],
getLineWidth: (d) => (d.h3 === selectedHexagonId ? 2 : 0),
lineWidthUnits: 'pixels',
updateTriggers: {
getFillColor: [colorFeatureName, colorFeatureMeta],
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
getLineColor: [selectedHexagonId],
getLineWidth: [selectedHexagonId],
},
extruded: false,
pickable: true,
opacity: 0.5,
highPrecision: true,
onClick: handleHexagonClick,
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
beforeId: LABEL_LAYER_ID,
}),
@ -381,7 +279,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
data: pois,
getPosition: (d) => [d.lng, d.lat],
getIcon: (d) => ({
url: getPOIIconUrl(d.category),
url: emojiToTwemojiUrl(d.emoji),
width: 72,
height: 72,
}),
@ -392,7 +290,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
onHover: handlePoiHover,
}),
],
[data, pois, handlePoiHover, colorFeatureName, colorFeatureMeta]
[data, pois, handlePoiHover, handleHexagonClick, activeFeature, dragValue, countRange, colorFeatureMeta, selectedHexagonId]
);
const getTooltip = useCallback(
@ -409,7 +307,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
if (minVal != null && maxVal != null) {
const minStr = typeof minVal === 'number' ? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(minVal);
const maxStr = typeof maxVal === 'number' ? maxVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(maxVal);
const highlight = f.name === colorFeatureName ? 'font-weight: bold;' : '';
const highlight = f.name === activeFeature ? 'font-weight: bold;' : '';
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
}
}
@ -423,7 +321,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
},
};
},
[features, colorFeatureName]
[features, activeFeature]
);
return (
@ -434,6 +332,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
onLoad={handleMapLoad as never}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
</MapGL>
@ -447,10 +346,8 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
zIndex: 9999,
}}
>
<strong>
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
</strong>
<div className="text-gray-500 text-xs">{popupInfo.category.replace(/_/g, ' ')}</div>
<strong>{popupInfo.name}</strong>
<div className="text-gray-500 text-xs">{popupInfo.category}</div>
</div>
)}
</div>

View file

@ -0,0 +1,140 @@
import { useState, useRef, useEffect } from 'react';
import { Label } from './ui/label';
interface POIPaneProps {
categories: string[];
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
}
export default function POIPane({
categories,
selectedCategories,
onCategoriesChange,
poiCount,
}: POIPaneProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
onCategoriesChange(newSet);
};
const selectAll = () => {
onCategoriesChange(new Set(categories));
};
const selectNone = () => {
onCategoriesChange(new Set());
};
const filteredCategories = categories.filter((cat) =>
cat.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedCount = selectedCategories.size;
return (
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto max-h-screen">
<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-slate-300 rounded hover:border-slate-400 bg-white"
>
<span className="truncate text-left">
{selectedCount === 0
? 'Select categories...'
: selectedCount === categories.length
? '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>
</button>
{dropdownOpen && (
<div className="border border-slate-300 rounded shadow-lg bg-white">
<div className="flex gap-2 px-3 py-2 border-b border-slate-200">
<button onClick={selectAll} className="text-xs text-blue-600 hover:text-blue-800">
All
</button>
<span className="text-xs text-slate-300">|</span>
<button onClick={selectNone} className="text-xs text-blue-600 hover:text-blue-800">
None
</button>
</div>
<div className="px-3 py-2 border-b border-slate-200">
<input
type="text"
placeholder="Search categories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-2 py-1 text-sm border border-slate-300 rounded"
/>
</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-slate-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>
{selectedCount > 0 && (
<div className="p-3 bg-blue-50 rounded text-sm">
<div className="font-medium text-blue-900">
{poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible
</div>
<div className="text-xs text-blue-700 mt-1">
{selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected
</div>
</div>
)}
<div className="p-3 bg-slate-100 rounded text-xs text-slate-600">
<p>Select categories to display POIs on the map.</p>
<p className="mt-2">Zoom in for better visibility of individual locations.</p>
</div>
</div>
);
}

View file

@ -0,0 +1,224 @@
import React, { useMemo, useState } from 'react';
import { Property } from '../types';
interface PropertiesPaneProps {
properties: Property[];
total: number;
loading: boolean;
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
}
type SortBy = 'price' | 'size' | 'energy';
export function PropertiesPane({
properties,
total,
loading,
hexagonId,
onLoadMore,
onClose,
}: PropertiesPaneProps) {
const [sortBy, setSortBy] = useState<SortBy>('price');
// Sort properties
const sortedProperties = useMemo(() => {
return [...properties].sort((a, b) => {
switch (sortBy) {
case 'price':
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
case 'size':
return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0);
case 'energy':
return (a.current_energy_rating || 'Z').localeCompare(
b.current_energy_rating || 'Z'
);
}
});
}, [properties, sortBy]);
if (!hexagonId) {
return (
<div className="flex items-center justify-center h-full text-gray-500">
Click a hexagon to view properties
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">Properties in Hexagon</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
>
×
</button>
</div>
<p className="text-sm text-gray-600">
Showing {properties.length} of {total} properties
</p>
</div>
{/* Sort controls */}
<div className="p-2 border-b border-gray-200">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
className="w-full p-2 border border-gray-300 rounded text-sm"
>
<option value="price">Price (High to Low)</option>
<option value="size">Size (Large to Small)</option>
<option value="energy">Energy Rating (Best to Worst)</option>
</select>
</div>
{/* Properties list */}
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<div className="p-4">Loading...</div>
) : (
<>
{sortedProperties.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-blue-600 hover:bg-blue-50 disabled:opacity-50"
>
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
</button>
)}
</>
)}
</div>
</div>
);
}
// Property card component showing all fields
function PropertyCard({ property }: { property: Property }) {
const formatNumber = (value: number | undefined, decimals = 0): string => {
if (value === undefined) return '';
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
};
return (
<div className="p-4 border-b border-gray-100 hover:bg-gray-50">
{/* Address */}
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
<div className="text-sm text-gray-600">{property.postcode}</div>
{/* Price */}
{property.latest_price && (
<div className="mt-2 text-lg font-bold text-green-700">
£{formatNumber(property.latest_price as number)}
{property.price_per_sqm && (
<span className="text-sm font-normal text-gray-600">
{' '}
(£{formatNumber(property.price_per_sqm as number)}/m²)
</span>
)}
</div>
)}
{/* Property details grid */}
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
{property.property_type && (
<div>
<span className="text-gray-600">Type:</span> {property.property_type}
</div>
)}
{property.built_form && (
<div>
<span className="text-gray-600">Form:</span> {property.built_form}
</div>
)}
{property.total_floor_area && (
<div>
<span className="text-gray-600">Area:</span> {formatNumber(property.total_floor_area as number)}m²
</div>
)}
{property.number_habitable_rooms && (
<div>
<span className="text-gray-600">Rooms:</span>{' '}
{formatNumber(property.number_habitable_rooms as number)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-gray-600">Energy:</span> {property.current_energy_rating}
</div>
)}
{property.potential_energy_rating && (
<div>
<span className="text-gray-600">Potential:</span> {property.potential_energy_rating}
</div>
)}
{property.construction_age_band !== undefined && (
<div>
<span className="text-gray-600">Built (age):</span> {formatNumber(property.construction_age_band as number)}
</div>
)}
{/* Journey times */}
{property.public_transport_easy_minutes && (
<div>
<span className="text-gray-600">PT (easy):</span>{' '}
{formatNumber(property.public_transport_easy_minutes as number)}min
</div>
)}
{property.public_transport_quick_minutes && (
<div>
<span className="text-gray-600">PT (quick):</span>{' '}
{formatNumber(property.public_transport_quick_minutes as number)}min
</div>
)}
{property.cycling_minutes && (
<div>
<span className="text-gray-600">Cycling:</span>{' '}
{formatNumber(property.cycling_minutes as number)}min
</div>
)}
{/* Deprivation scores */}
{property.income_score !== undefined && (
<div>
<span className="text-gray-600">Income:</span>{' '}
{formatNumber(property.income_score as number, 1)}
</div>
)}
{property.employment_score !== undefined && (
<div>
<span className="text-gray-600">Employment:</span>{' '}
{formatNumber(property.employment_score as number, 1)}
</div>
)}
{property.education_score !== undefined && (
<div>
<span className="text-gray-600">Education:</span>{' '}
{formatNumber(property.education_score as number, 1)}
</div>
)}
{property.health_score !== undefined && (
<div>
<span className="text-gray-600">Health:</span>{' '}
{formatNumber(property.health_score as number, 1)}
</div>
)}
{property.crime_score !== undefined && (
<div>
<span className="text-gray-600">Crime:</span>{' '}
{formatNumber(property.crime_score as number, 1)}
</div>
)}
</div>
</div>
);
}