diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a1f10e..467ac7a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 | null>(null); const abortControllerRef = useRef(null); + // POI state + const [pois, setPois] = useState([]); + const [selectedPOICategories, setSelectedPOICategories] = useState>(new Set()); + const poiDebounceRef = useRef | null>(null); + const poiAbortControllerRef = useRef(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 (
- +
- + {loading && (
Loading...
)} diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index aa3e305..a99be49 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -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; + onPOICategoriesChange: (categories: Set) => void; } -export default function Filters({ filters, onChange, zoom }: FiltersProps) { +const POI_LABELS: Record = { + 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 ( -
+

UK Property Prices

Zoom: {zoom.toFixed(1)}
@@ -69,6 +97,23 @@ export default function Filters({ filters, onChange, zoom }: FiltersProps) { £800k+
+ +
+ +
+ {POI_CATEGORY_GROUPS.map((category) => ( + + ))} +
+
); } diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 241ecdb..be5a074 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -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 = { + // 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 = { + 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(null); const [viewState, setViewState] = useState(INITIAL_VIEW); const [dimensions, setDimensions] = useState({ 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) => { + 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({ @@ -166,20 +262,76 @@ export default function Map({ data, onViewChange }: MapProps) { opacity: 0.5, highPrecision: true, }), + new IconLayer({ + 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: `
+ Avg: £${hex.avg_price?.toLocaleString() || 'N/A'} +
+ ${hex.count} sales
+ Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()} +
+
`, + style: { + backgroundColor: 'white', + borderRadius: '4px', + boxShadow: '0 2px 4px rgba(0,0,0,0.2)', + }, + }; + }, []); + return ( -
+
+ {popupInfo && ( +
+ + {getTooltipEmoji(popupInfo.category)} {popupInfo.name} + +
+ {popupInfo.category.replace(/_/g, ' ')} +
+
+ )}
); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ecfe433..b094d4a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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]; diff --git a/server/main.py b/server/main.py index 511016c..d404a51 100644 --- a/server/main.py +++ b/server/main.py @@ -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" diff --git a/server/routes/pois.py b/server/routes/pois.py new file mode 100644 index 0000000..d7e0e58 --- /dev/null +++ b/server/routes/pois.py @@ -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())}