Update map to do filtering

This commit is contained in:
Andras Schmelczer 2026-01-30 18:34:12 +00:00
parent 6122ee44da
commit d4fe881ef4
8 changed files with 349 additions and 372 deletions

View file

@ -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>
);
})}

View file

@ -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}>