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];
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from server.routes import hexagons
|
||||
from server.routes import hexagons, pois
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: preload all parquet files
|
||||
hexagons.preload_dataframes()
|
||||
pois.preload_pois()
|
||||
yield
|
||||
# Shutdown: nothing to clean up
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
app.include_router(hexagons.router, prefix="/api")
|
||||
app.include_router(pois.router, prefix="/api")
|
||||
|
||||
# Mount static files for production (frontend build)
|
||||
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
|
|
|
|||
122
server/routes/pois.py
Normal file
122
server/routes/pois.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""POI (Points of Interest) API endpoint."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["POLARS_UNKNOWN_EXTENSION_TYPE_BEHAVIOR"] = "load_as_storage"
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
import polars as pl
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DATA_FILE = Path("data_sources/uk_pois.parquet")
|
||||
|
||||
# Categories useful for property buyers
|
||||
POI_CATEGORIES = {
|
||||
"schools": [
|
||||
"elementary_school",
|
||||
"school",
|
||||
"high_school",
|
||||
"preschool",
|
||||
"college_university",
|
||||
"private_school",
|
||||
],
|
||||
"healthcare": [
|
||||
"doctor",
|
||||
"dentist",
|
||||
"pharmacy",
|
||||
"hospital",
|
||||
"public_health_clinic",
|
||||
],
|
||||
"transport": [
|
||||
"train_station",
|
||||
"bus_station",
|
||||
"metro_station",
|
||||
"light_rail_and_subway_stations",
|
||||
],
|
||||
"parks": ["park", "national_park", "dog_park"],
|
||||
"emergency": ["police_department", "fire_department"],
|
||||
"supermarkets": ["supermarket", "grocery_store", "convenience_store"],
|
||||
}
|
||||
|
||||
# Flatten for quick lookup
|
||||
ALL_CATEGORIES = {cat for cats in POI_CATEGORIES.values() for cat in cats}
|
||||
|
||||
# Cache the dataframe
|
||||
_df_cache: pl.DataFrame | None = None
|
||||
|
||||
|
||||
def get_df() -> pl.DataFrame | None:
|
||||
"""Load and cache the POI dataframe."""
|
||||
global _df_cache
|
||||
if _df_cache is None:
|
||||
if not DATA_FILE.exists():
|
||||
return None
|
||||
df = pl.read_parquet(DATA_FILE)
|
||||
# Extract fields we need and filter to relevant categories
|
||||
_df_cache = df.select(
|
||||
pl.col("id"),
|
||||
pl.col("names").struct.field("primary").alias("name"),
|
||||
pl.col("categories").struct.field("primary").alias("category"),
|
||||
pl.col("bbox").struct.field("xmin").alias("lng"),
|
||||
pl.col("bbox").struct.field("ymin").alias("lat"),
|
||||
).filter(pl.col("category").is_in(ALL_CATEGORIES))
|
||||
return _df_cache
|
||||
|
||||
|
||||
def preload_pois() -> None:
|
||||
"""Preload POI data on startup."""
|
||||
df = get_df()
|
||||
if df is not None:
|
||||
print(f"Loaded {len(df):,} POIs")
|
||||
|
||||
|
||||
@router.get("/pois")
|
||||
async def get_pois(
|
||||
categories: str = Query(..., description="Comma-separated category groups"),
|
||||
bounds: str = Query(..., description="Bounding box: south,west,north,east"),
|
||||
) -> dict:
|
||||
"""Get POIs within bounds for specified category groups."""
|
||||
df = get_df()
|
||||
if df is None:
|
||||
return {"features": []}
|
||||
|
||||
# Parse bounds
|
||||
try:
|
||||
south, west, north, east = map(float, bounds.split(","))
|
||||
except ValueError:
|
||||
return {"features": []}
|
||||
|
||||
# Get categories to include
|
||||
requested_groups = [g.strip() for g in categories.split(",")]
|
||||
cats_to_include = set()
|
||||
for group in requested_groups:
|
||||
if group in POI_CATEGORIES:
|
||||
cats_to_include.update(POI_CATEGORIES[group])
|
||||
|
||||
if not cats_to_include:
|
||||
return {"features": []}
|
||||
|
||||
# Filter by bounds and categories
|
||||
filtered = df.filter(
|
||||
(pl.col("lat") >= south)
|
||||
& (pl.col("lat") <= north)
|
||||
& (pl.col("lng") >= west)
|
||||
& (pl.col("lng") <= east)
|
||||
& (pl.col("category").is_in(cats_to_include))
|
||||
)
|
||||
|
||||
# Limit results to avoid overwhelming the frontend
|
||||
MAX_POIS = 5000
|
||||
if len(filtered) > MAX_POIS:
|
||||
filtered = filtered.sample(n=MAX_POIS, seed=42)
|
||||
|
||||
return {"features": filtered.to_dicts()}
|
||||
|
||||
|
||||
@router.get("/poi-categories")
|
||||
async def get_poi_categories() -> dict:
|
||||
"""Get available POI category groups."""
|
||||
return {"categories": list(POI_CATEGORIES.keys())}
|
||||
Loading…
Add table
Add a link
Reference in a new issue