292 lines
9.1 KiB
TypeScript
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 };
|
|
}
|