mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
51af575c9a
A right-click, click or drag on a repeated world copy reported the longitude it has on that copy (274.9 for -85.1). The place was saved there, the geocoder found nothing, and a route to a place on the real world ran across the whole map (#2976). Longitudes are now brought back onto [-180, 180] where the map hands them out (Leaflet and GL), where they are stored (places, booking endpoints, collection places, so REST, MCP, plugin RPC and importers alike) and before reverse geocoding. A migration moves the ones already stored. Leaflet draws a layer at its literal longitude only, so a trip from Los Angeles to Auckland had its pins at opposite ends of the world while the flight ran off the edge to the next copy (#2975). A trip that crosses the antimeridian is now drawn in one longitude frame, seamed in the widest stretch it leaves empty, so pins, routes, flights and every overlay lie on one copy and the map opens across the Pacific. Every other trip has no frame and draws exactly as before. The GL day fit also takes the short way. A new install now starts with Journey and Collections switched on, next to the trip addons (all but Road trip), Atlas, Vacay and the Naver import that already were. Existing installs keep their addons as they are. Closes #2975 Closes #2976
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
|
|
import type { AssistantRouteLine } from '../../types/assistantMap';
|
|
import { onRealWorld } from '../../utils/worldCopy';
|
|
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);
|
|
const at = onRealWorld(event.lngLat);
|
|
handlers.current.onPoint?.(ref, at.lat, at.lng);
|
|
};
|
|
const leave = () => handlers.current.onHover?.(null);
|
|
const click = (event: LayerEvent) => {
|
|
const ref = refOf(event);
|
|
if (ref === null || onPlanPin(map, event)) return;
|
|
const at = onRealWorld(event.lngLat);
|
|
handlers.current.onPoint?.(ref, at.lat, at.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]);
|
|
}
|