238 lines
7.1 KiB
TypeScript
238 lines
7.1 KiB
TypeScript
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 };
|
|
}
|