Files
TREK/client/src/components/Map/useAssistantGhostLinesGL.ts
T

277 lines
11 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef } from 'react';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { mapHoverLabel } from './mapHoverLabel';
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
const SOURCE_ID = 'assistant-route-lines';
export const ROUTE_LINE_LAYERS = {
casing: `${SOURCE_ID}-casing`,
line: `${SOURCE_ID}-line`,
dashed: `${SOURCE_ID}-line-dashed`,
tint: `${SOURCE_ID}-tint`,
ends: `${SOURCE_ID}-ends`,
stops: `${SOURCE_ID}-stops`,
hit: `${SOURCE_ID}-hit`,
} as const;
/** The way a change replaces, beside the new one. */
const DIM_OPACITY = 0.45;
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
type Position = [number, number];
interface Feature {
type: 'Feature';
properties: Record<string, string | number | boolean>;
geometry:
| { type: 'MultiLineString'; coordinates: Position[][] }
| { type: 'LineString'; coordinates: Position[] }
| { type: 'Point'; coordinates: Position };
}
interface FeatureCollection {
type: 'FeatureCollection';
features: Feature[];
}
/** What a pointer event on a layer hands over: where, and the feature under it. */
interface LayerEvent {
lngLat: { lng: number; lat: number };
point?: { x: number; y: number };
features?: { properties?: Record<string, unknown> | null }[];
/** The DOM event behind it: a place pin is a DOM marker over the canvas, and its taps reach the layers too. */
originalEvent?: { target?: EventTarget | null };
}
/**
* The slice of the GL map the route lines need. Every call is optional, so a
* stand-in map without them draws nothing instead of throwing; Mapbox and
* MapLibre both fit.
*/
export interface LineMap {
getSource?(id: string): { setData?: (data: FeatureCollection) => void } | undefined;
addSource?(id: string, source: { type: 'geojson'; data: FeatureCollection }): unknown;
addLayer?(layer: Record<string, unknown>): unknown;
getLayer?(id: string): unknown;
isStyleLoaded?(): boolean;
queryRenderedFeatures?(point: { x: number; y: number }, options: { layers: string[] }): unknown[];
getContainer?(): HTMLElement;
getCanvas?(): HTMLElement;
on?(type: 'styledata', listener: () => void): unknown;
on?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
off?(type: 'styledata', listener: () => void): unknown;
off?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
}
/** [lat, lng] as the store keeps points, [lng, lat] as GeoJSON wants them. */
const flip = ([lat, lng]: [number, number]): Position => [lng, lat];
const pointFeature = (properties: Feature['properties'], coordinates: Position): Feature => ({
type: 'Feature',
properties,
geometry: { type: 'Point', coordinates },
});
/**
* The routes as one GeoJSON collection: each route its line (with its rim
* colour, whether it is dashed, how faint, whether it is lit), its hard
* grades, its two ends, and its named stops while it is lit. Pure, for the
* tests.
*/
export function routeLineFeatures(lines: readonly AssistantRouteLine[]): FeatureCollection {
const features: Feature[] = [];
for (const line of lines) {
const opacity = line.dim ? DIM_OPACITY : 1;
features.push({
type: 'Feature',
properties: {
kind: 'line',
ref: line.ref,
color: line.color,
casing: line.casing,
dashed: !!line.dashed,
hot: !!line.hot,
opacity,
},
geometry: { type: 'MultiLineString', coordinates: line.lines.map((part) => part.map(flip)) },
});
for (const tint of line.tints ?? []) {
features.push({
type: 'Feature',
properties: { kind: 'tint', color: tint.color, opacity },
geometry: { type: 'LineString', coordinates: tint.line.map(flip) },
});
}
if (line.ends) {
const start = { kind: 'end', fill: line.color, stroke: line.casing, strokeWidth: 2, opacity };
features.push(pointFeature(start, flip(line.ends.start)));
if (line.ends.end) {
const end = { kind: 'end', fill: line.casing, stroke: line.color, strokeWidth: 3, opacity };
features.push(pointFeature(end, flip(line.ends.end)));
}
}
if (!line.hot) continue;
for (const stop of line.stops ?? []) {
const properties = { kind: 'stop', fill: line.color, stroke: line.casing, label: stop.label };
features.push(pointFeature(properties, [stop.lng, stop.lat]));
}
}
return { type: 'FeatureCollection', features };
}
const kindIs = (kind: string) => ['==', ['get', 'kind'], kind];
/** The layers, bottom first: rims, lines, dashed lines, grades, ends, stops, and the invisible line on top. */
function addRouteLineLayers(map: LineMap): void {
map.addSource?.(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
const source = SOURCE_ID;
const line = (id: string, filter: unknown[], paint: Record<string, unknown>) =>
map.addLayer?.({ id, type: 'line', source, filter, paint, layout: ROUND });
const solid = ['all', kindIs('line'), ['==', ['get', 'dashed'], false]];
const dashed = ['all', kindIs('line'), ['==', ['get', 'dashed'], true]];
const opacity = ['get', 'opacity'];
line(ROUTE_LINE_LAYERS.casing, kindIs('line'), {
'line-color': ['get', 'casing'],
'line-width': 7,
'line-opacity': ['*', opacity, 0.9],
});
line(ROUTE_LINE_LAYERS.line, solid, { 'line-color': ['get', 'color'], 'line-width': 4, 'line-opacity': opacity });
// Dashes are counted in line widths: 6 px on, 7 px off at a width of 4, as the Leaflet map draws them.
line(ROUTE_LINE_LAYERS.dashed, dashed, {
'line-color': ['get', 'color'],
'line-width': 4,
'line-opacity': opacity,
'line-dasharray': [1.5, 1.75],
});
line(ROUTE_LINE_LAYERS.tint, kindIs('tint'), {
'line-color': ['get', 'color'],
'line-width': 4,
'line-opacity': opacity,
});
const disc = (id: string, filter: unknown[], radius: number) =>
map.addLayer?.({
id,
type: 'circle',
source,
filter,
paint: {
'circle-radius': radius,
'circle-color': ['get', 'fill'],
'circle-stroke-color': ['get', 'stroke'],
'circle-stroke-width': ['coalesce', ['get', 'strokeWidth'], 2],
'circle-opacity': ['coalesce', opacity, 1],
'circle-stroke-opacity': ['coalesce', opacity, 1],
},
});
disc(ROUTE_LINE_LAYERS.ends, kindIs('end'), 5);
disc(ROUTE_LINE_LAYERS.stops, kindIs('stop'), 4);
line(ROUTE_LINE_LAYERS.hit, kindIs('line'), { 'line-color': ['get', 'casing'], 'line-width': 16, 'line-opacity': 0 });
}
interface AssistantGhostLinesOptions {
mapRef: { readonly current: LineMap | null };
mapReady: boolean;
lines: readonly AssistantRouteLine[] | undefined;
onHover?: (ref: string | null) => void;
onPoint?: (ref: string, lat: number, lng: number) => void;
}
/** The ref of the route line under a pointer event, if any. */
function refOf(event: LayerEvent): string | null {
const ref = event.features?.[0]?.properties?.ref;
return typeof ref === 'string' ? ref : null;
}
/**
* Whether the pointer is on something of the plan's that lies over the route
* on screen: a place pin (a DOM marker) or a cluster bubble (drawn under the
* route's layers, but the bubble is what a tap there means). The plan's own
* tracks leave such a tap alone the same way.
*/
function onPlanPin(map: LineMap, event: LayerEvent): boolean {
const target = event.originalEvent?.target;
if (target instanceof Element && target.closest('.mapboxgl-marker, .maplibregl-marker')) return true;
if (!event.point || !map.queryRenderedFeatures || !map.getLayer?.(PLACE_CLUSTER_CIRCLE_LAYER_ID)) return false;
const layers = [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID];
return map.queryRenderedFeatures(event.point, { layers }).length > 0;
}
/**
* The routes the chat shows on the GL map, the twin of the Leaflet
* AssistantGhostLineLayer: one GeoJSON source and its layers, added the
* first time there is a route to draw and again whenever the basemap
* rebuilds the style, the data set anew when the routes change. The
* invisible line reports the pointer onto, along and off a route; a stop
* under the pointer shows its name in the map's hover label.
*/
export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onPoint }: AssistantGhostLinesOptions) {
const handlers = useRef({ onHover, onPoint });
useLayoutEffect(() => {
handlers.current = { onHover, onPoint };
}, [onHover, onPoint]);
const data = useRef<FeatureCollection>(routeLineFeatures([]));
const added = useRef(false);
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady || typeof map.getSource !== 'function') return;
data.current = routeLineFeatures(lines ?? []);
// Nothing is added to a map that never had a route to draw.
if (!added.current && data.current.features.length === 0) return;
added.current = true;
if (!map.getSource(SOURCE_ID)) {
// A basemap still loading takes no layers; its styledata puts the routes on once it is ready.
if (map.isStyleLoaded?.() === false) return;
addRouteLineLayers(map);
}
map.getSource(SOURCE_ID)?.setData?.(data.current);
}, [lines, mapReady, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady || typeof map.on !== 'function' || typeof map.off !== 'function') return;
const label = mapHoverLabel(map, 'assistant-route-label');
// A new basemap takes every layer with it: put the routes back, as the plan's own tracks are.
const restore = () => {
if (!added.current || !map.getSource || map.getSource(SOURCE_ID)) return;
addRouteLineLayers(map);
map.getSource(SOURCE_ID)?.setData?.(data.current);
};
const move = (event: LayerEvent) => {
const ref = refOf(event);
if (ref === null) return;
// On a pin over the line the pin is what the pointer is on: the line goes out as if left.
if (onPlanPin(map, event)) {
handlers.current.onHover?.(null);
return;
}
handlers.current.onHover?.(ref);
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
};
const leave = () => handlers.current.onHover?.(null);
const click = (event: LayerEvent) => {
const ref = refOf(event);
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
};
const stop = (event: LayerEvent) => {
const name = event.features?.[0]?.properties?.label;
if (typeof name === 'string' && event.point) label.show({ name, detail: null }, event.point);
};
const hideLabel = () => label.hide();
map.on('styledata', restore);
map.on('mousemove', ROUTE_LINE_LAYERS.hit, move);
map.on('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
map.on('click', ROUTE_LINE_LAYERS.hit, click);
map.on('mousemove', ROUTE_LINE_LAYERS.stops, stop);
map.on('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
return () => {
map.off?.('styledata', restore);
map.off?.('mousemove', ROUTE_LINE_LAYERS.hit, move);
map.off?.('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
map.off?.('click', ROUTE_LINE_LAYERS.hit, click);
map.off?.('mousemove', ROUTE_LINE_LAYERS.stops, stop);
map.off?.('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
label.remove();
};
}, [mapReady, mapRef]);
}