Improve map
This commit is contained in:
parent
ced6b16140
commit
a2e4c29839
10 changed files with 285 additions and 111 deletions
|
|
@ -1,48 +1,77 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Map from './components/Map';
|
||||
import Filters from './components/Filters';
|
||||
import { DEFAULT_FILTERS } from './lib/constants';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
export default function App() {
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const [data, setData] = useState([]);
|
||||
const [resolution, setResolution] = useState(8);
|
||||
const [bounds, setBounds] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const debounceRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
min_year: filters.minYear.toString(),
|
||||
max_year: filters.maxYear.toString(),
|
||||
min_price: filters.minPrice.toString(),
|
||||
max_price: filters.maxPrice.toString(),
|
||||
});
|
||||
const res = await fetch(`/api/hexagons?${params}`);
|
||||
const json = await res.json();
|
||||
setData(
|
||||
json.features.map((f) => ({
|
||||
h3: f.properties.h3,
|
||||
...f.properties,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, resolution]);
|
||||
|
||||
// Debounced fetch when dependencies change
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
if (!bounds) return;
|
||||
|
||||
// Clear previous debounce timer
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const params = new URLSearchParams({
|
||||
resolution: resolution.toString(),
|
||||
min_year: filters.minYear.toString(),
|
||||
max_year: filters.maxYear.toString(),
|
||||
min_price: filters.minPrice.toString(),
|
||||
max_price: filters.maxPrice.toString(),
|
||||
bounds: boundsStr,
|
||||
});
|
||||
const res = await fetch(`/api/hexagons?${params}`, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json = await res.json();
|
||||
setData(json.features || []);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch data:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [filters, resolution, bounds]);
|
||||
|
||||
const handleViewChange = useCallback(({ resolution: newRes, bounds: newBounds }) => {
|
||||
setResolution(newRes);
|
||||
setBounds(newBounds);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex">
|
||||
<Filters filters={filters} onChange={setFilters} />
|
||||
<div className="flex-1 relative">
|
||||
<Map data={data} onZoom={setResolution} />
|
||||
<Map data={data} onViewChange={handleViewChange} />
|
||||
{loading && (
|
||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">
|
||||
Loading...
|
||||
|
|
|
|||
|
|
@ -54,34 +54,19 @@ export default function Filters({ filters, onChange }) {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-3 bg-slate-100 rounded text-xs space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: 'rgb(46, 204, 113)' }}
|
||||
></span>
|
||||
<span>{'< £150k'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: 'rgb(241, 196, 15)' }}
|
||||
></span>
|
||||
<span>£150k - £300k</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: 'rgb(231, 76, 60)' }}
|
||||
></span>
|
||||
<span>£300k - £500k</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: 'rgb(142, 68, 173)' }}
|
||||
></span>
|
||||
<span>{'> £500k'}</span>
|
||||
<div className="mt-6 p-3 bg-slate-100 rounded text-xs">
|
||||
<div className="mb-2 font-medium">Average Price</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>£0</span>
|
||||
<span>£200k</span>
|
||||
<span>£400k</span>
|
||||
<span>£800k+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { Map as MapGL } from 'react-map-gl/maplibre';
|
||||
import DeckGL from '@deck.gl/react';
|
||||
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
||||
|
|
@ -13,32 +13,128 @@ const INITIAL_VIEW = {
|
|||
|
||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
||||
|
||||
// Continuous color scale from green (low) -> yellow -> red -> purple (high)
|
||||
const COLOR_SCALE = [
|
||||
{ price: 0, color: [46, 204, 113] }, // Green
|
||||
{ price: 200000, color: [241, 196, 15] }, // Yellow
|
||||
{ price: 400000, color: [231, 76, 60] }, // Red
|
||||
{ price: 800000, color: [142, 68, 173] }, // Purple
|
||||
];
|
||||
|
||||
function interpolateColor(c1, c2, t) {
|
||||
return [
|
||||
Math.round(c1[0] + (c2[0] - c1[0]) * t),
|
||||
Math.round(c1[1] + (c2[1] - c1[1]) * t),
|
||||
Math.round(c1[2] + (c2[2] - c1[2]) * t),
|
||||
];
|
||||
}
|
||||
|
||||
function priceToColor(price) {
|
||||
if (price < 150000) return [46, 204, 113];
|
||||
if (price < 300000) return [241, 196, 15];
|
||||
if (price < 500000) return [231, 76, 60];
|
||||
return [142, 68, 173];
|
||||
if (price == null || isNaN(price)) return [128, 128, 128]; // Gray for missing data
|
||||
|
||||
// Clamp to scale range
|
||||
if (price <= COLOR_SCALE[0].price) return COLOR_SCALE[0].color;
|
||||
if (price >= COLOR_SCALE[COLOR_SCALE.length - 1].price) {
|
||||
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
|
||||
}
|
||||
|
||||
// Find the two colors to interpolate between
|
||||
for (let i = 0; i < COLOR_SCALE.length - 1; i++) {
|
||||
const lower = COLOR_SCALE[i];
|
||||
const upper = COLOR_SCALE[i + 1];
|
||||
if (price >= lower.price && price <= upper.price) {
|
||||
const t = (price - lower.price) / (upper.price - lower.price);
|
||||
return interpolateColor(lower.color, upper.color, t);
|
||||
}
|
||||
}
|
||||
|
||||
return COLOR_SCALE[COLOR_SCALE.length - 1].color;
|
||||
}
|
||||
|
||||
function zoomToResolution(zoom) {
|
||||
if (zoom < 7) return 6;
|
||||
if (zoom < 8) return 6;
|
||||
if (zoom < 9) return 7;
|
||||
if (zoom < 11) return 8;
|
||||
if (zoom < 13) return 9;
|
||||
if (zoom < 15) return 10;
|
||||
if (zoom < 17) return 11;
|
||||
return 12;
|
||||
if (zoom < 14) return 9;
|
||||
if (zoom < 16) return 10;
|
||||
return 11;
|
||||
}
|
||||
|
||||
export default function Map({ data, onZoom }) {
|
||||
const onViewStateChange = useCallback(
|
||||
({ viewState }) => {
|
||||
onZoom(zoomToResolution(viewState.zoom));
|
||||
},
|
||||
[onZoom]
|
||||
);
|
||||
function getBoundsFromViewState(viewState, width, height) {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
|
||||
const layers = [
|
||||
// Clamp latitude to valid Mercator range to avoid math errors
|
||||
const clampedLat = Math.max(-85, Math.min(85, latitude));
|
||||
|
||||
// Web Mercator projection math
|
||||
const TILE_SIZE = 256;
|
||||
const scale = Math.pow(2, zoom);
|
||||
const worldSize = TILE_SIZE * scale;
|
||||
|
||||
// Longitude is linear
|
||||
const degreesPerPixelLng = 360 / worldSize;
|
||||
const halfWidthDeg = (width / 2) * degreesPerPixelLng;
|
||||
|
||||
// Latitude uses Mercator projection (non-linear)
|
||||
// Convert center lat to pixel y, offset by half height, convert back to lat
|
||||
const latRad = clampedLat * Math.PI / 180;
|
||||
const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||
const centerPixelY = mercatorY * worldSize;
|
||||
|
||||
const topPixelY = centerPixelY - height / 2;
|
||||
const bottomPixelY = centerPixelY + height / 2;
|
||||
|
||||
// Convert pixel Y back to latitude
|
||||
const pixelYToLat = (pixelY) => {
|
||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize)); // Clamp to avoid edge cases
|
||||
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
||||
return latRadians * 180 / Math.PI;
|
||||
};
|
||||
|
||||
const north = Math.min(85, pixelYToLat(topPixelY));
|
||||
const south = Math.max(-85, pixelYToLat(bottomPixelY));
|
||||
const west = Math.max(-180, longitude - halfWidthDeg);
|
||||
const east = Math.min(180, longitude + halfWidthDeg);
|
||||
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
export default function Map({ data, onViewChange }) {
|
||||
const containerRef = useRef(null);
|
||||
const [viewState, setViewState] = useState(INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
// Track container dimensions with ResizeObserver
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setDimensions({ width, height });
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Notify parent when view or dimensions change
|
||||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
onViewChange({ resolution, bounds });
|
||||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleViewStateChange = useCallback(({ viewState: newViewState }) => {
|
||||
setViewState(newViewState);
|
||||
}, []);
|
||||
|
||||
const layers = useMemo(() => [
|
||||
new H3HexagonLayer({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
|
|
@ -48,15 +144,15 @@ export default function Map({ data, onZoom }) {
|
|||
pickable: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
];
|
||||
], [data]);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 h-full" ref={containerRef}>
|
||||
<DeckGL
|
||||
initialViewState={INITIAL_VIEW}
|
||||
viewState={viewState}
|
||||
controller
|
||||
layers={layers}
|
||||
onViewStateChange={onViewStateChange}
|
||||
onViewStateChange={handleViewStateChange}
|
||||
>
|
||||
<MapGL mapStyle={MAP_STYLE} />
|
||||
</DeckGL>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// Filter configuration constants
|
||||
// Should match backend pipeline/config.py
|
||||
|
||||
export const YEAR_MIN = 1995;
|
||||
export const YEAR_MAX = 2024;
|
||||
export const YEAR_STEP = 1;
|
||||
|
||||
export const PRICE_MIN = 0;
|
||||
export const PRICE_MAX = 2000000;
|
||||
export const PRICE_MAX = 5000000; // £5M max for slider, but no server-side cap
|
||||
export const PRICE_STEP = 50000;
|
||||
|
||||
export const DEFAULT_FILTERS = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue