mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
2449616ee9
A hut-to-hut hike or a paddle trip over several days is now planned in the Tours planner as one tour: the whole route is planned as before, and stops along it are marked as stage ends (MDT-01). The stages are read off that one route in its order, never kept as copies, so a change inside a stage changes the whole tour and every figure on it. - A Day tour / Multi-day tour switch beside the tour type; a stop's menu ends a stage there or lets it go on. Back to a day tour removes every stage end after a confirmation, as one step undo brings back. - The list of points is headed per stage with its distance and moving time; a stop that ends a stage carries a moon at its number, on the map too. - The overview lists the stages from stop to stop with distance, climb, descent and time (hiking rule per stage, paddle estimate per stage, the router's time shared by distance otherwise), marks where each ends across the elevation profile, and a stage of more than a day on the move warns. - A stage end a change cannot keep (its stop removed, moved to an end or made a shaping point) is dropped and said so, with undo, never moved to another point. - A multi-day tour may take a day a stage, so its time limit grows with its stages; the tour keeps its one type, portages included. - The list and the MCP tools show the stage count and where stages end. Stage ends are a flag on the waypoint (migration 046000); a tour without one is a day tour, as every tour saved before.
132 lines
5.4 KiB
TypeScript
132 lines
5.4 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
|
|
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
|
import type { MapViewProps } from './mapViewContract';
|
|
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
|
|
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
|
|
|
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
|
interface PlannerWaypointMap {
|
|
getContainer: () => HTMLElement;
|
|
unproject: (point: [number, number]) => { lat: number; lng: number };
|
|
/** Optional so a stand-in map without events still takes the pins. */
|
|
on?: (type: 'movestart', listener: () => void) => unknown;
|
|
off?: (type: 'movestart', listener: () => void) => unknown;
|
|
}
|
|
|
|
/** The slice of a hand-positioned pin the waypoint markers drive. */
|
|
interface PlannerWaypointPin {
|
|
remove: () => void;
|
|
setLngLat: (value: [number, number]) => void;
|
|
}
|
|
|
|
interface PlannerWaypointMarkersOptions<Pin extends PlannerWaypointPin> {
|
|
mapRef: { readonly current: PlannerWaypointMap | null };
|
|
mapReady: boolean;
|
|
/** The pins live in the caller's ref so its render-clock reposition can reach them. */
|
|
pinsRef: { current: Pin[] };
|
|
/** Puts a waypoint element on the map at the given position. */
|
|
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
|
|
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
|
pinCss: (size: number, selected: boolean) => string;
|
|
plannerWaypoints: NonNullable<MapViewProps['plannerWaypoints']>;
|
|
selectedPlannerWaypointId: string | null;
|
|
onPlannerWaypointClick: MapViewProps['onPlannerWaypointClick'];
|
|
onPlannerWaypointMove: MapViewProps['onPlannerWaypointMove'];
|
|
}
|
|
|
|
/**
|
|
* The numbered control points of a list-first route editor, drawn as pins on the GL map.
|
|
*
|
|
* The handlers are read through a ref so a parent re-rendering with fresh callbacks does
|
|
* not tear the pins down mid-drag; only a change in whether they can be dragged does.
|
|
*/
|
|
export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
|
mapRef,
|
|
mapReady,
|
|
pinsRef,
|
|
attachPin,
|
|
pinCss,
|
|
plannerWaypoints,
|
|
selectedPlannerWaypointId,
|
|
onPlannerWaypointClick,
|
|
onPlannerWaypointMove,
|
|
}: PlannerWaypointMarkersOptions<Pin>): void {
|
|
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove });
|
|
useLayoutEffect(() => {
|
|
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove };
|
|
}, [onPlannerWaypointClick, onPlannerWaypointMove]);
|
|
const plannerWaypointsDraggable = !!onPlannerWaypointMove;
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || !mapReady) return;
|
|
pinsRef.current.forEach((pin) => pin.remove());
|
|
pinsRef.current = [];
|
|
const dragCleanups: (() => void)[] = [];
|
|
const hoverLabel = mapHoverLabel(map, 'planner-waypoint-label');
|
|
// A label left behind by a pan or a zoom would float over the wrong spot.
|
|
const hideOnMove = () => hoverLabel.hide();
|
|
map.on?.('movestart', hideOnMove);
|
|
dragCleanups.push(() => map.off?.('movestart', hideOnMove));
|
|
|
|
for (const [index, point] of plannerWaypoints.entries()) {
|
|
const selected = point.id === selectedPlannerWaypointId;
|
|
const { size, text } = plannerPinShape(point, index, selected);
|
|
const el = document.createElement('button');
|
|
el.type = 'button';
|
|
el.textContent = text;
|
|
const name = point.name ?? `Waypoint ${index + 1}`;
|
|
el.setAttribute('aria-label', name);
|
|
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
|
// MDT-01: the moon where a stage ends; the pin is positioned, so the badge sits at its corner.
|
|
const badge = plannerPinBadge(point);
|
|
if (badge) el.insertAdjacentHTML('beforeend', badge);
|
|
const swallow = (event: Event) => event.stopPropagation();
|
|
const select = (event: Event) => {
|
|
event.stopPropagation();
|
|
plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id);
|
|
};
|
|
el.addEventListener('pointerdown', swallow);
|
|
el.addEventListener('touchstart', swallow, { passive: true });
|
|
el.addEventListener('click', select);
|
|
const pin = attachPin(el, point.lng, point.lat);
|
|
pinsRef.current.push(pin);
|
|
dragCleanups.push(bindPinHoverLabel(el, hoverLabel, map.getContainer(), { name, detail: point.detail ?? null }));
|
|
if (plannerWaypointsDraggable) {
|
|
el.style.cursor = 'grab';
|
|
dragCleanups.push(
|
|
wirePlannerWaypointDrag({
|
|
element: el,
|
|
initial: { lat: point.lat, lng: point.lng },
|
|
coordinateAt: (clientX, clientY) => {
|
|
const rect = map.getContainer().getBoundingClientRect();
|
|
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
|
|
return { lat: at.lat, lng: at.lng };
|
|
},
|
|
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
|
|
onCommit: ({ lat, lng }) =>
|
|
plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
dragCleanups.forEach((cleanup) => cleanup());
|
|
hoverLabel.remove();
|
|
pinsRef.current.forEach((pin) => pin.remove());
|
|
pinsRef.current = [];
|
|
};
|
|
}, [
|
|
attachPin,
|
|
mapReady,
|
|
mapRef,
|
|
pinCss,
|
|
pinsRef,
|
|
plannerWaypoints,
|
|
plannerWaypointsDraggable,
|
|
selectedPlannerWaypointId,
|
|
]);
|
|
}
|