these too

This commit is contained in:
Andras Schmelczer 2026-05-04 16:19:15 +01:00
parent d4dde21ad2
commit 90c47afe17
11 changed files with 1045 additions and 0 deletions

View 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,
});
});
});

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

View 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]);
});
});