perfect-postcode/frontend/src/hooks/usePoiLayers.ts
Andras Schmelczer e2b85fe819
Some checks failed
CI / Check (push) Failing after 2m47s
Build and publish Docker image / build-and-push (push) Successful in 5m37s
lgtm
2026-06-26 16:48:20 +01:00

292 lines
9.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PickingInfo } from '@deck.gl/core';
import { IconLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
import Supercluster from 'supercluster';
import type { POI, SchoolMetadata } from '../types';
import {
POI_GROUP_COLORS,
MINOR_POI_CATEGORIES,
MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { getPoiIconUrl } from '../lib/map-utils';
export interface PopupInfo {
x: number;
y: number;
name: string;
category: string;
icon_category?: string;
group: string;
emoji: string;
id: string;
isCluster?: boolean;
clusterCount?: number;
school?: SchoolMetadata;
}
interface ClusterPoint {
lng: number;
lat: number;
count: number;
clusterId: number;
}
interface UsePoiLayersProps {
pois: POI[];
zoom: number;
isDark: boolean;
}
function getPoiIconUrlForPoi(poi: POI): string {
return getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name);
}
function isBundledPoiIcon(url: string): boolean {
return url.startsWith('/assets/poi-icons/') || url.startsWith('data:image/svg+xml');
}
function hasBundledPoiLogo(poi: POI): boolean {
return isBundledPoiIcon(getPoiIconUrlForPoi(poi));
}
function getPoiGroupColor(group: string): [number, number, number] {
const color = POI_GROUP_COLORS[group];
if (!color) {
throw new Error(`Missing POI group color for '${group}'`);
}
return color;
}
function getPoiIconSize(poi: POI): number {
return hasBundledPoiLogo(poi) ? 14 : 11;
}
export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const { t } = useTranslation();
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
// Dismiss a lingering hover/cluster popup once the POIs behind it are gone — e.g.
// after the user clears the POI categories. Without this the card stays stuck on
// screen because it is only otherwise cleared on mouse-leave or the close button.
useEffect(() => {
setPopupInfo((current) => {
if (!current) return current;
if (pois.length === 0) return null;
if (current.isCluster) return current;
return pois.some((poi) => poi.id === current.id) ? current : null;
});
}, [pois]);
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,
icon_category: info.object.icon_category,
group: info.object.group,
emoji: info.object.emoji,
id: info.object.id,
school: info.object.school,
});
} 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} ${t('common.places')}`,
category: t('map.poi.zoomInToSeeDetails'),
group: '',
emoji: '',
id: '',
isCluster: true,
clusterCount: info.object.count,
});
} else {
setPopupInfo(null);
}
},
[t]
);
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: (d) => (hasBundledPoiLogo(d) ? 0 : 10),
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: (d) => (hasBundledPoiLogo(d) ? 14 : 8),
radiusUnits: 'pixels',
getFillColor: (d) =>
hasBundledPoiLogo(d)
? ([0, 0, 0, 0] as [number, number, number, number])
: isDark
? ([41, 37, 36, 255] as [number, number, number, number])
: ([255, 255, 255, 255] as [number, number, number, number]),
getLineColor: (d) => {
if (hasBundledPoiLogo(d)) return [0, 0, 0, 0] as [number, number, number, number];
const c = getPoiGroupColor(d.group);
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) => {
const url = getPoiIconUrlForPoi(d);
const isLogo = isBundledPoiIcon(url);
return {
url,
width: isLogo ? 96 : 72,
height: isLogo ? 48 : 72,
};
},
getSize: getPoiIconSize,
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, visiblePois, popupInfo, clearPopupInfo };
}