these too
This commit is contained in:
parent
d4dde21ad2
commit
90c47afe17
11 changed files with 1045 additions and 0 deletions
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]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue