Update map to do filtering
This commit is contained in:
parent
6122ee44da
commit
d4fe881ef4
8 changed files with 349 additions and 372 deletions
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue