Improve map
This commit is contained in:
parent
400f733956
commit
51967fa880
7 changed files with 794 additions and 353 deletions
|
|
@ -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<FeatureMeta[]>([]);
|
||||
const [filters, setFilters] = useState<FeatureFilters>({});
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
|
|
@ -54,35 +60,41 @@ export default function App() {
|
|||
|
||||
// POI state
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
|
||||
const [poiCategories, setPOICategories] = useState<string[]>([]);
|
||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
|
||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Hexagon properties state
|
||||
const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>(null);
|
||||
const [properties, setProperties] = useState<Property[]>([]);
|
||||
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 (
|
||||
<div className="h-screen flex">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
onFiltersChange={setFilters}
|
||||
onActiveFeatureChange={setActiveFeature}
|
||||
dragValue={dragValue}
|
||||
enabledFeatures={enabledFeatures}
|
||||
onAddFilter={handleAddFilter}
|
||||
onRemoveFilter={handleRemoveFilter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragChange={handleDragChange}
|
||||
onDragEnd={handleDragEnd}
|
||||
zoom={zoom}
|
||||
poiCategories={poiCategories}
|
||||
selectedPOICategories={selectedPOICategories}
|
||||
onPOICategoriesChange={setSelectedPOICategories}
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Map
|
||||
|
|
@ -224,11 +332,61 @@ export default function App() {
|
|||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
features={features}
|
||||
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||
onHexagonClick={handleHexagonClick}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
||||
)}
|
||||
<DataSources />
|
||||
</div>
|
||||
<div className="w-72 bg-white shadow-lg z-10 flex flex-col">
|
||||
{/* Tab headers */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'pois'
|
||||
? 'border-b-2 border-blue-500 font-semibold'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('pois')}
|
||||
>
|
||||
POIs {pois.length > 0 && `(${pois.length})`}
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 p-3 ${
|
||||
rightPaneTab === 'properties'
|
||||
? 'border-b-2 border-blue-500 font-semibold'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
onClick={() => setRightPaneTab('properties')}
|
||||
>
|
||||
Properties {propertiesTotal > 0 && `(${propertiesTotal})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPaneTab === 'pois' ? (
|
||||
<POIPane
|
||||
categories={poiCategories}
|
||||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
/>
|
||||
) : (
|
||||
<PropertiesPane
|
||||
properties={properties}
|
||||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.h3 || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onClose={handleCloseProperties}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, POICategoryInfo>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue