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 }; +}