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 { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import Map from './components/Map';
|
import Map from './components/Map';
|
||||||
import Filters from './components/Filters';
|
import Filters from './components/Filters';
|
||||||
|
import POIPane from './components/POIPane';
|
||||||
|
import { PropertiesPane } from './components/PropertiesPane';
|
||||||
|
import DataSources from './components/DataSources';
|
||||||
import type {
|
import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
FeatureFilters,
|
FeatureFilters,
|
||||||
|
|
@ -10,7 +13,9 @@ import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
POI,
|
POI,
|
||||||
POIResponse,
|
POIResponse,
|
||||||
POICategoriesMap,
|
POICategoriesResponse,
|
||||||
|
Property,
|
||||||
|
HexagonPropertiesResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 150;
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
@ -44,6 +49,7 @@ export default function App() {
|
||||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||||
const [filters, setFilters] = useState<FeatureFilters>({});
|
const [filters, setFilters] = useState<FeatureFilters>({});
|
||||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||||
|
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||||
const [resolution, setResolution] = useState<number>(8);
|
const [resolution, setResolution] = useState<number>(8);
|
||||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||||
|
|
@ -54,35 +60,41 @@ export default function App() {
|
||||||
|
|
||||||
// POI state
|
// POI state
|
||||||
const [pois, setPois] = useState<POI[]>([]);
|
const [pois, setPois] = useState<POI[]>([]);
|
||||||
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
|
const [poiCategories, setPOICategories] = useState<string[]>([]);
|
||||||
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
|
const [selectedPOICategories, setSelectedPOICategories] = useState<Set<string>>(new Set());
|
||||||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const poiAbortControllerRef = useRef<AbortController | 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
|
// Fetch feature metadata + POI categories on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${getApiBaseUrl()}/api/features`)
|
fetch(`${getApiBaseUrl()}/api/features`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: { features: FeatureMeta[] }) => {
|
.then((json: { features: FeatureMeta[] }) => {
|
||||||
setFeatures(json.features);
|
setFeatures(json.features);
|
||||||
// Initialize filters with full range for each feature
|
// Start with no filters (empty object)
|
||||||
const initial: FeatureFilters = {};
|
|
||||||
for (const f of json.features) {
|
|
||||||
initial[f.name] = [f.min, f.max];
|
|
||||||
}
|
|
||||||
setFilters(initial);
|
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('Failed to fetch features:', err));
|
.catch((err) => console.error('Failed to fetch features:', err));
|
||||||
|
|
||||||
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((json: { categories: POICategoriesMap }) => {
|
.then((json: POICategoriesResponse) => {
|
||||||
setPOICategories(json.categories);
|
setPOICategories(json.categories);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('Failed to fetch POI categories:', err));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (!bounds) return;
|
if (!bounds) return;
|
||||||
|
|
||||||
|
|
@ -103,6 +115,14 @@ export default function App() {
|
||||||
resolution: resolution.toString(),
|
resolution: resolution.toString(),
|
||||||
bounds: boundsStr,
|
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}`, {
|
const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
|
|
@ -122,36 +142,19 @@ export default function App() {
|
||||||
clearTimeout(debounceRef.current);
|
clearTimeout(debounceRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [resolution, bounds]);
|
}, [resolution, bounds, filters]);
|
||||||
|
|
||||||
// Client-side filtering
|
// Client-side filtering: only during drag preview
|
||||||
const data = useMemo(() => {
|
const data = useMemo(() => {
|
||||||
if (features.length === 0) return rawData;
|
if (!activeFeature || !dragValue) return rawData;
|
||||||
|
|
||||||
return rawData.filter((hex) => {
|
return rawData.filter((hex) => {
|
||||||
if (activeFeature) {
|
const minVal = hex[`min_${activeFeature}`];
|
||||||
// Only apply the active feature's filter
|
const maxVal = hex[`max_${activeFeature}`];
|
||||||
const range = filters[activeFeature];
|
if (minVal == null || maxVal == null) return false;
|
||||||
if (!range) return true;
|
return (minVal as number) <= dragValue[1] && (maxVal as number) >= dragValue[0];
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
}, [rawData, filters, activeFeature, features]);
|
}, [rawData, activeFeature, dragValue]);
|
||||||
|
|
||||||
// Fetch POIs when bounds or selected categories change
|
// Fetch POIs when bounds or selected categories change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -181,7 +184,7 @@ export default function App() {
|
||||||
signal: poiAbortControllerRef.current.signal,
|
signal: poiAbortControllerRef.current.signal,
|
||||||
});
|
});
|
||||||
const json: POIResponse = await res.json();
|
const json: POIResponse = await res.json();
|
||||||
setPois(json.features || []);
|
setPois(json.pois || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name !== 'AbortError') {
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
console.error('Failed to fetch POIs:', err);
|
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 (
|
return (
|
||||||
<div className="h-screen flex">
|
<div className="h-screen flex">
|
||||||
<Filters
|
<Filters
|
||||||
features={features}
|
features={features}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
activeFeature={activeFeature}
|
activeFeature={activeFeature}
|
||||||
onFiltersChange={setFilters}
|
dragValue={dragValue}
|
||||||
onActiveFeatureChange={setActiveFeature}
|
enabledFeatures={enabledFeatures}
|
||||||
|
onAddFilter={handleAddFilter}
|
||||||
|
onRemoveFilter={handleRemoveFilter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragChange={handleDragChange}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
poiCategories={poiCategories}
|
|
||||||
selectedPOICategories={selectedPOICategories}
|
|
||||||
onPOICategoriesChange={setSelectedPOICategories}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Map
|
<Map
|
||||||
|
|
@ -224,11 +332,61 @@ export default function App() {
|
||||||
pois={pois}
|
pois={pois}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
activeFeature={activeFeature}
|
activeFeature={activeFeature}
|
||||||
|
dragValue={dragValue}
|
||||||
features={features}
|
features={features}
|
||||||
|
selectedHexagonId={selectedHexagon?.h3 || null}
|
||||||
|
onHexagonClick={handleHexagonClick}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
<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>
|
||||||
</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 { Slider } from './ui/slider';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import type { FeatureMeta, FeatureFilters, POICategoriesMap } from '../types';
|
import type { FeatureMeta, FeatureFilters } from '../types';
|
||||||
|
|
||||||
interface FiltersProps {
|
interface FiltersProps {
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
filters: FeatureFilters;
|
filters: FeatureFilters;
|
||||||
activeFeature: string | null;
|
activeFeature: string | null;
|
||||||
onFiltersChange: (filters: FeatureFilters) => void;
|
dragValue: [number, number] | null;
|
||||||
onActiveFeatureChange: (feature: string | null) => void;
|
enabledFeatures: Set<string>;
|
||||||
|
onAddFilter: (name: string) => void;
|
||||||
|
onRemoveFilter: (name: string) => void;
|
||||||
|
onDragStart: (name: string) => void;
|
||||||
|
onDragChange: (value: [number, number]) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
poiCategories: POICategoriesMap;
|
|
||||||
selectedPOICategories: Set<string>;
|
|
||||||
onPOICategoriesChange: (categories: Set<string>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: number): string {
|
function formatValue(value: number): string {
|
||||||
|
|
@ -26,47 +27,17 @@ export default function Filters({
|
||||||
features,
|
features,
|
||||||
filters,
|
filters,
|
||||||
activeFeature,
|
activeFeature,
|
||||||
onFiltersChange,
|
dragValue,
|
||||||
onActiveFeatureChange,
|
enabledFeatures,
|
||||||
|
onAddFilter,
|
||||||
|
onRemoveFilter,
|
||||||
|
onDragStart,
|
||||||
|
onDragChange,
|
||||||
|
onDragEnd,
|
||||||
zoom,
|
zoom,
|
||||||
poiCategories,
|
|
||||||
selectedPOICategories,
|
|
||||||
onPOICategoriesChange,
|
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 p-4 bg-white shadow-lg space-y-4 overflow-y-auto max-h-screen">
|
<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>
|
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||||
|
|
||||||
{features.map((feature) => {
|
{/* Add filter dropdown */}
|
||||||
const range = filters[feature.name] || [feature.min, feature.max];
|
{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 isActive = activeFeature === feature.name;
|
||||||
|
const displayValue =
|
||||||
|
isActive && dragValue ? dragValue : filters[feature.name] || [feature.min, feature.max];
|
||||||
const step = (feature.max - feature.min) / 100;
|
const step = (feature.max - feature.min) / 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -84,19 +77,26 @@ export default function Filters({
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-blue-400 bg-blue-50' : ''}`}
|
className={`space-y-1 p-2 rounded ${isActive ? 'ring-2 ring-blue-400 bg-blue-50' : ''}`}
|
||||||
>
|
>
|
||||||
<Label className="text-xs">
|
<div className="flex items-center justify-between">
|
||||||
{feature.label}: {formatValue(range[0])} - {formatValue(range[1])}
|
<Label className="text-xs">
|
||||||
</Label>
|
{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
|
<Slider
|
||||||
min={feature.min}
|
min={feature.min}
|
||||||
max={feature.max}
|
max={feature.max}
|
||||||
step={step}
|
step={step}
|
||||||
value={[range[0], range[1]]}
|
value={[displayValue[0], displayValue[1]]}
|
||||||
onValueChange={([min, max]) => {
|
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||||
onFiltersChange({ ...filters, [feature.name]: [min, max] });
|
onPointerDown={() => onDragStart(feature.name)}
|
||||||
}}
|
onPointerUp={() => onDragEnd()}
|
||||||
onPointerDown={() => onActiveFeatureChange(feature.name)}
|
|
||||||
onPointerUp={() => onActiveFeatureChange(null)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -104,82 +104,33 @@ export default function Filters({
|
||||||
|
|
||||||
<div className="p-3 bg-slate-100 rounded text-xs">
|
<div className="p-3 bg-slate-100 rounded text-xs">
|
||||||
<div className="mb-2 font-medium">Color Scale</div>
|
<div className="mb-2 font-medium">Color Scale</div>
|
||||||
<div
|
{activeFeature ? (
|
||||||
className="h-4 rounded"
|
<>
|
||||||
style={{
|
<div
|
||||||
background:
|
className="h-4 rounded"
|
||||||
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
style={{
|
||||||
}}
|
background:
|
||||||
></div>
|
'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))',
|
||||||
<div className="flex justify-between mt-1">
|
}}
|
||||||
<span>Low</span>
|
></div>
|
||||||
<span>High</span>
|
<div className="flex justify-between mt-1">
|
||||||
</div>
|
<span>Low</span>
|
||||||
</div>
|
<span>High</span>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-64 overflow-y-auto py-1">
|
</>
|
||||||
{categoryKeys.map((key) => {
|
) : (
|
||||||
const { emoji, label, count } = poiCategories[key];
|
<>
|
||||||
return (
|
<div
|
||||||
<label
|
className="h-4 rounded"
|
||||||
key={key}
|
style={{
|
||||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 cursor-pointer"
|
background: 'linear-gradient(to right, rgb(209, 226, 243), rgb(33, 102, 172))',
|
||||||
>
|
}}
|
||||||
<input
|
></div>
|
||||||
type="checkbox"
|
<div className="flex justify-between mt-1">
|
||||||
checked={selectedPOICategories.has(key)}
|
<span>Few</span>
|
||||||
onChange={() => toggleCategory(key)}
|
<span>Many</span>
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm flex-1">
|
|
||||||
{emoji} {label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-400">{count.toLocaleString()}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,169 +13,24 @@ interface MapProps {
|
||||||
pois: POI[];
|
pois: POI[];
|
||||||
onViewChange: (params: ViewChangeParams) => void;
|
onViewChange: (params: ViewChangeParams) => void;
|
||||||
activeFeature: string | null;
|
activeFeature: string | null;
|
||||||
|
dragValue: [number, number] | null;
|
||||||
features: FeatureMeta[];
|
features: FeatureMeta[];
|
||||||
|
selectedHexagonId: string | null;
|
||||||
|
onHexagonClick: (h3: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Twemoji CDN base URL
|
// Twemoji CDN base URL
|
||||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/';
|
||||||
|
|
||||||
// Map category to Twemoji codepoint (emoji unicode -> hex)
|
// Convert emoji to Twemoji URL
|
||||||
const POI_EMOJI_CODES: Record<string, string> = {
|
function emojiToTwemojiUrl(emoji: string): string {
|
||||||
// Education
|
// Convert emoji to Unicode codepoint hex
|
||||||
school: '1f3eb', // 🏫
|
const codePoint = emoji.codePointAt(0);
|
||||||
preschool: '1f476', // 👶
|
if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin
|
||||||
college_university: '1f393', // 🎓
|
const hex = codePoint.toString(16);
|
||||||
library: '1f4da', // 📚
|
return `${TWEMOJI_BASE}${hex}.png`;
|
||||||
// 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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = {
|
const INITIAL_VIEW: ViewState = {
|
||||||
longitude: -1.5,
|
longitude: -1.5,
|
||||||
|
|
@ -280,7 +135,16 @@ function DeckOverlay({
|
||||||
return null;
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
||||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
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
|
// Compute count range for count-based coloring
|
||||||
const colorFeatureName = activeFeature || (features.length > 0 ? features[0].name : null);
|
const countRange = useMemo(() => {
|
||||||
const colorFeatureMeta = features.find((f) => f.name === colorFeatureName) || null;
|
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(
|
const layers = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|
@ -358,21 +244,33 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
||||||
data,
|
data,
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
getFillColor: (d) => {
|
getFillColor: (d) => {
|
||||||
if (!colorFeatureName || !colorFeatureMeta) return [128, 128, 128] as [number, number, number];
|
if (activeFeature && dragValue && colorFeatureMeta) {
|
||||||
const val = d[`min_${colorFeatureName}`];
|
// Drag mode: color by feature value using gradient
|
||||||
if (val == null) return [128, 128, 128] as [number, number, number];
|
const val = d[`min_${activeFeature}`];
|
||||||
const range = colorFeatureMeta.max - colorFeatureMeta.min;
|
if (val == null) return [128, 128, 128] as [number, number, number];
|
||||||
if (range === 0) return GRADIENT[0].color;
|
const range = dragValue[1] - dragValue[0];
|
||||||
const t = ((val as number) - colorFeatureMeta.min) / range;
|
if (range === 0) return GRADIENT[0].color;
|
||||||
return normalizedToColor(t);
|
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: {
|
updateTriggers: {
|
||||||
getFillColor: [colorFeatureName, colorFeatureMeta],
|
getFillColor: [activeFeature, dragValue, countRange, colorFeatureMeta],
|
||||||
|
getLineColor: [selectedHexagonId],
|
||||||
|
getLineWidth: [selectedHexagonId],
|
||||||
},
|
},
|
||||||
extruded: false,
|
extruded: false,
|
||||||
pickable: true,
|
pickable: true,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
highPrecision: true,
|
highPrecision: true,
|
||||||
|
onClick: handleHexagonClick,
|
||||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||||
beforeId: LABEL_LAYER_ID,
|
beforeId: LABEL_LAYER_ID,
|
||||||
}),
|
}),
|
||||||
|
|
@ -381,7 +279,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
||||||
data: pois,
|
data: pois,
|
||||||
getPosition: (d) => [d.lng, d.lat],
|
getPosition: (d) => [d.lng, d.lat],
|
||||||
getIcon: (d) => ({
|
getIcon: (d) => ({
|
||||||
url: getPOIIconUrl(d.category),
|
url: emojiToTwemojiUrl(d.emoji),
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
}),
|
}),
|
||||||
|
|
@ -392,7 +290,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
||||||
onHover: handlePoiHover,
|
onHover: handlePoiHover,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[data, pois, handlePoiHover, colorFeatureName, colorFeatureMeta]
|
[data, pois, handlePoiHover, handleHexagonClick, activeFeature, dragValue, countRange, colorFeatureMeta, selectedHexagonId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTooltip = useCallback(
|
const getTooltip = useCallback(
|
||||||
|
|
@ -409,7 +307,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
||||||
if (minVal != null && maxVal != null) {
|
if (minVal != null && maxVal != null) {
|
||||||
const minStr = typeof minVal === 'number' ? minVal.toLocaleString(undefined, { maximumFractionDigits: 1 }) : String(minVal);
|
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 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>`);
|
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 (
|
return (
|
||||||
|
|
@ -434,6 +332,7 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
||||||
onLoad={handleMapLoad as never}
|
onLoad={handleMapLoad as never}
|
||||||
mapStyle={MAP_STYLE}
|
mapStyle={MAP_STYLE}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
attributionControl={false}
|
||||||
>
|
>
|
||||||
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
<DeckOverlay layers={layers} getTooltip={getTooltip as never} />
|
||||||
</MapGL>
|
</MapGL>
|
||||||
|
|
@ -447,10 +346,8 @@ export default function Map({ data, pois, onViewChange, activeFeature, features
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>{popupInfo.name}</strong>
|
||||||
{getTooltipEmoji(popupInfo.category)} {popupInfo.name}
|
<div className="text-gray-500 text-xs">{popupInfo.category}</div>
|
||||||
</strong>
|
|
||||||
<div className="text-gray-500 text-xs">{popupInfo.category.replace(/_/g, ' ')}</div>
|
|
||||||
</div>
|
</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;
|
category: string;
|
||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
|
emoji: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface POIResponse {
|
export interface POIResponse {
|
||||||
features: POI[];
|
pois: POI[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface POICategoryInfo {
|
export interface POICategoriesResponse {
|
||||||
emoji: string;
|
categories: string[];
|
||||||
label: string;
|
|
||||||
count: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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