+
UK Property Prices
+
+
Zoom: {zoom.toFixed(1)}
+
+
+
+ onChange({ ...filters, minYear: min, maxYear: max })}
+ />
+
+
+
+
+ update('minPrice', v)}
+ />
+
+
+
+
+ update('maxPrice', v)}
+ />
+
+
+
+
Average Price
+
+
+ £0
+ £200k
+ £400k
+ £800k+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx
new file mode 100644
index 0000000..5628b96
--- /dev/null
+++ b/frontend/src/components/Map.tsx
@@ -0,0 +1,185 @@
+import { 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';
+import 'maplibre-gl/dist/maplibre-gl.css';
+import type { HexagonData, ViewState, ViewChangeParams, Bounds } from '../types';
+
+interface MapProps {
+ data: HexagonData[];
+ onViewChange: (params: ViewChangeParams) => void;
+}
+
+const INITIAL_VIEW: ViewState = {
+ longitude: -1.5,
+ latitude: 53.5,
+ zoom: 6,
+ pitch: 0,
+};
+
+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
+];
+
+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 priceToColor(price: number | null | undefined): [number, number, number] {
+ 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: number): number {
+ if (zoom < 7) return 6;
+ if (zoom < 8.5) return 7;
+ if (zoom < 9.5) return 8;
+ if (zoom < 11) return 9;
+ if (zoom < 13) return 10;
+ return 11;
+}
+
+function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds {
+ const { longitude, latitude, zoom } = viewState;
+
+ // 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: number): number => {
+ 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 };
+}
+
+interface Dimensions {
+ width: number;
+ height: number;
+}
+
+export default function Map({ data, onViewChange }: MapProps) {
+ const containerRef = useRef