740 lines
23 KiB
TypeScript
740 lines
23 KiB
TypeScript
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
|
|
import { H3HexagonLayer } from '@deck.gl/geo-layers';
|
|
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
|
|
import { cellToBoundary } from 'h3-js';
|
|
import Supercluster from 'supercluster';
|
|
import type { PickingInfo } from '@deck.gl/core';
|
|
import type {
|
|
HexagonData,
|
|
PostcodeFeature,
|
|
PostcodeProperties,
|
|
PostcodeGeometry,
|
|
POI,
|
|
FeatureMeta,
|
|
Bounds,
|
|
} from '../types';
|
|
import {
|
|
DENSITY_GRADIENT,
|
|
DENSITY_GRADIENT_DARK,
|
|
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, getFeatureFillColor } from '../lib/map-utils';
|
|
import type { TravelTimeEntry } from './useTravelTime';
|
|
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
|
|
|
interface UseDeckLayersProps {
|
|
data: HexagonData[];
|
|
postcodeData: PostcodeFeature[];
|
|
usePostcodeView: boolean;
|
|
zoom: number;
|
|
pois: POI[];
|
|
viewFeature: string | null;
|
|
colorRange: [number, number] | null;
|
|
filterRange: [number, number] | null;
|
|
features: FeatureMeta[];
|
|
selectedHexagonId: string | null;
|
|
hoveredHexagonId: string | null;
|
|
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
|
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
|
theme: 'light' | 'dark';
|
|
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
|
bounds?: Bounds | null;
|
|
travelTimeEntries?: TravelTimeEntry[];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function useDeckLayers({
|
|
data,
|
|
postcodeData,
|
|
usePostcodeView,
|
|
zoom,
|
|
pois,
|
|
viewFeature,
|
|
colorRange,
|
|
filterRange,
|
|
features,
|
|
selectedHexagonId,
|
|
hoveredHexagonId,
|
|
onHexagonClick,
|
|
onHexagonHover,
|
|
theme,
|
|
selectedPostcodeGeometry,
|
|
bounds: viewportBounds,
|
|
travelTimeEntries = [],
|
|
}: UseDeckLayersProps) {
|
|
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
|
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
|
const [hoveredPostcode, setHoveredPostcode] = useState<string | null>(null);
|
|
|
|
// Marching ants animation
|
|
const [marchTime, setMarchTime] = useState(0);
|
|
const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null;
|
|
useEffect(() => {
|
|
if (!hasSelection) return;
|
|
setMarchTime(0);
|
|
const id = setInterval(() => setMarchTime((t) => (t + 0.3) % 10000), 50);
|
|
return () => clearInterval(id);
|
|
}, [hasSelection]);
|
|
|
|
const isDark = theme === 'dark';
|
|
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
|
|
|
// --- Refs for deck.gl accessors ---
|
|
const viewFeatureRef = useRef(viewFeature);
|
|
viewFeatureRef.current = viewFeature;
|
|
const colorRangeRef = useRef(colorRange);
|
|
colorRangeRef.current = colorRange;
|
|
const filterRangeRef = useRef(filterRange);
|
|
filterRangeRef.current = filterRange;
|
|
const isDarkRef = useRef(isDark);
|
|
isDarkRef.current = isDark;
|
|
const densityGradientRef = useRef(densityGradient);
|
|
densityGradientRef.current = densityGradient;
|
|
const selectedHexagonIdRef = useRef(selectedHexagonId);
|
|
selectedHexagonIdRef.current = selectedHexagonId;
|
|
const hoveredHexagonIdRef = useRef(hoveredHexagonId);
|
|
hoveredHexagonIdRef.current = hoveredHexagonId;
|
|
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
|
hoveredPostcodeRef.current = hoveredPostcode;
|
|
|
|
const colorFeatureMeta = useMemo(
|
|
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
|
[viewFeature, features]
|
|
);
|
|
const colorFeatureMetaRef = useRef(colorFeatureMeta);
|
|
colorFeatureMetaRef.current = colorFeatureMeta;
|
|
|
|
// Track enum value count for discrete coloring (0 = numeric/continuous)
|
|
const enumCountRef = useRef(0);
|
|
enumCountRef.current =
|
|
colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values
|
|
? colorFeatureMeta.values.length
|
|
: 0;
|
|
|
|
// --- Count ranges ---
|
|
const countRange = useMemo(() => {
|
|
if (data.length === 0) return { min: 0, max: 1 };
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
for (const d of data) {
|
|
if (viewportBounds) {
|
|
if (
|
|
d.lat < viewportBounds.south ||
|
|
d.lat > viewportBounds.north ||
|
|
d.lon < viewportBounds.west ||
|
|
d.lon > viewportBounds.east
|
|
)
|
|
continue;
|
|
}
|
|
const c = d.count as number;
|
|
if (c < min) min = c;
|
|
if (c > max) max = c;
|
|
}
|
|
if (min === Infinity) return { min: 0, max: 1 };
|
|
if (min === max) return { min, max: min + 1 };
|
|
return { min, max };
|
|
}, [data, viewportBounds]);
|
|
|
|
const countRangeRef = useRef(countRange);
|
|
countRangeRef.current = countRange;
|
|
|
|
const postcodeCountRange = useMemo(() => {
|
|
if (postcodeData.length === 0) return { min: 0, max: 1 };
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
for (const d of postcodeData) {
|
|
if (viewportBounds) {
|
|
const [lng, lat] = d.properties.centroid as [number, number];
|
|
if (
|
|
lat < viewportBounds.south ||
|
|
lat > viewportBounds.north ||
|
|
lng < viewportBounds.west ||
|
|
lng > viewportBounds.east
|
|
)
|
|
continue;
|
|
}
|
|
const c = d.properties.count;
|
|
if (c < min) min = c;
|
|
if (c > max) max = c;
|
|
}
|
|
if (min === Infinity) return { min: 0, max: 1 };
|
|
if (min === max) return { min, max: min + 1 };
|
|
return { min, max };
|
|
}, [postcodeData, viewportBounds]);
|
|
|
|
const postcodeCountRangeRef = useRef(postcodeCountRange);
|
|
postcodeCountRangeRef.current = postcodeCountRange;
|
|
|
|
// --- Click/hover handlers ---
|
|
const onHexagonClickRef = useRef(onHexagonClick);
|
|
onHexagonClickRef.current = onHexagonClick;
|
|
const handleHexagonClick = useCallback((info: PickingInfo<HexagonData>) => {
|
|
if (info.object && 'h3' in info.object) {
|
|
onHexagonClickRef.current(info.object.h3);
|
|
}
|
|
}, []);
|
|
|
|
const onHexagonHoverRef = useRef(onHexagonHover);
|
|
onHexagonHoverRef.current = onHexagonHover;
|
|
const handleHexagonHover = useCallback((info: PickingInfo<HexagonData>) => {
|
|
if (info.object && 'h3' in info.object && info.x !== undefined && info.y !== undefined) {
|
|
setHoverPosition({ x: info.x, y: info.y });
|
|
onHexagonHoverRef.current(info.object.h3, info.x, info.y);
|
|
} else {
|
|
setHoverPosition(null);
|
|
onHexagonHoverRef.current(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);
|
|
}, []);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const handlePostcodeClick = useCallback((info: PickingInfo<any>) => {
|
|
const pc = info.object?.properties?.postcode;
|
|
if (pc) {
|
|
onHexagonClickRef.current(pc, true, info.object?.geometry);
|
|
}
|
|
}, []);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const handlePostcodeHoverCallback = useCallback((info: PickingInfo<any>) => {
|
|
const pc = info.object?.properties?.postcode;
|
|
if (pc && info.x !== undefined && info.y !== undefined) {
|
|
setHoveredPostcode(pc);
|
|
setHoverPosition({ x: info.x, y: info.y });
|
|
onHexagonHoverRef.current(pc, info.x, info.y);
|
|
} else {
|
|
setHoveredPostcode(null);
|
|
setHoverPosition(null);
|
|
onHexagonHoverRef.current(null);
|
|
}
|
|
}, []);
|
|
|
|
// --- Color triggers ---
|
|
const ttTrigger = useMemo(() => {
|
|
const parts: string[] = [];
|
|
for (let i = 0; i < travelTimeEntries.length; i++) {
|
|
const entry = travelTimeEntries[i];
|
|
parts.push(`${i}:${entry.slug}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
|
|
}
|
|
return parts.join(';');
|
|
}, [travelTimeEntries]);
|
|
|
|
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
|
|
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
|
|
|
// --- Layers ---
|
|
const hexLayer = useMemo(
|
|
() =>
|
|
new H3HexagonLayer<HexagonData>({
|
|
id: 'h3-hexagons',
|
|
data,
|
|
getHexagon: (d) => d.h3,
|
|
getFillColor: (d) => {
|
|
const dark = isDarkRef.current;
|
|
const vf = viewFeatureRef.current;
|
|
const clr = colorRangeRef.current;
|
|
const fr = filterRangeRef.current;
|
|
const cfm = colorFeatureMetaRef.current;
|
|
|
|
if (vf && clr) {
|
|
// Travel time feature: dim hexagons with no data
|
|
if (vf.startsWith('tt_')) {
|
|
const ttVal = d[`avg_${vf}`];
|
|
if (ttVal == null) {
|
|
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
];
|
|
}
|
|
return getFeatureFillColor(
|
|
ttVal as number,
|
|
ttVal as number,
|
|
ttVal as number,
|
|
clr,
|
|
fr,
|
|
0,
|
|
densityGradientRef.current,
|
|
dark,
|
|
255
|
|
);
|
|
}
|
|
|
|
// Regular feature
|
|
if (cfm) {
|
|
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
|
const minVal = d[`min_${vf}`] as number | undefined;
|
|
const maxVal = d[`max_${vf}`] as number | undefined;
|
|
return getFeatureFillColor(
|
|
val as number | null | undefined,
|
|
minVal,
|
|
maxVal,
|
|
clr,
|
|
fr,
|
|
0,
|
|
densityGradientRef.current,
|
|
dark,
|
|
255,
|
|
enumCountRef.current
|
|
);
|
|
}
|
|
}
|
|
|
|
// Density fallback
|
|
const cr = countRangeRef.current;
|
|
const c = d.count as number;
|
|
const t = (c - cr.min) / (cr.max - cr.min);
|
|
return getFeatureFillColor(
|
|
null,
|
|
undefined,
|
|
undefined,
|
|
null,
|
|
null,
|
|
t,
|
|
densityGradientRef.current,
|
|
dark,
|
|
255
|
|
);
|
|
},
|
|
getLineColor: (d) => {
|
|
if (d.h3 === hoveredHexagonIdRef.current)
|
|
return [29, 228, 195, 200] as [number, number, number, number];
|
|
return [0, 0, 0, 0] as [number, number, number, number];
|
|
},
|
|
getLineWidth: (d) => {
|
|
if (d.h3 === hoveredHexagonIdRef.current) return 2;
|
|
return 0;
|
|
},
|
|
lineWidthUnits: 'pixels',
|
|
updateTriggers: {
|
|
getFillColor: [colorTrigger],
|
|
getLineColor: [colorTrigger],
|
|
getLineWidth: [colorTrigger],
|
|
},
|
|
extruded: false,
|
|
pickable: true,
|
|
opacity: 1,
|
|
highPrecision: true,
|
|
onClick: handleHexagonClick,
|
|
onHover: handleHexagonHover,
|
|
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
|
beforeId: 'landuse_park',
|
|
}),
|
|
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
|
);
|
|
|
|
const postcodeLayer = useMemo(
|
|
() =>
|
|
new GeoJsonLayer<PostcodeProperties>({
|
|
id: 'postcode-polygons',
|
|
data: postcodeData as PostcodeFeature[],
|
|
getFillColor: (f) => {
|
|
const d = f.properties;
|
|
const dark = isDarkRef.current;
|
|
const vf = viewFeatureRef.current;
|
|
const clr = colorRangeRef.current;
|
|
const fr = filterRangeRef.current;
|
|
const cfm = colorFeatureMetaRef.current;
|
|
|
|
if (vf && clr) {
|
|
// Travel time feature: dim postcodes with no data
|
|
if (vf.startsWith('tt_')) {
|
|
const ttVal = d[`avg_${vf}`];
|
|
if (ttVal == null) {
|
|
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
];
|
|
}
|
|
return getFeatureFillColor(
|
|
ttVal as number,
|
|
ttVal as number,
|
|
ttVal as number,
|
|
clr,
|
|
fr,
|
|
0,
|
|
densityGradientRef.current,
|
|
dark,
|
|
180
|
|
);
|
|
}
|
|
|
|
// Regular feature
|
|
if (cfm) {
|
|
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
|
const minVal = d[`min_${vf}`] as number | undefined;
|
|
const maxVal = d[`max_${vf}`] as number | undefined;
|
|
return getFeatureFillColor(
|
|
val as number | null | undefined,
|
|
minVal,
|
|
maxVal,
|
|
clr,
|
|
fr,
|
|
0,
|
|
densityGradientRef.current,
|
|
dark,
|
|
180,
|
|
enumCountRef.current
|
|
);
|
|
}
|
|
}
|
|
const cr = postcodeCountRangeRef.current;
|
|
const c = d.count;
|
|
const t = (c - cr.min) / (cr.max - cr.min);
|
|
return getFeatureFillColor(
|
|
null,
|
|
undefined,
|
|
undefined,
|
|
null,
|
|
null,
|
|
t,
|
|
densityGradientRef.current,
|
|
dark,
|
|
180
|
|
);
|
|
},
|
|
getLineColor: (f) => {
|
|
const pc = f.properties.postcode;
|
|
const dark = isDarkRef.current;
|
|
if (pc === hoveredPostcodeRef.current)
|
|
return [29, 228, 195, 200] as [number, number, number, number];
|
|
return (dark ? [180, 170, 160, 100] : [100, 100, 100, 150]) as [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
];
|
|
},
|
|
getLineWidth: (f) => {
|
|
const pc = f.properties.postcode;
|
|
if (pc === hoveredPostcodeRef.current) return 2;
|
|
return 1;
|
|
},
|
|
lineWidthUnits: 'pixels',
|
|
updateTriggers: {
|
|
getFillColor: [postcodeColorTrigger],
|
|
getLineColor: [postcodeColorTrigger],
|
|
getLineWidth: [postcodeColorTrigger],
|
|
},
|
|
extruded: false,
|
|
pickable: true,
|
|
onClick: handlePostcodeClick,
|
|
onHover: handlePostcodeHoverCallback,
|
|
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
|
beforeId: 'landuse_park',
|
|
}),
|
|
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
|
);
|
|
|
|
const postcodeLabelsLayer = useMemo(
|
|
() =>
|
|
new TextLayer<PostcodeFeature>({
|
|
id: 'postcode-labels',
|
|
data: postcodeData,
|
|
getPosition: (f) => f.properties.centroid,
|
|
getText: (f) => f.properties.postcode,
|
|
getSize: 12,
|
|
getColor: theme === 'dark' ? [255, 255, 255, 240] : [40, 40, 40, 220],
|
|
getTextAnchor: 'middle',
|
|
getAlignmentBaseline: 'center',
|
|
fontFamily: 'Inter, system-ui, sans-serif',
|
|
fontWeight: 600,
|
|
outlineWidth: 2,
|
|
outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200],
|
|
sizeUnits: 'pixels',
|
|
sizeMinPixels: 10,
|
|
sizeMaxPixels: 14,
|
|
billboard: false,
|
|
pickable: false,
|
|
}),
|
|
[postcodeData, theme]
|
|
);
|
|
|
|
// --- POI clustering ---
|
|
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]);
|
|
|
|
// --- Individual POI layers (shadow → background → emoji) ---
|
|
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]
|
|
);
|
|
|
|
// --- Cluster layers ---
|
|
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]
|
|
);
|
|
|
|
// Marching ants highlight layer for selected hexagon or postcode
|
|
const marchingAntsLayer = useMemo(() => {
|
|
let geometry: PostcodeGeometry | null = null;
|
|
if (selectedPostcodeGeometry) {
|
|
geometry = selectedPostcodeGeometry;
|
|
} else if (selectedHexagonId) {
|
|
const boundary = cellToBoundary(selectedHexagonId, true);
|
|
geometry = { type: 'Polygon', coordinates: [boundary] };
|
|
}
|
|
if (!geometry) return null;
|
|
return new GeoJsonLayer({
|
|
id: 'marching-ants',
|
|
data: [
|
|
{
|
|
type: 'Feature' as const,
|
|
geometry,
|
|
properties: {},
|
|
},
|
|
],
|
|
filled: false,
|
|
stroked: true,
|
|
getLineColor: [29, 228, 195, 255],
|
|
getLineWidth: 3,
|
|
lineWidthUnits: 'pixels' as const,
|
|
pickable: false,
|
|
marchTime,
|
|
extensions: [new MarchingAntsExtension()],
|
|
});
|
|
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
|
|
|
|
const poiLayers = useMemo(
|
|
() => [poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer],
|
|
[poiShadowLayer, poiBackgroundLayer, poiIconLayer, clusterCircleLayer, clusterTextLayer]
|
|
);
|
|
|
|
const layers = useMemo(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const baseLayers: any[] = usePostcodeView
|
|
? zoom >= 16
|
|
? [postcodeLayer, postcodeLabelsLayer, ...poiLayers]
|
|
: [postcodeLayer, ...poiLayers]
|
|
: [hexLayer, ...poiLayers];
|
|
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
|
return baseLayers;
|
|
}, [
|
|
usePostcodeView,
|
|
zoom,
|
|
hexLayer,
|
|
postcodeLayer,
|
|
postcodeLabelsLayer,
|
|
poiLayers,
|
|
marchingAntsLayer,
|
|
]);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
setHoverPosition(null);
|
|
setHoveredPostcode(null);
|
|
setPopupInfo(null);
|
|
onHexagonHoverRef.current(null);
|
|
}, []);
|
|
|
|
const clearPopupInfo = useCallback(() => setPopupInfo(null), []);
|
|
|
|
return {
|
|
layers,
|
|
popupInfo,
|
|
clearPopupInfo,
|
|
hoverPosition,
|
|
countRange,
|
|
postcodeCountRange,
|
|
colorFeatureMeta,
|
|
handleMouseLeave,
|
|
hoveredPostcode,
|
|
};
|
|
}
|