Display pois
This commit is contained in:
parent
c157c2d5ec
commit
433fca64ad
6 changed files with 412 additions and 10 deletions
|
|
@ -8,6 +8,9 @@ import type {
|
|||
HexagonData,
|
||||
ViewChangeParams,
|
||||
ApiResponse,
|
||||
POI,
|
||||
POIResponse,
|
||||
POICategoryGroup,
|
||||
} from './types';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
|
@ -47,6 +50,12 @@ export default function App() {
|
|||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// POI state
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<POICategoryGroup>>(new Set());
|
||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Debounced fetch when dependencies change
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
|
@ -95,6 +104,49 @@ export default function App() {
|
|||
};
|
||||
}, [filters, resolution, bounds]);
|
||||
|
||||
// Fetch POIs when bounds or selected categories change
|
||||
useEffect(() => {
|
||||
if (!bounds || selectedPOICategories.size === 0) {
|
||||
setPois([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (poiDebounceRef.current) {
|
||||
clearTimeout(poiDebounceRef.current);
|
||||
}
|
||||
|
||||
poiDebounceRef.current = setTimeout(async () => {
|
||||
if (poiAbortControllerRef.current) {
|
||||
poiAbortControllerRef.current.abort();
|
||||
}
|
||||
poiAbortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const categoriesStr = Array.from(selectedPOICategories).join(',');
|
||||
const params = new URLSearchParams({
|
||||
categories: categoriesStr,
|
||||
bounds: boundsStr,
|
||||
});
|
||||
const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, {
|
||||
signal: poiAbortControllerRef.current.signal,
|
||||
});
|
||||
const json: POIResponse = await res.json();
|
||||
setPois(json.features || []);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch POIs:', err);
|
||||
}
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (poiDebounceRef.current) {
|
||||
clearTimeout(poiDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [bounds, selectedPOICategories]);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
({ resolution: newRes, bounds: newBounds, zoom: newZoom }: ViewChangeParams) => {
|
||||
setResolution(newRes);
|
||||
|
|
@ -106,9 +158,15 @@ export default function App() {
|
|||
|
||||
return (
|
||||
<div className="h-screen flex">
|
||||
<Filters filters={filters} onChange={setFilters} zoom={zoom} />
|
||||
<Filters
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
zoom={zoom}
|
||||
selectedPOICategories={selectedPOICategories}
|
||||
onPOICategoriesChange={setSelectedPOICategories}
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Map data={data} onViewChange={handleViewChange} />
|
||||
<Map data={data} pois={pois} onViewChange={handleViewChange} />
|
||||
{loading && (
|
||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,47 @@
|
|||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants';
|
||||
import type { Filters as FiltersType } from '../types';
|
||||
import type { Filters as FiltersType, POICategoryGroup } from '../types';
|
||||
import { POI_CATEGORY_GROUPS } from '../types';
|
||||
|
||||
interface FiltersProps {
|
||||
filters: FiltersType;
|
||||
onChange: (filters: FiltersType) => void;
|
||||
zoom: number;
|
||||
selectedPOICategories: Set<POICategoryGroup>;
|
||||
onPOICategoriesChange: (categories: Set<POICategoryGroup>) => void;
|
||||
}
|
||||
|
||||
export default function Filters({ filters, onChange, zoom }: FiltersProps) {
|
||||
const POI_LABELS: Record<POICategoryGroup, string> = {
|
||||
schools: '🏫 Schools',
|
||||
healthcare: '🏥 Healthcare',
|
||||
transport: '🚉 Transport',
|
||||
parks: '🌳 Parks',
|
||||
emergency: '🚨 Emergency',
|
||||
supermarkets: '🛒 Supermarkets',
|
||||
};
|
||||
|
||||
export default function Filters({
|
||||
filters,
|
||||
onChange,
|
||||
zoom,
|
||||
selectedPOICategories,
|
||||
onPOICategoriesChange,
|
||||
}: FiltersProps) {
|
||||
const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value });
|
||||
|
||||
const togglePOICategory = (category: POICategoryGroup) => {
|
||||
const newSet = new Set(selectedPOICategories);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
onPOICategoriesChange(newSet);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-72 p-4 bg-white shadow-lg space-y-6">
|
||||
<div className="w-72 p-4 bg-white shadow-lg space-y-6 overflow-y-auto max-h-screen">
|
||||
<h1 className="text-xl font-bold">UK Property Prices</h1>
|
||||
|
||||
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||
|
|
@ -69,6 +97,23 @@ export default function Filters({ filters, onChange, zoom }: FiltersProps) {
|
|||
<span>£800k+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Points of Interest</Label>
|
||||
<div className="space-y-1">
|
||||
{POI_CATEGORY_GROUPS.map((category) => (
|
||||
<label key={category} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPOICategories.has(category)}
|
||||
onChange={() => togglePOICategory(category)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{POI_LABELS[category]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,89 @@ import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
|||
import { Map as MapGL } from 'react-map-gl/maplibre';
|
||||
import DeckGL from '@deck.gl/react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
import { IconLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds } from '../types';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI } from '../types';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => 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> = {
|
||||
// Schools
|
||||
elementary_school: '1f3eb', // 🏫
|
||||
school: '1f3eb',
|
||||
high_school: '1f393', // 🎓
|
||||
preschool: '1f476', // 👶
|
||||
college_university: '1f393',
|
||||
private_school: '1f3eb',
|
||||
// Healthcare
|
||||
doctor: '1f3e5', // 🏥
|
||||
dentist: '1f9b7', // 🦷
|
||||
pharmacy: '1f48a', // 💊
|
||||
hospital: '1f3e5',
|
||||
public_health_clinic: '1f3e5',
|
||||
// Transport
|
||||
train_station: '1f689', // 🚉
|
||||
bus_station: '1f68c', // 🚌
|
||||
metro_station: '1f687', // 🚇
|
||||
light_rail_and_subway_stations: '1f687',
|
||||
// Parks
|
||||
park: '1f333', // 🌳
|
||||
national_park: '1f3de', // 🏞
|
||||
dog_park: '1f415', // 🐕
|
||||
// Emergency
|
||||
police_department: '1f694', // 🚔
|
||||
fire_department: '1f692', // 🚒
|
||||
// Supermarkets
|
||||
supermarket: '1f6d2', // 🛒
|
||||
grocery_store: '1f6d2',
|
||||
convenience_store: '1f3ea', // 🏪
|
||||
};
|
||||
|
||||
function getPOIIconUrl(category: string): string {
|
||||
const code = POI_EMOJI_CODES[category] || '1f4cd'; // 📍 default
|
||||
return `${TWEMOJI_BASE}${code}.png`;
|
||||
}
|
||||
|
||||
// Tooltip emojis (these render fine in HTML)
|
||||
const TOOLTIP_EMOJIS: Record<string, string> = {
|
||||
elementary_school: '🏫',
|
||||
school: '🏫',
|
||||
high_school: '🎓',
|
||||
preschool: '👶',
|
||||
college_university: '🎓',
|
||||
private_school: '🏫',
|
||||
doctor: '👨⚕️',
|
||||
dentist: '🦷',
|
||||
pharmacy: '💊',
|
||||
hospital: '🏥',
|
||||
public_health_clinic: '🏥',
|
||||
train_station: '🚉',
|
||||
bus_station: '🚌',
|
||||
metro_station: '🚇',
|
||||
light_rail_and_subway_stations: '🚇',
|
||||
park: '🌳',
|
||||
national_park: '🏞️',
|
||||
dog_park: '🐕',
|
||||
police_department: '🚔',
|
||||
fire_department: '🚒',
|
||||
supermarket: '🛒',
|
||||
grocery_store: '🛒',
|
||||
convenience_store: '🏪',
|
||||
};
|
||||
|
||||
function getTooltipEmoji(category: string): string {
|
||||
return TOOLTIP_EMOJIS[category] || '📍';
|
||||
}
|
||||
|
||||
const INITIAL_VIEW: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
|
|
@ -118,7 +193,7 @@ interface Dimensions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
export default function Map({ data, onViewChange }: MapProps) {
|
||||
export default function Map({ data, pois, onViewChange }: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
|
@ -154,6 +229,27 @@ export default function Map({ data, onViewChange }: MapProps) {
|
|||
setViewState(newViewState);
|
||||
}, []);
|
||||
|
||||
// Popup state for POI hover (using screen coordinates)
|
||||
const [popupInfo, setPopupInfo] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
category: string;
|
||||
} | null>(null);
|
||||
|
||||
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||
if (info.object && info.x !== undefined && info.y !== undefined) {
|
||||
setPopupInfo({
|
||||
x: info.x,
|
||||
y: info.y,
|
||||
name: info.object.name,
|
||||
category: info.object.category,
|
||||
});
|
||||
} else {
|
||||
setPopupInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const layers = useMemo(
|
||||
() => [
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
|
|
@ -166,20 +262,76 @@ export default function Map({ data, onViewChange }: MapProps) {
|
|||
opacity: 0.5,
|
||||
highPrecision: true,
|
||||
}),
|
||||
new IconLayer<POI>({
|
||||
id: 'poi-icons',
|
||||
data: pois,
|
||||
getPosition: (d) => [d.lng, d.lat],
|
||||
getIcon: (d) => ({
|
||||
url: getPOIIconUrl(d.category),
|
||||
width: 72,
|
||||
height: 72,
|
||||
}),
|
||||
getSize: 24,
|
||||
sizeMinPixels: 20,
|
||||
sizeMaxPixels: 40,
|
||||
pickable: true,
|
||||
onHover: handlePoiHover,
|
||||
}),
|
||||
],
|
||||
[data]
|
||||
[data, pois, handlePoiHover]
|
||||
);
|
||||
|
||||
|
||||
// Tooltip for hexagons only (POIs use MapLibre popup)
|
||||
const getTooltip = useCallback(({ object }: { object?: HexagonData }) => {
|
||||
if (!object || !('h3' in object)) return null;
|
||||
|
||||
const hex = object as HexagonData;
|
||||
return {
|
||||
html: `<div style="padding: 8px; font-size: 14px;">
|
||||
<strong>Avg: £${hex.avg_price?.toLocaleString() || 'N/A'}</strong>
|
||||
<div style="color: #666; font-size: 12px;">
|
||||
${hex.count} sales<br/>
|
||||
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
|
||||
</div>
|
||||
</div>`,
|
||||
style: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full" ref={containerRef}>
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
<DeckGL
|
||||
viewState={viewState}
|
||||
controller
|
||||
layers={layers}
|
||||
onViewStateChange={handleViewStateChange as never}
|
||||
getTooltip={getTooltip as never}
|
||||
>
|
||||
<MapGL mapStyle={MAP_STYLE} />
|
||||
</DeckGL>
|
||||
{popupInfo && (
|
||||
<div
|
||||
className="absolute pointer-events-none bg-white rounded shadow-lg p-2 text-sm"
|
||||
style={{
|
||||
left: popupInfo.x,
|
||||
top: popupInfo.y - 40,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
|
||||
</strong>
|
||||
<div className="text-gray-500 text-xs">
|
||||
{popupInfo.category.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,3 +38,26 @@ export interface ViewChangeParams {
|
|||
export interface ApiResponse {
|
||||
features: HexagonData[];
|
||||
}
|
||||
|
||||
export interface POI {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface POIResponse {
|
||||
features: POI[];
|
||||
}
|
||||
|
||||
export const POI_CATEGORY_GROUPS = [
|
||||
'schools',
|
||||
'healthcare',
|
||||
'transport',
|
||||
'parks',
|
||||
'emergency',
|
||||
'supermarkets',
|
||||
] as const;
|
||||
|
||||
export type POICategoryGroup = (typeof POI_CATEGORY_GROUPS)[number];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue