diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ba8e38..4f30c11 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); const [filters, setFilters] = useState({}); const [activeFeature, setActiveFeature] = useState(null); + const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [rawData, setRawData] = useState([]); const [resolution, setResolution] = useState(8); const [bounds, setBounds] = useState(null); @@ -54,35 +60,41 @@ export default function App() { // POI state const [pois, setPois] = useState([]); - const [poiCategories, setPOICategories] = useState({}); + const [poiCategories, setPOICategories] = useState([]); const [selectedPOICategories, setSelectedPOICategories] = useState>(new Set()); const poiDebounceRef = useRef | null>(null); const poiAbortControllerRef = useRef(null); + // Hexagon properties state + const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(null); + const [properties, setProperties] = useState([]); + 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 (
{loading && (
Loading...
)} + +
+
+ {/* Tab headers */} +
+ + +
+ + {/* Tab content */} +
+ {rightPaneTab === 'pois' ? ( + + ) : ( + + )} +
); diff --git a/frontend/src/components/DataSources.tsx b/frontend/src/components/DataSources.tsx new file mode 100644 index 0000000..1d558bf --- /dev/null +++ b/frontend/src/components/DataSources.tsx @@ -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 ( +
+
Data Sources
+
+ {sources.map((source, idx) => ( + + + {source.name} + + {idx < sources.length - 1 && โ€ข} + + ))} +
+
+ ); +} diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx index 181d685..3bcb29e 100644 --- a/frontend/src/components/Filters.tsx +++ b/frontend/src/components/Filters.tsx @@ -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; + 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; - onPOICategoriesChange: (categories: Set) => 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(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 (
@@ -74,9 +45,31 @@ export default function Filters({
Zoom: {zoom.toFixed(1)}
- {features.map((feature) => { - const range = filters[feature.name] || [feature.min, feature.max]; + {/* Add filter dropdown */} + {availableFeatures.length > 0 && ( + + )} + + {/* 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' : ''}`} > - +
+ + +
{ - 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()} />
); @@ -104,82 +104,33 @@ export default function Filters({
Color Scale
-
-
- Low - High -
-
- -
- - - - {dropdownOpen && ( -
-
- - | - + {activeFeature ? ( + <> +
+
+ Low + High
-
- {categoryKeys.map((key) => { - const { emoji, label, count } = poiCategories[key]; - return ( - - ); - })} + + ) : ( + <> +
+
+ Few + Many
-
+ )}
diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 6353cac..3aa94c8 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -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 = { - // 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 = { - 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(null); const [viewState, setViewState] = useState(INITIAL_VIEW); const [dimensions, setDimensions] = useState({ 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) => { + 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(`
${f.label}: ${minStr} - ${maxStr}
`); } } @@ -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} > @@ -447,10 +346,8 @@ export default function Map({ data, pois, onViewChange, activeFeature, features zIndex: 9999, }} > - - {getTooltipEmoji(popupInfo.category)} {popupInfo.name} - -
{popupInfo.category.replace(/_/g, ' ')}
+ {popupInfo.name} +
{popupInfo.category}
)} diff --git a/frontend/src/components/POIPane.tsx b/frontend/src/components/POIPane.tsx new file mode 100644 index 0000000..961d038 --- /dev/null +++ b/frontend/src/components/POIPane.tsx @@ -0,0 +1,140 @@ +import { useState, useRef, useEffect } from 'react'; +import { Label } from './ui/label'; + +interface POIPaneProps { + categories: string[]; + selectedCategories: Set; + onCategoriesChange: (categories: Set) => void; + poiCount: number; +} + +export default function POIPane({ + categories, + selectedCategories, + onCategoriesChange, + poiCount, +}: POIPaneProps) { + const [dropdownOpen, setDropdownOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const dropdownRef = useRef(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 ( +
+

Points of Interest

+ +
+ + + + {dropdownOpen && ( +
+
+ + | + +
+
+ setSearchTerm(e.target.value)} + className="w-full px-2 py-1 text-sm border border-slate-300 rounded" + /> +
+
+ {filteredCategories.map((category) => ( + + ))} +
+
+ )} +
+ + {selectedCount > 0 && ( +
+
+ {poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible +
+
+ {selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected +
+
+ )} + +
+

Select categories to display POIs on the map.

+

Zoom in for better visibility of individual locations.

+
+
+ ); +} diff --git a/frontend/src/components/PropertiesPane.tsx b/frontend/src/components/PropertiesPane.tsx new file mode 100644 index 0000000..07b03bd --- /dev/null +++ b/frontend/src/components/PropertiesPane.tsx @@ -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('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 ( +
+ Click a hexagon to view properties +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Properties in Hexagon

+ +
+

+ Showing {properties.length} of {total} properties +

+
+ + {/* Sort controls */} +
+ +
+ + {/* Properties list */} +
+ {loading && properties.length === 0 ? ( +
Loading...
+ ) : ( + <> + {sortedProperties.map((property, idx) => ( + + ))} + {properties.length < total && ( + + )} + + )} +
+
+ ); +} + +// 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 ( +
+ {/* Address */} +
{property.address || 'Unknown Address'}
+
{property.postcode}
+ + {/* Price */} + {property.latest_price && ( +
+ ยฃ{formatNumber(property.latest_price as number)} + {property.price_per_sqm && ( + + {' '} + (ยฃ{formatNumber(property.price_per_sqm as number)}/mยฒ) + + )} +
+ )} + + {/* Property details grid */} +
+ {property.property_type && ( +
+ Type: {property.property_type} +
+ )} + {property.built_form && ( +
+ Form: {property.built_form} +
+ )} + {property.total_floor_area && ( +
+ Area: {formatNumber(property.total_floor_area as number)}mยฒ +
+ )} + {property.number_habitable_rooms && ( +
+ Rooms:{' '} + {formatNumber(property.number_habitable_rooms as number)} +
+ )} + {property.current_energy_rating && ( +
+ Energy: {property.current_energy_rating} +
+ )} + {property.potential_energy_rating && ( +
+ Potential: {property.potential_energy_rating} +
+ )} + {property.construction_age_band !== undefined && ( +
+ Built (age): {formatNumber(property.construction_age_band as number)} +
+ )} + + {/* Journey times */} + {property.public_transport_easy_minutes && ( +
+ PT (easy):{' '} + {formatNumber(property.public_transport_easy_minutes as number)}min +
+ )} + {property.public_transport_quick_minutes && ( +
+ PT (quick):{' '} + {formatNumber(property.public_transport_quick_minutes as number)}min +
+ )} + {property.cycling_minutes && ( +
+ Cycling:{' '} + {formatNumber(property.cycling_minutes as number)}min +
+ )} + + {/* Deprivation scores */} + {property.income_score !== undefined && ( +
+ Income:{' '} + {formatNumber(property.income_score as number, 1)} +
+ )} + {property.employment_score !== undefined && ( +
+ Employment:{' '} + {formatNumber(property.employment_score as number, 1)} +
+ )} + {property.education_score !== undefined && ( +
+ Education:{' '} + {formatNumber(property.education_score as number, 1)} +
+ )} + {property.health_score !== undefined && ( +
+ Health:{' '} + {formatNumber(property.health_score as number, 1)} +
+ )} + {property.crime_score !== undefined && ( +
+ Crime:{' '} + {formatNumber(property.crime_score as number, 1)} +
+ )} +
+
+ ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7416021..8412774 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; +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; +}