Files
TREK/client/src/components/Map/assistantViewport.ts
T
Maurice 51af575c9a fix(map): keep places and routes on one world, and start new installs with Journey and Collections
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
2026-11-04 10:56:36 +01:00

109 lines
3.8 KiB
TypeScript

import type { AssistantMapView } from '@trek/shared';
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
import { useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import { bboxOnRealWorld } from '../../utils/worldCopy';
/**
* The part of the planner's map on screen, told to the assistant after every
* move, so a question about "here" needs no place named. Both renderers report
* through this module; the assistant sends it with the next question only for
* the trip it was taken on.
*/
interface Edges {
south: number;
west: number;
north: number;
east: number;
}
const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value));
const round = (value: number) => Math.round(value * 1e4) / 1e4;
/** The edges as the contract takes them: inside the globe (a wrapped map runs past ±180) and to about ten metres. */
export function mapViewOf(edges: Edges): AssistantMapView {
return {
south: round(clamp(edges.south, 90)),
west: round(clamp(edges.west, 180)),
north: round(clamp(edges.north, 90)),
east: round(clamp(edges.east, 180)),
};
}
function report(tripId: number | null, edges: Edges | null): void {
const set = useAssistantStore.getState().setMapView;
// A map without a size yet (hidden, or still laying out) has no edges to speak of.
const usable = edges && Object.values(edges).every(Number.isFinite) && edges.north > edges.south;
set(tripId !== null && usable ? { tripId, view: mapViewOf(edges) } : null);
}
/** The trip whose view is reported: only while the assistant can run, so a map without it listens to nothing. */
function useReportedTrip(): number | null {
const available = useAssistantStore((s) => s.status?.available === true);
const tripId = useTripStore((s) => s.trip?.id ?? null);
return available ? tripId : null;
}
/** Leaflet: read from inside the map container, after each move and once on mount. */
export function useReportViewLeaflet(): void {
const map = useMap();
const tripId = useReportedTrip();
useEffect(() => {
if (tripId === null) return;
const read = () => {
const b = map.getBounds();
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
map.on('moveend', read);
return () => {
map.off('moveend', read);
report(null, null);
};
}, [map, tripId]);
}
/** What the GL map offers for it; mapbox-gl and maplibre-gl alike. */
interface ViewMap {
getBounds: () => {
getSouth: () => number;
getWest: () => number;
getNorth: () => number;
getEast: () => number;
} | null;
on: (type: 'moveend', listener: () => void) => unknown;
off: (type: 'moveend', listener: () => void) => unknown;
}
/** GL: the same, once the map is ready. */
export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady: boolean): void {
const tripId = useReportedTrip();
useEffect(() => {
const map = mapRef.current as Partial<ViewMap> | null;
if (tripId === null || !mapReady || !map?.on || !map.off || !map.getBounds) return;
const { on, off, getBounds } = { on: map.on.bind(map), off: map.off.bind(map), getBounds: map.getBounds.bind(map) };
const read = () => {
const b = getBounds();
if (typeof b?.getSouth !== 'function') return;
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
report(
tripId,
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
);
};
read();
on('moveend', read);
return () => {
off('moveend', read);
report(null, null);
};
}, [mapRef, mapReady, tripId]);
}