diff --git a/frontend/src/components/map/MapPageSelectionPane.tsx b/frontend/src/components/map/MapPageSelectionPane.tsx
new file mode 100644
index 0000000..dc6d697
--- /dev/null
+++ b/frontend/src/components/map/MapPageSelectionPane.tsx
@@ -0,0 +1,72 @@
+import type { PointerEvent, ReactNode } from 'react';
+
+import { TabButton } from '../ui/TabButton';
+import { CloseIcon } from '../ui/icons/CloseIcon';
+
+interface ResizeHandlers {
+ onPointerDown: (event: PointerEvent) => void;
+ onPointerMove: (event: PointerEvent) => void;
+ onPointerUp: () => void;
+}
+
+interface MapPageSelectionPaneProps {
+ width: number;
+ resizeHandlers: ResizeHandlers;
+ tab: 'properties' | 'area';
+ onAreaTabClick: () => void;
+ onPropertiesTabClick: () => void;
+ onClose: () => void;
+ renderAreaPane: () => ReactNode;
+ renderPropertiesPane: () => ReactNode;
+}
+
+export function MapPageSelectionPane({
+ width,
+ resizeHandlers,
+ tab,
+ onAreaTabClick,
+ onPropertiesTabClick,
+ onClose,
+ renderAreaPane,
+ renderPropertiesPane,
+}: MapPageSelectionPaneProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {tab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/usePoiLayers.test.ts b/frontend/src/hooks/usePoiLayers.test.ts
new file mode 100644
index 0000000..7b46459
--- /dev/null
+++ b/frontend/src/hooks/usePoiLayers.test.ts
@@ -0,0 +1,128 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import type { POI } from '../types';
+import { usePoiLayers } from './usePoiLayers';
+
+const supermarket: POI = {
+ id: 'poi-1',
+ name: 'Market Hall',
+ category: 'Supermarket',
+ group: 'Groceries',
+ lat: 51.5,
+ lng: -0.12,
+ emoji: 'π',
+};
+
+const busStop: POI = {
+ id: 'poi-2',
+ name: 'High Street Stop',
+ category: 'Bus stop',
+ group: 'Public Transport',
+ lat: 51.501,
+ lng: -0.121,
+ emoji: 'π',
+};
+
+function layerById(layers: readonly unknown[], id: string) {
+ const layer = layers.find((item) => (item as { id?: string }).id === id);
+ if (!layer) throw new Error(`Layer ${id} not found`);
+ return layer as { props: Record };
+}
+
+describe('usePoiLayers', () => {
+ it('returns the expected layer stack', () => {
+ const { result } = renderHook(() =>
+ usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
+ );
+
+ expect(result.current.poiLayers.map((layer) => layer.id)).toEqual([
+ 'poi-shadow',
+ 'poi-background',
+ 'poi-icons',
+ 'poi-clusters',
+ 'poi-cluster-text',
+ ]);
+ });
+
+ it('hides minor POI categories until the configured zoom threshold', () => {
+ const { result, rerender } = renderHook(
+ ({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
+ { initialProps: { zoom: 13 } }
+ );
+
+ expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
+
+ rerender({ zoom: 14 });
+
+ expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([busStop]);
+ });
+
+ it('keeps POI hover popup state in sync with layer hover events', () => {
+ const { result } = renderHook(() =>
+ usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
+ );
+ const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
+
+ act(() => {
+ (backgroundLayer.props.onHover as (info: unknown) => void)({
+ object: supermarket,
+ x: 42,
+ y: 88,
+ });
+ });
+
+ expect(result.current.popupInfo).toEqual({
+ x: 42,
+ y: 88,
+ name: supermarket.name,
+ category: supermarket.category,
+ group: supermarket.group,
+ emoji: supermarket.emoji,
+ id: supermarket.id,
+ });
+
+ act(() => {
+ result.current.clearPopupInfo();
+ });
+
+ expect(result.current.popupInfo).toBeNull();
+ });
+
+ it('creates cluster hover popup state from clustered POIs', () => {
+ const clusteredPois = Array.from(
+ { length: 4 },
+ (_, index): POI => ({
+ ...supermarket,
+ id: `clustered-${index}`,
+ name: `Clustered ${index}`,
+ lat: 51.5 + index * 0.0001,
+ lng: -0.12 - index * 0.0001,
+ })
+ );
+ const { result } = renderHook(() =>
+ usePoiLayers({ pois: clusteredPois, zoom: 5, isDark: true })
+ );
+ const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters');
+ const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>;
+
+ expect(clusters).toHaveLength(1);
+ expect(clusters[0].count).toBe(4);
+
+ act(() => {
+ (clusterLayer.props.onHover as (info: unknown) => void)({
+ object: clusters[0],
+ x: 12,
+ y: 24,
+ });
+ });
+
+ expect(result.current.popupInfo).toMatchObject({
+ x: 12,
+ y: 24,
+ name: '4 places',
+ isCluster: true,
+ clusterCount: 4,
+ });
+ });
+});
diff --git a/frontend/src/hooks/usePoiLayers.ts b/frontend/src/hooks/usePoiLayers.ts
new file mode 100644
index 0000000..4291a95
--- /dev/null
+++ b/frontend/src/hooks/usePoiLayers.ts
@@ -0,0 +1,238 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import type { PickingInfo } from '@deck.gl/core';
+import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
+import Supercluster from 'supercluster';
+
+import type { POI } from '../types';
+import {
+ POI_GROUP_COLORS,
+ POI_DEFAULT_COLOR,
+ MINOR_POI_CATEGORIES,
+ MINOR_POI_ZOOM_THRESHOLD,
+ POI_CLUSTER_RADIUS,
+ POI_CLUSTER_MAX_ZOOM,
+} from '../lib/consts';
+import { emojiToTwemojiUrl } from '../lib/map-utils';
+
+export interface PopupInfo {
+ x: number;
+ y: number;
+ name: string;
+ category: string;
+ group: string;
+ emoji: string;
+ id: string;
+ isCluster?: boolean;
+ clusterCount?: number;
+}
+
+interface ClusterPoint {
+ lng: number;
+ lat: number;
+ count: number;
+ clusterId: number;
+}
+
+interface UsePoiLayersProps {
+ pois: POI[];
+ zoom: number;
+ isDark: boolean;
+}
+
+export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
+ const [popupInfo, setPopupInfo] = useState(null);
+
+ const handlePoiHover = useCallback((info: PickingInfo) => {
+ if (info.object && info.x !== undefined && info.y !== undefined) {
+ setPopupInfo({
+ x: info.x,
+ y: info.y,
+ name: info.object.name,
+ category: info.object.category,
+ group: info.object.group,
+ emoji: info.object.emoji,
+ id: info.object.id,
+ });
+ } else {
+ setPopupInfo(null);
+ }
+ }, []);
+
+ const handlePoiHoverRef = useRef(handlePoiHover);
+ handlePoiHoverRef.current = handlePoiHover;
+ const stablePoiHover = useCallback((info: PickingInfo) => {
+ handlePoiHoverRef.current(info);
+ }, []);
+
+ const handleClusterHover = useCallback((info: PickingInfo) => {
+ if (info.object && info.x !== undefined && info.y !== undefined) {
+ setPopupInfo({
+ x: info.x,
+ y: info.y,
+ name: `${info.object.count} places`,
+ category: 'Zoom in to see details',
+ group: '',
+ emoji: '',
+ id: '',
+ isCluster: true,
+ clusterCount: info.object.count,
+ });
+ } else {
+ setPopupInfo(null);
+ }
+ }, []);
+
+ const handleClusterHoverRef = useRef(handleClusterHover);
+ handleClusterHoverRef.current = handleClusterHover;
+ const stableClusterHover = useCallback((info: PickingInfo) => {
+ handleClusterHoverRef.current(info);
+ }, []);
+
+ const clusterIndex = useMemo(() => {
+ if (pois.length === 0) return null;
+ const index = new Supercluster({
+ radius: POI_CLUSTER_RADIUS,
+ maxZoom: POI_CLUSTER_MAX_ZOOM,
+ });
+ const features: Supercluster.PointFeature[] = pois.map((poi) => ({
+ type: 'Feature',
+ geometry: { type: 'Point', coordinates: [poi.lng, poi.lat] },
+ properties: poi,
+ }));
+ index.load(features);
+ return index;
+ }, [pois]);
+
+ const clusterZoom = Math.floor(zoom);
+ const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
+
+ const { visiblePois, clusters } = useMemo(() => {
+ if (!clusterIndex || pois.length === 0) {
+ return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const allFeatures = clusterIndex.getClusters([-180, -85, 180, 85], clusterZoom) as any[];
+ const individual: POI[] = [];
+ const clusterPoints: ClusterPoint[] = [];
+ for (const feature of allFeatures) {
+ if (feature.properties.cluster) {
+ clusterPoints.push({
+ lng: feature.geometry.coordinates[0],
+ lat: feature.geometry.coordinates[1],
+ count: feature.properties.point_count,
+ clusterId: feature.properties.cluster_id,
+ });
+ } else {
+ const poi = feature.properties as POI;
+ if (!showMinorPois && MINOR_POI_CATEGORIES.has(poi.category)) continue;
+ individual.push(poi);
+ }
+ }
+ return { visiblePois: individual, clusters: clusterPoints };
+ }, [clusterIndex, clusterZoom, showMinorPois, pois]);
+
+ const poiShadowLayer = useMemo(
+ () =>
+ new ScatterplotLayer({
+ id: 'poi-shadow',
+ data: visiblePois,
+ getPosition: (d) => [d.lng, d.lat],
+ getRadius: 16,
+ radiusUnits: 'pixels',
+ getFillColor: isDark ? [0, 0, 0, 50] : [0, 0, 0, 25],
+ pickable: false,
+ transitions: { getRadius: { duration: 300, enter: () => [0] } },
+ }),
+ [visiblePois, isDark]
+ );
+
+ const poiBackgroundLayer = useMemo(
+ () =>
+ new ScatterplotLayer({
+ id: 'poi-background',
+ data: visiblePois,
+ getPosition: (d) => [d.lng, d.lat],
+ getRadius: 14,
+ radiusUnits: 'pixels',
+ getFillColor: isDark ? [41, 37, 36, 255] : [255, 255, 255, 255],
+ getLineColor: (d) => {
+ const c = POI_GROUP_COLORS[d.group] || POI_DEFAULT_COLOR;
+ return [c[0], c[1], c[2], 255] as [number, number, number, number];
+ },
+ getLineWidth: 2.5,
+ lineWidthUnits: 'pixels',
+ stroked: true,
+ pickable: true,
+ onHover: stablePoiHover,
+ transitions: { getRadius: { duration: 300, enter: () => [0] } },
+ }),
+ [visiblePois, isDark, stablePoiHover]
+ );
+
+ const poiIconLayer = useMemo(
+ () =>
+ new IconLayer({
+ id: 'poi-icons',
+ data: visiblePois,
+ getPosition: (d) => [d.lng, d.lat],
+ getIcon: (d) => ({
+ url: emojiToTwemojiUrl(d.emoji),
+ width: 72,
+ height: 72,
+ }),
+ getSize: 18,
+ sizeUnits: 'pixels',
+ pickable: false,
+ transitions: { getSize: { duration: 300, enter: () => [0] } },
+ }),
+ [visiblePois]
+ );
+
+ const clusterCircleLayer = useMemo(
+ () =>
+ new ScatterplotLayer({
+ id: 'poi-clusters',
+ data: clusters,
+ getPosition: (d) => [d.lng, d.lat],
+ getRadius: (d) => Math.min(30, 14 + Math.sqrt(d.count) * 2),
+ radiusUnits: 'pixels',
+ getFillColor: isDark ? [5, 129, 114, 220] : [20, 184, 166, 220],
+ getLineColor: [255, 255, 255, isDark ? 60 : 120],
+ getLineWidth: 2,
+ lineWidthUnits: 'pixels',
+ stroked: true,
+ pickable: true,
+ onHover: stableClusterHover,
+ transitions: { getRadius: { duration: 300, enter: () => [0] } },
+ }),
+ [clusters, isDark, stableClusterHover]
+ );
+
+ const clusterTextLayer = useMemo(
+ () =>
+ new TextLayer({
+ id: 'poi-cluster-text',
+ data: clusters,
+ getPosition: (d) => [d.lng, d.lat],
+ getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
+ getSize: 12,
+ getColor: [255, 255, 255, 255],
+ fontWeight: 700,
+ fontFamily: 'Inter, system-ui, sans-serif',
+ getTextAnchor: 'middle',
+ getAlignmentBaseline: 'center',
+ sizeUnits: 'pixels',
+ pickable: false,
+ }),
+ [clusters]
+ );
+
+ const poiLayers = useMemo(
+ () => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
+ [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
+ );
+
+ const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
+
+ return { poiLayers, popupInfo, clearPopupInfo };
+}
diff --git a/frontend/src/hooks/useTravelTime.test.ts b/frontend/src/hooks/useTravelTime.test.ts
new file mode 100644
index 0000000..d598495
--- /dev/null
+++ b/frontend/src/hooks/useTravelTime.test.ts
@@ -0,0 +1,68 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { travelFieldKey, useTravelTime, type TravelTimeEntry } from './useTravelTime';
+
+describe('useTravelTime', () => {
+ it('creates backend field keys from mode and destination slug', () => {
+ expect(
+ travelFieldKey({
+ mode: 'transit',
+ slug: 'kings-cross',
+ label: 'Kings Cross',
+ timeRange: [0, 45],
+ useBest: true,
+ })
+ ).toBe('tt_transit_kings-cross');
+ });
+
+ it('adds, updates, toggles, and removes travel-time entries', () => {
+ const { result } = renderHook(() => useTravelTime());
+
+ act(() => result.current.handleAddEntry('transit'));
+ expect(result.current.entries).toEqual([
+ { mode: 'transit', slug: '', label: '', timeRange: null, useBest: false },
+ ]);
+ expect(result.current.activeEntries).toEqual([]);
+
+ act(() => result.current.handleSetDestination(0, 'bank', 'Bank'));
+ expect(result.current.entries[0]).toMatchObject({
+ slug: 'bank',
+ label: 'Bank',
+ timeRange: [0, 120],
+ });
+ expect(result.current.activeEntries).toHaveLength(1);
+
+ act(() => result.current.handleTimeRangeChange(0, [10, 35]));
+ expect(result.current.entries[0].timeRange).toEqual([10, 35]);
+
+ act(() => result.current.handleToggleBest(0));
+ expect(result.current.entries[0].useBest).toBe(true);
+
+ act(() => result.current.handleRemoveEntry(0));
+ expect(result.current.entries).toEqual([]);
+ });
+
+ it('replaces entries wholesale for AI-generated filters', () => {
+ const initial: TravelTimeEntry = {
+ mode: 'walking',
+ slug: 'old',
+ label: 'Old',
+ timeRange: [0, 20],
+ useBest: false,
+ };
+ const replacement: TravelTimeEntry = {
+ mode: 'car',
+ slug: 'new',
+ label: 'New',
+ timeRange: [5, 30],
+ useBest: false,
+ };
+ const { result } = renderHook(() => useTravelTime({ entries: [initial] }));
+
+ act(() => result.current.handleSetEntries([replacement]));
+
+ expect(result.current.entries).toEqual([replacement]);
+ expect(result.current.activeEntries).toEqual([replacement]);
+ });
+});
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..b39e589
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+
+import type { FeatureMeta } from '../types';
+import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
+
+describe('api utilities', () => {
+ it('builds API URLs from endpoint names, paths, and params', () => {
+ expect(apiUrl('features')).toBe('/api/features');
+ expect(apiUrl('/custom/path')).toBe('/custom/path');
+ expect(apiUrl('hexagons', new URLSearchParams({ bounds: '1,2,3,4' }))).toBe(
+ '/api/hexagons?bounds=1%2C2%2C3%2C4'
+ );
+ });
+
+ it('throws helpful errors for non-OK responses', () => {
+ expect(() => assertOk(new Response(null, { status: 204 }), 'empty')).not.toThrow();
+ expect(() =>
+ assertOk(new Response(null, { status: 404, statusText: 'Not Found' }), 'lookup')
+ ).toThrow('lookup: HTTP 404 Not Found');
+ });
+
+ it('recognizes AbortError instances', () => {
+ const abort = new Error('Aborted');
+ abort.name = 'AbortError';
+ const regular = new Error('nope');
+
+ expect(isAbortError(abort)).toBe(true);
+ expect(isAbortError(regular)).toBe(false);
+ });
+
+ it('serializes numeric, absolute, and enum filters for backend routes', () => {
+ const features: FeatureMeta[] = [
+ { name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
+ {
+ name: 'Estimated current price',
+ type: 'numeric',
+ absolute: true,
+ histogram: { min: 0, max: 2_000_000, p1: 0, p99: 2_000_000, counts: [1] },
+ },
+ { name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
+ ];
+
+ expect(
+ buildFilterString(
+ {
+ 'Last known price': [100_000, 500_000],
+ 'Estimated current price': [0, 2_000_000],
+ 'Property type': ['Flat', 'House'],
+ },
+ features
+ )
+ ).toBe(
+ 'Last known price:100000:500000;;Estimated current price:0:inf;;Property type:Flat|House'
+ );
+
+ expect(
+ buildFilterString(
+ {
+ 'Last known price': [100_000, 500_000],
+ 'Property type': ['Flat'],
+ },
+ features,
+ 'Last known price'
+ )
+ ).toBe('Property type:Flat');
+ });
+});
diff --git a/frontend/src/lib/external-search.test.ts b/frontend/src/lib/external-search.test.ts
new file mode 100644
index 0000000..2255a35
--- /dev/null
+++ b/frontend/src/lib/external-search.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildPropertySearchUrls } from './external-search';
+
+describe('external property search URLs', () => {
+ it('returns null when no postcode is available', () => {
+ expect(
+ buildPropertySearchUrls({
+ location: { lat: 51.5, lon: -0.1, resolution: 8 },
+ filters: {},
+ })
+ ).toBeNull();
+ });
+
+ it('builds Rightmove, OnTheMarket, and Zoopla URLs with snapped filters', () => {
+ const urls = buildPropertySearchUrls({
+ location: {
+ lat: 51.501,
+ lon: -0.141,
+ resolution: 8,
+ postcode: 'SW1A 1AA',
+ isPostcode: false,
+ },
+ rightmoveLocationId: 'POSTCODE^123456',
+ filters: {
+ 'Last known price': [123_456, 376_000],
+ 'Property type': ['Detached', 'Flats/Maisonettes'],
+ 'Leasehold/Freehold': ['Freehold'],
+ 'Number of bedrooms & living rooms': [2, 4],
+ },
+ });
+
+ expect(urls).not.toBeNull();
+ const rightmove = new URL(urls!.rightmove!);
+ const onthemarket = new URL(urls!.onthemarket);
+ const zoopla = new URL(urls!.zoopla);
+
+ expect(rightmove.hostname).toBe('www.rightmove.co.uk');
+ expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
+ expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^123456');
+ expect(rightmove.searchParams.get('radius')).toBe('0.5');
+ expect(rightmove.searchParams.get('minPrice')).toBe('120000');
+ expect(rightmove.searchParams.get('maxPrice')).toBe('400000');
+ expect(rightmove.searchParams.get('minBedrooms')).toBe('1');
+ expect(rightmove.searchParams.get('maxBedrooms')).toBe('3');
+ expect(rightmove.searchParams.get('propertyTypes')).toBe('detached,flat');
+ expect(rightmove.searchParams.get('tenureTypes')).toBe('FREEHOLD');
+
+ expect(onthemarket.pathname).toBe('/for-sale/property/sw1a-1aa/');
+ expect(onthemarket.searchParams.get('radius')).toBe('0.5');
+ expect(onthemarket.searchParams.get('min-price')).toBe('120000');
+ expect(onthemarket.searchParams.get('max-price')).toBe('400000');
+ expect(onthemarket.searchParams.getAll('prop-types')).toEqual(['detached', 'flats']);
+
+ expect(zoopla.searchParams.get('q')).toBe('SW1A 1AA');
+ expect(zoopla.searchParams.get('radius')).toBe('0.5');
+ expect(zoopla.searchParams.get('price_min')).toBe('100000');
+ expect(zoopla.searchParams.get('price_max')).toBe('400000');
+ expect(zoopla.searchParams.getAll('property_sub_type')).toEqual(['detached', 'flat']);
+ });
+
+ it('omits Rightmove when location identifier is missing and uses zero radius for postcodes', () => {
+ const urls = buildPropertySearchUrls({
+ location: {
+ lat: 51.501,
+ lon: -0.141,
+ resolution: 9,
+ postcode: 'E1 6AN',
+ isPostcode: true,
+ },
+ filters: {},
+ });
+
+ expect(urls?.rightmove).toBeNull();
+ expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.25');
+ expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
+ });
+});
diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts
new file mode 100644
index 0000000..65be1f6
--- /dev/null
+++ b/frontend/src/lib/format.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildPercentileScale,
+ calculateHistogramMean,
+ formatFilterValue,
+ formatTransactionDate,
+ parseInputValue,
+ roundedPercentages,
+} from './format';
+
+describe('format utilities', () => {
+ it('formats compact filter values and transaction dates', () => {
+ expect(formatFilterValue(1250)).toBe('1.3k');
+ expect(formatFilterValue(1_250_000)).toBe('1.3M');
+ expect(formatFilterValue(1250, true)).toBe('1250');
+ expect(formatTransactionDate(2024.5)).toBe('Jul 2024');
+ });
+
+ it('parses user-entered compact numeric values', () => {
+ expect(parseInputValue('Β£1.25M', { prefix: 'Β£', step: 5000 })).toBe(1_250_000);
+ expect(parseInputValue('45 sqm', { suffix: ' sqm' })).toBe(45);
+ expect(parseInputValue('2.5δΈ')).toBe(25_000);
+ expect(parseInputValue('not a number')).toBeNull();
+ });
+
+ it('rounds percentages so displayed values sum to 100', () => {
+ expect(roundedPercentages([1, 1, 1], 3)).toEqual([34, 33, 33]);
+ expect(roundedPercentages([1, 2, 3], 6, 1)).toEqual([16.7, 33.3, 50]);
+ expect(roundedPercentages([5, 5], 0)).toEqual([0, 0]);
+ });
+
+ it('maps histogram percentiles and weighted means consistently', () => {
+ const histogram = { min: 0, p1: 10, p99: 90, max: 100, counts: [10, 80, 10] };
+ const scale = buildPercentileScale(histogram);
+
+ expect(scale.toValue(0)).toBe(0);
+ expect(scale.toValue(50)).toBeCloseTo(50);
+ expect(scale.toPercentile(50)).toBeCloseTo(50);
+ expect(calculateHistogramMean(histogram)).toBeCloseTo(50);
+ });
+});
diff --git a/frontend/src/lib/map-utils.test.ts b/frontend/src/lib/map-utils.test.ts
new file mode 100644
index 0000000..729c7c8
--- /dev/null
+++ b/frontend/src/lib/map-utils.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+
+import { DENSITY_GRADIENT, ENUM_PALETTE, FEATURE_GRADIENT } from './consts';
+import {
+ emojiToTwemojiUrl,
+ enumIndexToColor,
+ getBoundsFromViewState,
+ getFeatureFillColor,
+ zoomToResolution,
+} from './map-utils';
+
+describe('map utilities', () => {
+ it('maps zoom levels to H3 resolutions at configured thresholds', () => {
+ expect(zoomToResolution(6.9)).toBe(5);
+ expect(zoomToResolution(7)).toBe(6);
+ expect(zoomToResolution(10.6)).toBe(8);
+ expect(zoomToResolution(14)).toBe(9);
+ });
+
+ it('computes buffered bounds around a view state', () => {
+ const bounds = getBoundsFromViewState(
+ { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 },
+ 1200,
+ 800
+ );
+
+ expect(bounds.south).toBeLessThan(51.5);
+ expect(bounds.north).toBeGreaterThan(51.5);
+ expect(bounds.west).toBeLessThan(-0.1);
+ expect(bounds.east).toBeGreaterThan(-0.1);
+ });
+
+ it('builds twemoji URLs and wraps enum colors', () => {
+ expect(emojiToTwemojiUrl('π')).toBe('/assets/twemoji/1f6d2.png');
+ expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');
+ expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
+ });
+
+ it('returns fallback, filtered, enum, feature, and density colors', () => {
+ expect(
+ getFeatureFillColor(
+ null,
+ undefined,
+ undefined,
+ [0, 100],
+ null,
+ 0,
+ DENSITY_GRADIENT,
+ false,
+ 180
+ )
+ ).toEqual([128, 128, 128, 80]);
+
+ expect(
+ getFeatureFillColor(50, 50, 60, [0, 100], [70, 90], 0, DENSITY_GRADIENT, true, 180)
+ ).toEqual([60, 55, 50, 60]);
+
+ expect(
+ getFeatureFillColor(1, 1, 1, [0, 2], null, 0, DENSITY_GRADIENT, false, 180, 3, ENUM_PALETTE)
+ ).toEqual([...ENUM_PALETTE[1], 180]);
+
+ expect(
+ getFeatureFillColor(
+ 0,
+ 0,
+ 100,
+ [0, 100],
+ null,
+ 0,
+ DENSITY_GRADIENT,
+ false,
+ 200,
+ 0,
+ undefined,
+ FEATURE_GRADIENT
+ )
+ ).toEqual([...FEATURE_GRADIENT[0].color, 200]);
+
+ expect(
+ getFeatureFillColor(
+ undefined,
+ undefined,
+ undefined,
+ null,
+ null,
+ 0,
+ DENSITY_GRADIENT,
+ false,
+ 150
+ )
+ ).toEqual([...DENSITY_GRADIENT[0].color, 150]);
+ });
+});
diff --git a/frontend/src/lib/url-state.test.ts b/frontend/src/lib/url-state.test.ts
new file mode 100644
index 0000000..f9c8f94
--- /dev/null
+++ b/frontend/src/lib/url-state.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import type { FeatureMeta } from '../types';
+import { parseUrlState, stateToParams } from './url-state';
+
+describe('url-state', () => {
+ beforeEach(() => {
+ window.history.replaceState({}, '', '/');
+ });
+
+ it('parses view, filters, POIs, tab, postcode, and travel-time params', () => {
+ window.history.replaceState(
+ {},
+ '',
+ '/?lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&filter=Property%20type:Flat|House&poi=supermarket&tab=properties&pc=SW1A%201AA&tt=transit:kings-cross:Kings%20Cross:b:0:30'
+ );
+
+ const state = parseUrlState();
+
+ expect(state.viewState).toEqual({
+ latitude: 51.5074,
+ longitude: -0.1278,
+ zoom: 12.5,
+ pitch: 0,
+ });
+ expect(state.filters).toEqual({
+ 'Last known price': [100000, 500000],
+ 'Property type': ['Flat', 'House'],
+ });
+ expect(state.poiCategories).toEqual(new Set(['supermarket']));
+ expect(state.tab).toBe('properties');
+ expect(state.postcode).toBe('SW1A 1AA');
+ expect(state.travelTime?.entries).toEqual([
+ {
+ mode: 'transit',
+ slug: 'kings-cross',
+ label: 'Kings Cross',
+ timeRange: [0, 30],
+ useBest: true,
+ },
+ ]);
+ });
+
+ it('serializes map state and active filters into stable URL params', () => {
+ const features: FeatureMeta[] = [
+ { name: 'Last known price', type: 'numeric' },
+ { name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
+ ];
+
+ const params = stateToParams(
+ { latitude: 51.50742, longitude: -0.12781, zoom: 12.47 },
+ {
+ 'Last known price': [100000, 500000],
+ 'Property type': ['Flat', 'House'],
+ },
+ features,
+ new Set(['supermarket']),
+ 'properties',
+ [
+ {
+ mode: 'bicycle',
+ slug: 'bank',
+ label: 'Bank',
+ useBest: false,
+ timeRange: [5, 25],
+ },
+ ]
+ );
+
+ expect(params.get('lat')).toBe('51.5074');
+ expect(params.get('lon')).toBe('-0.1278');
+ expect(params.get('zoom')).toBe('12.5');
+ expect(params.getAll('filter')).toEqual([
+ 'Last known price:100000:500000',
+ 'Property type:Flat|House',
+ ]);
+ expect(params.getAll('poi')).toEqual(['supermarket']);
+ expect(params.get('tab')).toBe('properties');
+ expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
+ });
+
+ it('omits the default area tab', () => {
+ const params = stateToParams(null, {}, [], new Set(), 'area');
+
+ expect(params.has('tab')).toBe(false);
+ });
+});
diff --git a/screenshot/src/validation.test.ts b/screenshot/src/validation.test.ts
new file mode 100644
index 0000000..79ac1f1
--- /dev/null
+++ b/screenshot/src/validation.test.ts
@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { buildScreenshotRequest, ValidationError } from './validation.js';
+
+test('buildScreenshotRequest accepts supported screenshot parameters', () => {
+ const result = buildScreenshotRequest({
+ lat: '51.5074',
+ lon: '-0.1278',
+ zoom: '12.5',
+ tab: 'properties',
+ og: '1',
+ path: '/invite/abc123',
+ filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'],
+ poi: 'supermarket',
+ tt: 'transit:kings-cross:Kings Cross:b:0:30',
+ });
+
+ assert.equal(result.pagePath, '/invite/abc123');
+ assert.equal(result.qs.get('lat'), '51.5074');
+ assert.equal(result.qs.get('lon'), '-0.1278');
+ assert.equal(result.qs.get('zoom'), '12.5');
+ assert.equal(result.qs.get('tab'), 'properties');
+ assert.deepEqual(result.qs.getAll('filter'), [
+ 'Last known price:100000:500000',
+ 'Total floor area (sqm):50:150',
+ ]);
+});
+
+test('buildScreenshotRequest rejects invalid numeric values', () => {
+ assert.throws(
+ () => buildScreenshotRequest({ lat: '91', lon: '-0.1', zoom: '12' }),
+ ValidationError,
+ );
+ assert.throws(
+ () => buildScreenshotRequest({ lat: '51abc', lon: '-0.1', zoom: '12' }),
+ ValidationError,
+ );
+});
+
+test('buildScreenshotRequest rejects unsafe paths', () => {
+ assert.throws(() => buildScreenshotRequest({ path: '//example.com' }), ValidationError);
+ assert.throws(() => buildScreenshotRequest({ path: '/../../etc/passwd' }), ValidationError);
+});
+
+test('buildScreenshotRequest limits repeated parameters', () => {
+ assert.throws(
+ () =>
+ buildScreenshotRequest({
+ filter: Array.from({ length: 41 }, (_, index) => `Feature ${index}:0:1`),
+ }),
+ ValidationError,
+ );
+});
+
+test('buildScreenshotRequest rejects control characters', () => {
+ assert.throws(() => buildScreenshotRequest({ filter: 'Feature:\u0000:1' }), ValidationError);
+});
diff --git a/screenshot/src/validation.ts b/screenshot/src/validation.ts
new file mode 100644
index 0000000..f295616
--- /dev/null
+++ b/screenshot/src/validation.ts
@@ -0,0 +1,114 @@
+export class ValidationError extends Error {
+ status = 400;
+}
+
+export interface ValidatedScreenshotRequest {
+ pagePath: string;
+ qs: URLSearchParams;
+}
+
+const MAX_REPEATED_PARAMS = 40;
+const MAX_VALUE_LENGTH = 500;
+const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
+const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
+const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
+const REPEATED_KEYS = ['filter', 'poi', 'tt'] as const;
+
+type Query = Record;
+
+function validationError(message: string): never {
+ throw new ValidationError(message);
+}
+
+function firstString(query: Query, key: string): string | undefined {
+ const value = query[key];
+ if (value == null) return undefined;
+ if (Array.isArray(value)) {
+ validationError(`${key} must not be repeated`);
+ }
+ if (typeof value !== 'string') {
+ validationError(`${key} must be a string`);
+ }
+ return value || undefined;
+}
+
+function repeatedStrings(query: Query, key: string): string[] {
+ const value = query[key];
+ if (value == null) return [];
+ const values = Array.isArray(value) ? value : [value];
+ if (values.length > MAX_REPEATED_PARAMS) {
+ validationError(`${key} has too many values`);
+ }
+ return values.filter((item): item is string => {
+ if (typeof item !== 'string') {
+ validationError(`${key} values must be strings`);
+ }
+ return item.length > 0;
+ });
+}
+
+function assertSafeValue(key: string, value: string): void {
+ if (value.length > MAX_VALUE_LENGTH) {
+ validationError(`${key} is too long`);
+ }
+ if (!SAFE_VALUE_RE.test(value)) {
+ validationError(`${key} contains invalid characters`);
+ }
+}
+
+function appendBoundedNumber(
+ qs: URLSearchParams,
+ query: Query,
+ key: string,
+ min: number,
+ max: number,
+): void {
+ const value = firstString(query, key);
+ if (value == null) return;
+ if (value.length > 40 || !NUMERIC_RE.test(value)) {
+ validationError(`${key} must be a number`);
+ }
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < min || numeric > max) {
+ validationError(`${key} is out of range`);
+ }
+ qs.set(key, value);
+}
+
+export function buildScreenshotRequest(query: Query): ValidatedScreenshotRequest {
+ const qs = new URLSearchParams();
+
+ appendBoundedNumber(qs, query, 'lat', -90, 90);
+ appendBoundedNumber(qs, query, 'lon', -180, 180);
+ appendBoundedNumber(qs, query, 'zoom', 0, 22);
+
+ const tab = firstString(query, 'tab');
+ if (tab != null) {
+ if (tab !== 'area' && tab !== 'properties') {
+ validationError('tab is invalid');
+ }
+ qs.set('tab', tab);
+ }
+
+ const og = firstString(query, 'og');
+ if (og != null) {
+ if (og !== '1') {
+ validationError('og is invalid');
+ }
+ qs.set('og', og);
+ }
+
+ for (const key of REPEATED_KEYS) {
+ for (const value of repeatedStrings(query, key)) {
+ assertSafeValue(key, value);
+ qs.append(key, value);
+ }
+ }
+
+ const pagePath = firstString(query, 'path') ?? '/';
+ if (!PATH_RE.test(pagePath)) {
+ validationError('path is invalid');
+ }
+
+ return { pagePath, qs };
+}