these too
This commit is contained in:
parent
d4dde21ad2
commit
90c47afe17
11 changed files with 1045 additions and 0 deletions
72
frontend/src/components/map/MapPageSelectionPane.tsx
Normal file
72
frontend/src/components/map/MapPageSelectionPane.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
data-tutorial="right-pane"
|
||||||
|
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||||
|
{...resizeHandlers}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||||
|
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||||
|
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||||
|
<TabButton label="Area" isActive={tab === 'area'} onClick={onAreaTabClick} />
|
||||||
|
<TabButton
|
||||||
|
label="Properties"
|
||||||
|
isActive={tab === 'properties'}
|
||||||
|
onClick={onPropertiesTabClick}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-2 flex items-center text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||||
|
title="Close pane"
|
||||||
|
>
|
||||||
|
<CloseIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{tab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/hooks/usePoiLayers.test.ts
Normal file
128
frontend/src/hooks/usePoiLayers.test.ts
Normal file
|
|
@ -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<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
238
frontend/src/hooks/usePoiLayers.ts
Normal file
238
frontend/src/hooks/usePoiLayers.ts
Normal file
|
|
@ -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<PopupInfo | null>(null);
|
||||||
|
|
||||||
|
const handlePoiHover = useCallback((info: PickingInfo<POI>) => {
|
||||||
|
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<POI>) => {
|
||||||
|
handlePoiHoverRef.current(info);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClusterHover = useCallback((info: PickingInfo<ClusterPoint>) => {
|
||||||
|
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<ClusterPoint>) => {
|
||||||
|
handleClusterHoverRef.current(info);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clusterIndex = useMemo(() => {
|
||||||
|
if (pois.length === 0) return null;
|
||||||
|
const index = new Supercluster<POI>({
|
||||||
|
radius: POI_CLUSTER_RADIUS,
|
||||||
|
maxZoom: POI_CLUSTER_MAX_ZOOM,
|
||||||
|
});
|
||||||
|
const features: Supercluster.PointFeature<POI>[] = 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<POI>({
|
||||||
|
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<POI>({
|
||||||
|
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<POI>({
|
||||||
|
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<ClusterPoint>({
|
||||||
|
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<ClusterPoint>({
|
||||||
|
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 };
|
||||||
|
}
|
||||||
68
frontend/src/hooks/useTravelTime.test.ts
Normal file
68
frontend/src/hooks/useTravelTime.test.ts
Normal file
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/lib/api.test.ts
Normal file
67
frontend/src/lib/api.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
frontend/src/lib/external-search.test.ts
Normal file
78
frontend/src/lib/external-search.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
42
frontend/src/lib/format.test.ts
Normal file
42
frontend/src/lib/format.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
frontend/src/lib/map-utils.test.ts
Normal file
93
frontend/src/lib/map-utils.test.ts
Normal file
|
|
@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
frontend/src/lib/url-state.test.ts
Normal file
87
frontend/src/lib/url-state.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
screenshot/src/validation.test.ts
Normal file
58
screenshot/src/validation.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
114
screenshot/src/validation.ts
Normal file
114
screenshot/src/validation.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue