This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View file

@ -6,13 +6,18 @@ import type {
HexagonData,
PostcodeFeature,
PostcodeProperties,
PostcodeGeometry,
POI,
FeatureMeta,
Bounds,
} from '../types';
import type { SearchedPostcode } from '../components/map/PostcodeSearch';
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
} from './useTravelTime';
/** Convert POI id (e.g. "n12345") to OpenStreetMap URL */
function osmIdToUrl(id: string): string | null {
@ -38,12 +43,10 @@ interface UseDeckLayersProps {
onHexagonClick: (id: string, isPostcode?: boolean) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
theme: 'light' | 'dark';
searchedPostcode?: SearchedPostcode | null;
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEnabled?: boolean;
travelTimeDestination?: [number, number] | null;
travelTimeColorRange?: [number, number] | null;
travelTimeRange?: [number, number] | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
}
export interface PopupInfo {
@ -54,6 +57,17 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
function getPrimaryTravelMode(
entries: TravelTimeEntries,
colorRanges: Partial<Record<TransportMode, [number, number]>>
): TransportMode | null {
for (const mode of TRANSPORT_MODES) {
if (entries[mode]?.destination && colorRanges[mode]) return mode;
}
return null;
}
export function useDeckLayers({
data,
postcodeData,
@ -68,12 +82,10 @@ export function useDeckLayers({
onHexagonClick,
onHexagonHover,
theme,
searchedPostcode,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEnabled = false,
travelTimeDestination,
travelTimeColorRange,
travelTimeRange,
travelTimeEntries = {},
travelTimeColorRanges = {},
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -103,14 +115,17 @@ export function useDeckLayers({
const hoveredPostcodeRef = useRef(hoveredPostcode);
hoveredPostcodeRef.current = hoveredPostcode;
const travelTimeEnabledRef = useRef(travelTimeEnabled);
travelTimeEnabledRef.current = travelTimeEnabled;
const travelTimeDestinationRef = useRef(travelTimeDestination);
travelTimeDestinationRef.current = travelTimeDestination;
const travelTimeColorRangeRef = useRef(travelTimeColorRange);
travelTimeColorRangeRef.current = travelTimeColorRange;
const travelTimeRangeRef = useRef(travelTimeRange);
travelTimeRangeRef.current = travelTimeRange;
const travelTimeEntriesRef = useRef(travelTimeEntries);
travelTimeEntriesRef.current = travelTimeEntries;
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelMode = useMemo(
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelModeRef = useRef(primaryTravelMode);
primaryTravelModeRef.current = primaryTravelMode;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -238,7 +253,17 @@ export function useDeckLayers({
}, []);
// --- Color triggers ---
const ttTrigger = `${travelTimeEnabled}|${travelTimeColorRange?.[0]}|${travelTimeColorRange?.[1]}|${travelTimeRange?.[0]}|${travelTimeRange?.[1]}|${travelTimeDestination?.[0]}|${travelTimeDestination?.[1]}`;
// Build travel time trigger from all entries
const ttTrigger = useMemo(() => {
const parts: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
const cr = travelTimeColorRanges[mode];
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
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}|${selectedPostcode}|${hoveredPostcode}|${theme}|${ttTrigger}`;
@ -251,17 +276,28 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
// Travel time coloring takes priority
if (travelTimeEnabledRef.current && travelTimeDestinationRef.current) {
const ttVal = d.travel_time;
const ttClr = travelTimeColorRangeRef.current;
const pm = primaryTravelModeRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary mode colors, others dim-filter
if (pm) {
const ttVal = d[`travel_time_${pm}`];
const ttClr = colorRanges[pm];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
const ttFr = travelTimeRangeRef.current;
if (ttFr && ((ttVal as number) < ttFr[0] || (ttVal as number) > ttFr[1])) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
// Check all modes with time ranges as filters (including primary)
for (const mode of TRANSPORT_MODES) {
const entry = entries[mode];
if (!entry?.timeRange) continue;
const modeVal = d[`travel_time_${mode}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
}
if (ttClr) {
return getFeatureFillColor(
ttVal as number,
@ -464,19 +500,19 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Check if the searched postcode has data (passes current filters)
const searchedPostcodeHasData = useMemo(() => {
if (!searchedPostcode) return false;
return postcodeData.some((f) => f.properties.postcode === searchedPostcode.postcode);
}, [searchedPostcode, postcodeData]);
// Check if the selected postcode has data (passes current filters)
const selectedPostcodeHasData = useMemo(() => {
if (!selectedPostcodeGeometry || !selectedHexagonId) return false;
return postcodeData.some((f) => f.properties.postcode === selectedHexagonId);
}, [selectedPostcodeGeometry, selectedHexagonId, postcodeData]);
// Highlight layer for searched postcode
const searchedPostcodeHighlightLayer = useMemo(() => {
if (!searchedPostcode) return null;
const hasData = searchedPostcodeHasData;
// Highlight layer for selected postcode (from search)
const selectedPostcodeHighlightLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
const hasData = selectedPostcodeHasData;
const feature = {
type: 'Feature' as const,
geometry: searchedPostcode.geometry,
geometry: selectedPostcodeGeometry,
properties: {},
};
return new GeoJsonLayer({
@ -494,13 +530,25 @@ export function useDeckLayers({
filled: true,
pickable: false,
});
}, [searchedPostcode, searchedPostcodeHasData]);
}, [selectedPostcodeGeometry, selectedPostcodeHasData]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
const points: { position: [number, number] }[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
points.push({ position: [entry.destination[1], entry.destination[0]] });
}
}
return points;
}, [travelTimeEntries]);
const destinationMarkerLayer = useMemo(() => {
if (!travelTimeEnabled || !travelTimeDestination) return null;
if (destinationMarkerData.length === 0) return null;
return new ScatterplotLayer({
id: 'travel-time-destination',
data: [{ position: [travelTimeDestination[1], travelTimeDestination[0]] }],
id: 'travel-time-destinations',
data: destinationMarkerData,
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
@ -511,14 +559,14 @@ export function useDeckLayers({
stroked: true,
pickable: false,
});
}, [travelTimeEnabled, travelTimeDestination]);
}, [destinationMarkerData]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (searchedPostcodeHighlightLayer) baseLayers.push(searchedPostcodeHighlightLayer);
if (selectedPostcodeHighlightLayer) baseLayers.push(selectedPostcodeHighlightLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
@ -527,7 +575,7 @@ export function useDeckLayers({
postcodeLayer,
postcodeLabelsLayer,
poiLayer,
searchedPostcodeHighlightLayer,
selectedPostcodeHighlightLayer,
destinationMarkerLayer,
]);
@ -548,5 +596,6 @@ export function useDeckLayers({
handleMouseLeave,
selectedPostcode,
hoveredPostcode,
primaryTravelMode,
};
}