Update map to do filtering
This commit is contained in:
parent
6122ee44da
commit
d4fe881ef4
8 changed files with 349 additions and 372 deletions
|
|
@ -1,9 +1,9 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import Map from './components/Map';
|
||||
import Filters from './components/Filters';
|
||||
import { DEFAULT_FILTERS } from './lib/constants';
|
||||
import type {
|
||||
Filters as FiltersType,
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
Bounds,
|
||||
HexagonData,
|
||||
ViewChangeParams,
|
||||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
POI,
|
||||
POIResponse,
|
||||
POICategoriesMap,
|
||||
ColorMode,
|
||||
} from './types';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
|
@ -42,8 +41,10 @@ function getApiBaseUrl(): string {
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
const [filters, setFilters] = useState<FiltersType>(DEFAULT_FILTERS);
|
||||
const [data, setData] = useState<HexagonData[]>([]);
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [filters, setFilters] = useState<FeatureFilters>({});
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [rawData, setRawData] = useState<HexagonData[]>([]);
|
||||
const [resolution, setResolution] = useState<number>(8);
|
||||
const [bounds, setBounds] = useState<Bounds | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
|
@ -51,8 +52,6 @@ export default function App() {
|
|||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('price');
|
||||
|
||||
// POI state
|
||||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const [poiCategories, setPOICategories] = useState<POICategoriesMap>({});
|
||||
|
|
@ -60,8 +59,21 @@ export default function App() {
|
|||
const poiDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const poiAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch POI category definitions from server on mount
|
||||
// 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);
|
||||
})
|
||||
.catch((err) => console.error('Failed to fetch features:', err));
|
||||
|
||||
fetch(`${getApiBaseUrl()}/api/poi-categories`)
|
||||
.then((res) => res.json())
|
||||
.then((json: { categories: POICategoriesMap }) => {
|
||||
|
|
@ -70,7 +82,7 @@ export default function App() {
|
|||
.catch((err) => console.error('Failed to fetch POI categories:', err));
|
||||
}, []);
|
||||
|
||||
// Debounced fetch when dependencies change
|
||||
// Debounced fetch when resolution/bounds change (no filter params sent)
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
|
|
@ -89,17 +101,13 @@ export default function App() {
|
|||
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(`${getApiBaseUrl()}/api/hexagons?${params}`, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
const json: ApiResponse = await res.json();
|
||||
setData(json.features || []);
|
||||
setRawData(json.features || []);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Failed to fetch data:', err);
|
||||
|
|
@ -114,7 +122,36 @@ export default function App() {
|
|||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [filters, resolution, bounds]);
|
||||
}, [resolution, bounds]);
|
||||
|
||||
// Client-side filtering
|
||||
const data = useMemo(() => {
|
||||
if (features.length === 0) 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;
|
||||
});
|
||||
}, [rawData, filters, activeFeature, features]);
|
||||
|
||||
// Fetch POIs when bounds or selected categories change
|
||||
useEffect(() => {
|
||||
|
|
@ -171,17 +208,24 @@ export default function App() {
|
|||
return (
|
||||
<div className="h-screen flex">
|
||||
<Filters
|
||||
features={features}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
activeFeature={activeFeature}
|
||||
onFiltersChange={setFilters}
|
||||
onActiveFeatureChange={setActiveFeature}
|
||||
zoom={zoom}
|
||||
poiCategories={poiCategories}
|
||||
selectedPOICategories={selectedPOICategories}
|
||||
onPOICategoriesChange={setSelectedPOICategories}
|
||||
colorMode={colorMode}
|
||||
onColorModeChange={setColorMode}
|
||||
/>
|
||||
<div className="flex-1 relative">
|
||||
<Map data={data} pois={pois} onViewChange={handleViewChange} colorMode={colorMode} />
|
||||
<Map
|
||||
data={data}
|
||||
pois={pois}
|
||||
onViewChange={handleViewChange}
|
||||
activeFeature={activeFeature}
|
||||
features={features}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute top-4 right-4 bg-white px-3 py-1 rounded shadow">Loading...</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,38 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Slider } from './ui/slider';
|
||||
import { Label } from './ui/label';
|
||||
import { YEAR_MIN, YEAR_MAX, YEAR_STEP, PRICE_MIN, PRICE_MAX, PRICE_STEP } from '../lib/constants';
|
||||
import type { Filters as FiltersType, POICategoriesMap, ColorMode } from '../types';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoriesMap } from '../types';
|
||||
|
||||
interface FiltersProps {
|
||||
filters: FiltersType;
|
||||
onChange: (filters: FiltersType) => void;
|
||||
features: FeatureMeta[];
|
||||
filters: FeatureFilters;
|
||||
activeFeature: string | null;
|
||||
onFiltersChange: (filters: FeatureFilters) => void;
|
||||
onActiveFeatureChange: (feature: string | null) => void;
|
||||
zoom: number;
|
||||
poiCategories: POICategoriesMap;
|
||||
selectedPOICategories: Set<string>;
|
||||
onPOICategoriesChange: (categories: Set<string>) => void;
|
||||
colorMode: ColorMode;
|
||||
onColorModeChange: (mode: ColorMode) => void;
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
export default function Filters({
|
||||
features,
|
||||
filters,
|
||||
onChange,
|
||||
activeFeature,
|
||||
onFiltersChange,
|
||||
onActiveFeatureChange,
|
||||
zoom,
|
||||
poiCategories,
|
||||
selectedPOICategories,
|
||||
onPOICategoriesChange,
|
||||
colorMode,
|
||||
onColorModeChange,
|
||||
}: FiltersProps) {
|
||||
const update = (key: keyof FiltersType, value: number) => onChange({ ...filters, [key]: value });
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -63,99 +69,53 @@ export default function Filters({
|
|||
const selectedCount = selectedPOICategories.size;
|
||||
|
||||
return (
|
||||
<div className="w-72 p-4 bg-white shadow-lg space-y-6 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">
|
||||
<h1 className="text-xl font-bold">UK Property Prices</h1>
|
||||
|
||||
<div className="text-sm text-slate-500">Zoom: {zoom.toFixed(1)}</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Year Range: {filters.minYear} - {filters.maxYear}
|
||||
</Label>
|
||||
<Slider
|
||||
min={YEAR_MIN}
|
||||
max={YEAR_MAX}
|
||||
step={YEAR_STEP}
|
||||
value={[filters.minYear, filters.maxYear]}
|
||||
onValueChange={([min, max]) => onChange({ ...filters, minYear: min, maxYear: max })}
|
||||
/>
|
||||
</div>
|
||||
{features.map((feature) => {
|
||||
const range = filters[feature.name] || [feature.min, feature.max];
|
||||
const isActive = activeFeature === feature.name;
|
||||
const step = (feature.max - feature.min) / 100;
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Min Price: £{filters.minPrice.toLocaleString()}</Label>
|
||||
<Slider
|
||||
min={PRICE_MIN}
|
||||
max={PRICE_MAX}
|
||||
step={PRICE_STEP}
|
||||
value={[filters.minPrice]}
|
||||
onValueChange={([v]) => update('minPrice', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Max Price: £{filters.maxPrice.toLocaleString()}</Label>
|
||||
<Slider
|
||||
min={PRICE_MIN}
|
||||
max={PRICE_MAX}
|
||||
step={PRICE_STEP}
|
||||
value={[filters.maxPrice]}
|
||||
onValueChange={([v]) => update('maxPrice', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color By</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`flex-1 px-3 py-1.5 text-sm rounded ${colorMode === 'price' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700'}`}
|
||||
onClick={() => onColorModeChange('price')}
|
||||
>
|
||||
Price
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 px-3 py-1.5 text-sm rounded ${colorMode === 'journey_time' ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700'}`}
|
||||
onClick={() => onColorModeChange('journey_time')}
|
||||
>
|
||||
Journey Time
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{colorMode === 'price' ? (
|
||||
<div className="p-3 bg-slate-100 rounded text-xs">
|
||||
<div className="mb-2 font-medium">Average Price</div>
|
||||
return (
|
||||
<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>
|
||||
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>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<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 className="p-3 bg-slate-100 rounded text-xs">
|
||||
<div className="mb-2 font-medium">Journey Time to Bank</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 min</span>
|
||||
<span>30 min</span>
|
||||
<span>60 min</span>
|
||||
<span>120+ min</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2" ref={dropdownRef}>
|
||||
<Label>Points of Interest</Label>
|
||||
|
|
@ -199,7 +159,7 @@ export default function Filters({
|
|||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{categoryKeys.map((key) => {
|
||||
const { emoji, label } = poiCategories[key];
|
||||
const { emoji, label, count } = poiCategories[key];
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
|
|
@ -211,9 +171,10 @@ export default function Filters({
|
|||
onChange={() => toggleCategory(key)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="text-sm flex-1">
|
||||
{emoji} {label}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{count.toLocaleString()}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
|||
import { IconLayer } from '@deck.gl/layers';
|
||||
import type { PickingInfo } from '@deck.gl/core';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, ColorMode } from '../types';
|
||||
import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
pois: POI[];
|
||||
onViewChange: (params: ViewChangeParams) => void;
|
||||
colorMode: ColorMode;
|
||||
activeFeature: string | null;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
// Twemoji CDN base URL
|
||||
|
|
@ -185,66 +186,31 @@ const INITIAL_VIEW: ViewState = {
|
|||
|
||||
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
|
||||
|
||||
interface ColorStop {
|
||||
price: number;
|
||||
color: [number, number, number];
|
||||
}
|
||||
|
||||
// Continuous color scale from green (low) -> yellow -> red -> purple (high)
|
||||
const COLOR_SCALE: ColorStop[] = [
|
||||
{ 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
|
||||
// Gradient stops for normalized [0,1] values
|
||||
const GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [46, 204, 113] }, // Green
|
||||
{ t: 0.33, color: [241, 196, 15] }, // Yellow
|
||||
{ t: 0.66, color: [231, 76, 60] }, // Red
|
||||
{ t: 1, color: [142, 68, 173] }, // Purple
|
||||
];
|
||||
|
||||
function interpolateColor(
|
||||
c1: [number, number, number],
|
||||
c2: [number, number, number],
|
||||
t: number
|
||||
): [number, number, number] {
|
||||
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 normalizedToColor(t: number): [number, number, number] {
|
||||
if (t <= 0) return GRADIENT[0].color;
|
||||
if (t >= 1) return GRADIENT[GRADIENT.length - 1].color;
|
||||
|
||||
function scaleToColor(
|
||||
value: number | null | undefined,
|
||||
scale: ColorStop[]
|
||||
): [number, number, number] {
|
||||
if (value == null || isNaN(value)) return [128, 128, 128];
|
||||
|
||||
if (value <= scale[0].price) return scale[0].color;
|
||||
if (value >= scale[scale.length - 1].price) return scale[scale.length - 1].color;
|
||||
|
||||
for (let i = 0; i < scale.length - 1; i++) {
|
||||
const lower = scale[i];
|
||||
const upper = scale[i + 1];
|
||||
if (value >= lower.price && value <= upper.price) {
|
||||
const t = (value - lower.price) / (upper.price - lower.price);
|
||||
return interpolateColor(lower.color, upper.color, t);
|
||||
for (let i = 0; i < GRADIENT.length - 1; i++) {
|
||||
const lo = GRADIENT[i];
|
||||
const hi = GRADIENT[i + 1];
|
||||
if (t >= lo.t && t <= hi.t) {
|
||||
const frac = (t - lo.t) / (hi.t - lo.t);
|
||||
return [
|
||||
Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac),
|
||||
Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac),
|
||||
Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return scale[scale.length - 1].color;
|
||||
}
|
||||
|
||||
function priceToColor(price: number | null | undefined): [number, number, number] {
|
||||
return scaleToColor(price, COLOR_SCALE);
|
||||
}
|
||||
|
||||
// Journey time color scale: green (short) -> yellow -> orange -> red (long)
|
||||
const JOURNEY_COLOR_SCALE: ColorStop[] = [
|
||||
{ price: 0, color: [46, 204, 113] }, // Green
|
||||
{ price: 30, color: [241, 196, 15] }, // Yellow
|
||||
{ price: 60, color: [231, 76, 60] }, // Red
|
||||
{ price: 120, color: [142, 68, 173] }, // Purple
|
||||
];
|
||||
|
||||
function journeyTimeToColor(minutes: number | null | undefined): [number, number, number] {
|
||||
return scaleToColor(minutes, JOURNEY_COLOR_SCALE);
|
||||
return GRADIENT[GRADIENT.length - 1].color;
|
||||
}
|
||||
|
||||
function zoomToResolution(zoom: number): number {
|
||||
|
|
@ -271,7 +237,6 @@ function getBoundsFromViewState(viewState: ViewState, width: number, height: num
|
|||
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;
|
||||
|
|
@ -281,7 +246,7 @@ function getBoundsFromViewState(viewState: ViewState, width: number, height: num
|
|||
|
||||
// Convert pixel Y back to latitude
|
||||
const pixelYToLat = (pixelY: number): number => {
|
||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize)); // Clamp to avoid edge cases
|
||||
const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize));
|
||||
const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY)));
|
||||
return (latRadians * 180) / Math.PI;
|
||||
};
|
||||
|
|
@ -315,7 +280,7 @@ function DeckOverlay({
|
|||
return null;
|
||||
}
|
||||
|
||||
export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
|
||||
export default function Map({ data, pois, onViewChange, activeFeature, features }: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(INITIAL_VIEW);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
|
@ -355,7 +320,6 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
|
|||
const map = evt.target;
|
||||
for (const layer of map.getStyle().layers || []) {
|
||||
if (layer.type !== 'symbol') continue;
|
||||
// Stronger white halo so text pops over hex fills
|
||||
map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)');
|
||||
map.setPaintProperty(layer.id, 'text-halo-width', 2);
|
||||
map.setPaintProperty(layer.id, 'text-color', '#222');
|
||||
|
|
@ -383,24 +347,32 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
|
||||
const layers = useMemo(
|
||||
() => [
|
||||
new H3HexagonLayer<HexagonData>({
|
||||
id: 'h3-hexagons',
|
||||
data,
|
||||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) =>
|
||||
colorMode === 'journey_time'
|
||||
? journeyTimeToColor(d.median_journey_minutes)
|
||||
: priceToColor(d.avg_price),
|
||||
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);
|
||||
},
|
||||
updateTriggers: {
|
||||
getFillColor: colorMode,
|
||||
getFillColor: [colorFeatureName, colorFeatureMeta],
|
||||
},
|
||||
extruded: false,
|
||||
pickable: true,
|
||||
opacity: 0.5,
|
||||
highPrecision: true,
|
||||
// Render below labels so road names, place names etc. stay visible
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: LABEL_LAYER_ID,
|
||||
}),
|
||||
|
|
@ -420,41 +392,39 @@ export default function Map({ data, pois, onViewChange, colorMode }: MapProps) {
|
|||
onHover: handlePoiHover,
|
||||
}),
|
||||
],
|
||||
[data, pois, handlePoiHover, colorMode]
|
||||
[data, pois, handlePoiHover, colorFeatureName, colorFeatureMeta]
|
||||
);
|
||||
|
||||
const getTooltip = useCallback(({ object }: { object?: HexagonData }) => {
|
||||
if (!object || !('h3' in object)) return null;
|
||||
const getTooltip = useCallback(
|
||||
({ object }: { object?: HexagonData }) => {
|
||||
if (!object || !('h3' in object)) return null;
|
||||
|
||||
const hex = object as HexagonData;
|
||||
const journeyLines: string[] = [];
|
||||
if (hex.median_pt_quick_minutes != null)
|
||||
journeyLines.push(`🚇 Quick PT: ${hex.median_pt_quick_minutes} min`);
|
||||
if (hex.median_pt_easy_minutes != null)
|
||||
journeyLines.push(`🚌 Easy PT: ${hex.median_pt_easy_minutes} min`);
|
||||
if (hex.median_cycling_minutes != null)
|
||||
journeyLines.push(`🚲 Cycling: ${hex.median_cycling_minutes} min`);
|
||||
const journeyTimeHtml =
|
||||
journeyLines.length > 0
|
||||
? `<div style="color: #0066cc; margin-top: 4px; font-size: 12px;">${journeyLines.join('<br/>')}</div>`
|
||||
: '';
|
||||
const hex = object;
|
||||
const lines: string[] = [];
|
||||
lines.push(`<strong>${(hex.count as number).toLocaleString()} properties</strong>`);
|
||||
|
||||
return {
|
||||
html: `<div style="padding: 8px; font-size: 14px;">
|
||||
<strong>Avg: £${hex.avg_price?.toLocaleString() || 'N/A'}</strong>
|
||||
<div style="color: #666; font-size: 12px;">
|
||||
${hex.count} sales<br/>
|
||||
Range: £${hex.min_price?.toLocaleString()} - £${hex.max_price?.toLocaleString()}
|
||||
</div>
|
||||
${journeyTimeHtml}
|
||||
</div>`,
|
||||
style: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
for (const f of features) {
|
||||
const minVal = hex[`min_${f.name}`];
|
||||
const maxVal = hex[`max_${f.name}`];
|
||||
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;' : '';
|
||||
lines.push(`<div style="${highlight}">${f.label}: ${minStr} - ${maxStr}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<div style="padding: 8px; font-size: 12px;">${lines.join('')}</div>`,
|
||||
style: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
},
|
||||
};
|
||||
},
|
||||
[features, colorFeatureName]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full relative" ref={containerRef}>
|
||||
|
|
|
|||
|
|
@ -1,19 +1 @@
|
|||
import type { Filters } from '../types';
|
||||
|
||||
// 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 = 5000000; // £5M max for slider, but no server-side cap
|
||||
export const PRICE_STEP = 50000;
|
||||
|
||||
export const DEFAULT_FILTERS: Filters = {
|
||||
minYear: 2020,
|
||||
maxYear: YEAR_MAX,
|
||||
minPrice: PRICE_MIN,
|
||||
maxPrice: PRICE_MAX,
|
||||
};
|
||||
// No hardcoded filter constants - features are discovered dynamically from the API.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
export interface Filters {
|
||||
minYear: number;
|
||||
maxYear: number;
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
export interface FeatureMeta {
|
||||
name: string;
|
||||
min: number;
|
||||
max: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Filters: feature name -> [selectedMin, selectedMax]
|
||||
export type FeatureFilters = Record<string, [number, number]>;
|
||||
|
||||
export interface HexagonData {
|
||||
h3: string;
|
||||
count: number;
|
||||
[key: string]: string | number | null;
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
|
|
@ -12,21 +21,6 @@ export interface Bounds {
|
|||
east: number;
|
||||
}
|
||||
|
||||
export interface HexagonData {
|
||||
h3: string;
|
||||
count: number;
|
||||
avg_price: number;
|
||||
median_price: number;
|
||||
min_price: number;
|
||||
max_price: number;
|
||||
median_journey_minutes: number | null;
|
||||
median_pt_easy_minutes: number | null;
|
||||
median_pt_quick_minutes: number | null;
|
||||
median_cycling_minutes: number | null;
|
||||
}
|
||||
|
||||
export type ColorMode = 'price' | 'journey_time';
|
||||
|
||||
export interface ViewState {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
|
|
@ -60,6 +54,7 @@ export interface POIResponse {
|
|||
export interface POICategoryInfo {
|
||||
emoji: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type POICategoriesMap = Record<string, POICategoryInfo>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue