Improve map
This commit is contained in:
parent
400f733956
commit
51967fa880
7 changed files with 794 additions and 353 deletions
49
frontend/src/components/DataSources.tsx
Normal file
49
frontend/src/components/DataSources.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
140
frontend/src/components/POIPane.tsx
Normal file
140
frontend/src/components/POIPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
frontend/src/components/PropertiesPane.tsx
Normal file
224
frontend/src/components/PropertiesPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue