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

@ -1,6 +1,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import Map from './components/Map';
import Filters from './components/Filters';
import POIPane from './components/POIPane';
import { PropertiesPane } from './components/PropertiesPane';
import DataSources from './components/DataSources';
import type {
FeatureMeta,
FeatureFilters,
@ -10,7 +13,9 @@ import type {
ApiResponse,
POI,
POIResponse,
POICategoriesMap,
POICategoriesResponse,
Property,
HexagonPropertiesResponse,
} from './types';
const DEBOUNCE_MS = 150;
@ -44,6 +49,7 @@ export default function App() {
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [filters, setFilters] = useState<FeatureFilters>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [rawData, setRawData] = useState<HexagonData[]>([]);
const [resolution, setResolution] = useState<number>(8);
const [bounds, setBounds] = useState<Bounds | null>(null);
@ -54,35 +60,41 @@ export default function App() {
// POI state
const [pois, setPois] = useState<POI[]>([]);
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
const [poiCategories, setPOICategories] = useState<string[]>([]);
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const poiAbortControllerRef = useRef<AbortController | null>(null);
// Hexagon properties state
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
const [propertiesOffset, setPropertiesOffset] = useState(0);
const [loadingProperties, setLoadingProperties] = useState(false);
const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties'>('pois');
// Derive enabled features from filter keys
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
// Fetch feature metadata + POI categories on mount
useEffect(() => {
fetch(`${getApiBaseUrl()}/api/features`)
.then((res) => res.json())
.then((json: { features: FeatureMeta[] }) => {
setFeatures(json.features);
// Initialize filters with full range for each feature
const initial: FeatureFilters = {};
for (const f of json.features) {
initial[f.name] = [f.min, f.max];
}
setFilters(initial);
// Start with no filters (empty object)
})
.catch((err) => console.error('Failed to fetch features:', err));
fetch(`${getApiBaseUrl()}/api/poi-categories`)
.then((res) => res.json())
.then((json: { categories: POICategoriesMap }) => {
.then((json: POICategoriesResponse) => {
setPOICategories(json.categories);
})
.catch((err) => console.error('Failed to fetch POI categories:', err));
}, []);
// Debounced fetch when resolution/bounds change (no filter params sent)
// Debounced fetch when resolution/bounds/filters change
useEffect(() => {
if (!bounds) return;
@ -103,6 +115,14 @@ export default function App() {
resolution: resolution.toString(),
bounds: boundsStr,
});
// Build filters param: name:min:max,...
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filtersStr = filterEntries
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
.join(',');
params.set('filters', filtersStr);
}
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
signal: abortControllerRef.current.signal,
});
@ -122,36 +142,19 @@ export default function App() {
clearTimeout(debounceRef.current);
}
};
}, [resolution, bounds]);
}, [resolution, bounds, filters]);
// Client-side filtering
// Client-side filtering: only during drag preview
const data = useMemo(() => {
if (features.length === 0) return rawData;
if (!activeFeature || !dragValue) return rawData;
return rawData.filter((hex) => {
if (activeFeature) {
// Only apply the active feature's filter
const range = filters[activeFeature];
if (!range) return true;
const minVal = hex[`min_${activeFeature}`];
const maxVal = hex[`max_${activeFeature}`];
if (minVal == null || maxVal == null) return true;
return (minVal as number) <= range[1] && (maxVal as number) >= range[0];
}
// Apply ALL filters as intersection
for (const f of features) {
const range = filters[f.name];
if (!range) continue;
// Skip features where filter is at full range
if (range[0] === f.min && range[1] === f.max) continue;
const minVal = hex[`min_${f.name}`];
const maxVal = hex[`max_${f.name}`];
if (minVal == null || maxVal == null) continue;
if ((minVal as number) > range[1] || (maxVal as number) < range[0]) return false;
}
return true;
const minVal = hex[`min_${activeFeature}`];
const maxVal = hex[`max_${activeFeature}`];
if (minVal == null || maxVal == null) return false;
return (minVal as number) <= dragValue[1] && (maxVal as number) >= dragValue[0];
});
}, [rawData, filters, activeFeature, features]);
}, [rawData, activeFeature, dragValue]);
// Fetch POIs when bounds or selected categories change
useEffect(() => {
@ -181,7 +184,7 @@ export default function App() {
signal: poiAbortControllerRef.current.signal,
});
const json: POIResponse = await res.json();
setPois(json.features || []);
setPois(json.pois || []);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Failed to fetch POIs:', err);
@ -205,18 +208,123 @@ export default function App() {
[]
);
const handleAddFilter = useCallback(
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
setFilters((prev) => ({ ...prev, [name]: [meta.min, meta.max] }));
},
[features]
);
const handleRemoveFilter = useCallback((name: string) => {
setFilters((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}, []);
const handleDragStart = useCallback(
(name: string) => {
setActiveFeature(name);
setDragValue(filters[name] || null);
},
[filters]
);
const handleDragChange = useCallback((value: [number, number]) => {
setDragValue(value);
}, []);
const handleDragEnd = useCallback(() => {
if (activeFeature && dragValue) {
setFilters((prev) => ({ ...prev, [activeFeature]: dragValue }));
}
setActiveFeature(null);
setDragValue(null);
}, [activeFeature, dragValue]);
const fetchHexagonProperties = useCallback(
async (h3: string, res: number, offset = 0) => {
setLoadingProperties(true);
try {
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
// Add current filters
const filterEntries = Object.entries(filters);
if (filterEntries.length > 0) {
const filterStr = filterEntries
.map(([name, [min, max]]) => `${name}:${min}:${max}`)
.join(',');
params.append('filters', filterStr);
}
const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`);
const data: HexagonPropertiesResponse = await response.json();
if (offset === 0) {
setProperties(data.properties);
} else {
setProperties((prev) => [...prev, ...data.properties]);
}
setPropertiesTotal(data.total);
setPropertiesOffset(offset + data.properties.length);
} catch (err) {
console.error('Failed to fetch properties:', err);
} finally {
setLoadingProperties(false);
}
},
[filters]
);
const handleHexagonClick = useCallback(
(h3: string) => {
if (selectedHexagon?.h3 === h3) {
// Deselect if clicking same hexagon
setSelectedHexagon(null);
setProperties([]);
} else {
setSelectedHexagon({ h3, resolution });
setPropertiesOffset(0);
setRightPaneTab('properties'); // Auto-switch to properties tab
fetchHexagonProperties(h3, resolution, 0);
}
},
[selectedHexagon, resolution, fetchHexagonProperties]
);
const handleLoadMoreProperties = useCallback(() => {
if (selectedHexagon) {
fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset);
}
}, [selectedHexagon, propertiesOffset, fetchHexagonProperties]);
const handleCloseProperties = useCallback(() => {
setSelectedHexagon(null);
setProperties([]);
}, []);
return (
<div className="h-screen flex">
<Filters
features={features}
filters={filters}
activeFeature={activeFeature}
onFiltersChange={setFilters}
onActiveFeatureChange={setActiveFeature}
dragValue={dragValue}
enabledFeatures={enabledFeatures}
onAddFilter={handleAddFilter}
onRemoveFilter={handleRemoveFilter}
onDragStart={handleDragStart}
onDragChange={handleDragChange}
onDragEnd={handleDragEnd}
zoom={zoom}
poiCategories={poiCategories}
selectedPOICategories={selectedPOICategories}
onPOICategoriesChange={setSelectedPOICategories}
/>
<div className="flex-1 relative">
<Map
@ -224,11 +332,61 @@ export default function App() {
pois={pois}
onViewChange={handleViewChange}
activeFeature={activeFeature}
dragValue={dragValue}
features={features}
selectedHexagonId={selectedHexagon?.h3 || null}
onHexagonClick={handleHexagonClick}
/>
{loading && (
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
)}
<DataSources />
</div>
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
{/* Tab headers */}
<div className="flex border-b border-gray-200">
<button
className={`flex-1 p-3 ${
rightPaneTab === 'pois'
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
}`}
onClick={() => setRightPaneTab('pois')}
>
POIs {pois.length > 0 && `(${pois.length})`}
</button>
<button
className={`flex-1 p-3 ${
rightPaneTab === 'properties'
? 'border-b-2 border-blue-500 font-semibold'
: 'text-gray-600'
}`}
onClick={() => setRightPaneTab('properties')}
>
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{rightPaneTab === 'pois' ? (
<POIPane
categories={poiCategories}
selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length}
/>
) : (
<PropertiesPane
properties={properties}
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.h3 || null}
onLoadMore={handleLoadMoreProperties}
onClose={handleCloseProperties}
/>
)}
</div>
</div>
</div>
);

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

View file

@ -45,16 +45,38 @@ export interface POI {
category: string;
lat: number;
lng: number;
emoji: string;
}
export interface POIResponse {
features: POI[];
pois: POI[];
}
export interface POICategoryInfo {
emoji: string;
label: string;
count: number;
export interface POICategoriesResponse {
categories: string[];
}
export type POICategoriesMap = Record<string, POICategoryInfo>;
export interface Property {
// String fields
address?: string;
postcode?: string;
property_type?: string;
built_form?: string;
current_energy_rating?: string;
potential_energy_rating?: string;
// Numeric fields
lat: number;
lon: number;
// All other numeric features (dynamic, including construction_age_band)
[key: string]: string | number | undefined;
}
export interface HexagonPropertiesResponse {
properties: Property[];
total: number;
limit: number;
offset: number;
truncated: boolean;
}