mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
8afc20e1ab
- "Plan this" decides the take it holds at once instead of waiting below for a second confirmation, says it is being planned, and once the tour is added the desktop opens it in the Tours tab to be edited. - The stops run as a timeline: each walking time sits on the line between two stops, read with the stop it leads to, so no time reads as a stop's own. - An answer about a tour leaves out the strip of places its searches went through (hotels, a club in another state). - "On map" from another tab of the planner brings the map back first, and a map that mounts right after a request still carries it out.
127 lines
5.2 KiB
TypeScript
127 lines
5.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import type { ViewportPadding } from '../../utils/mapViewport';
|
|
import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
|
|
import type { AssistantCamera } from './mapViewContract';
|
|
|
|
/**
|
|
* What both renderers share when the assistant moves their camera or marks a
|
|
* pin: the padding around what the assistant's window hides, the centre of
|
|
* a set of points, whether the user is busy moving the map, and the classes
|
|
* on the real pins. The renderer keeps only its own camera calls.
|
|
*/
|
|
|
|
/** How long a move the assistant asks for takes, when it animates. */
|
|
export const ASSISTANT_CAMERA_MS = 600;
|
|
/** After the user touched the map, moves they did not ask for wait this long. */
|
|
const USER_MOVE_QUIET_MS = 5000;
|
|
/** How long a pin that just arrived keeps its pop class; the animation itself is shorter. */
|
|
const POP_HOLD_MS = 400;
|
|
/** A request this young when the map mounts was meant for this map: the chat switched to it to show something. */
|
|
const FRESH_REQUEST_MS = 5000;
|
|
|
|
/** The renderer's padding, widened on the side the assistant's window covers. */
|
|
export function coverPadding(base: ViewportPadding, cover: AssistantCamera['cover']): ViewportPadding {
|
|
if (!cover) return base;
|
|
return { ...base, [cover.side]: Math.max(base[cover.side], cover.px) };
|
|
}
|
|
|
|
/** The middle of the points' box, [lat, lng]. */
|
|
export function centreOf(points: readonly [number, number][]): [number, number] {
|
|
const lats = points.map(([lat]) => lat);
|
|
const lngs = points.map(([, lng]) => lng);
|
|
return [(Math.min(...lats) + Math.max(...lats)) / 2, (Math.min(...lngs) + Math.max(...lngs)) / 2];
|
|
}
|
|
|
|
/**
|
|
* When the user last pressed, scrolled or touched the map. Read from the
|
|
* input itself rather than from the map's move events, which fire for the
|
|
* assistant's own moves just the same.
|
|
*/
|
|
export function useUserMoveClock(getContainer: () => HTMLElement | null, ready: boolean): { readonly current: number } {
|
|
const last = useRef(0);
|
|
useEffect(() => {
|
|
const container = ready ? getContainer() : null;
|
|
if (!container) return;
|
|
const mark = () => {
|
|
last.current = Date.now();
|
|
};
|
|
const events = ['pointerdown', 'wheel', 'touchstart'] as const;
|
|
events.forEach((type) => container.addEventListener(type, mark, { passive: true }));
|
|
return () => events.forEach((type) => container.removeEventListener(type, mark));
|
|
}, [getContainer, ready]);
|
|
return last;
|
|
}
|
|
|
|
/**
|
|
* The request the camera should carry out now, or null. The one present when
|
|
* the map mounts is skipped (it was meant for a map that is gone) unless it
|
|
* was asked for a moment ago (the chat switched to the map to show it), as is a
|
|
* repeated key, and a move the user did not ask for while they are moving
|
|
* the map themselves.
|
|
*/
|
|
export function useCameraRequest(
|
|
camera: AssistantCamera | null | undefined,
|
|
userMovedAt: { readonly current: number }
|
|
): (run: (camera: AssistantCamera) => void) => void {
|
|
// The request on screen when the map mounted, judged on the first run: young enough, it was meant for this map.
|
|
const mounted = useRef(camera ?? null);
|
|
const seen = useRef<string | null>(null);
|
|
return (run) => {
|
|
const first = mounted.current;
|
|
if (first) {
|
|
mounted.current = null;
|
|
if (!(first.at && Date.now() - first.at < FRESH_REQUEST_MS)) seen.current = first.key;
|
|
}
|
|
if (!camera || camera.key === seen.current) return;
|
|
seen.current = camera.key;
|
|
if (camera.auto && Date.now() - userMovedAt.current < USER_MOVE_QUIET_MS) return;
|
|
if (camera.points.length > 0) run(camera);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Puts the pop class on the pins of places that just arrived and the pulse
|
|
* class on the pin the hovered suggestion is about. A frame late on purpose:
|
|
* the GL map rebuilds its pins in the next frame after the places change.
|
|
* A mark ends on the pin the place has then, which on the GL map may be a
|
|
* newer element than the one marked (`carryAssistantMarks` in useAssistantMapGL).
|
|
*/
|
|
export function useAssistantMarkerClasses(
|
|
markerEl: (placeId: number) => HTMLElement | null,
|
|
poppedPlaceIds: readonly number[] | undefined,
|
|
pulsePlaceId: number | null | undefined
|
|
): void {
|
|
useEffect(() => {
|
|
if (!poppedPlaceIds?.length) return;
|
|
let marked: HTMLElement[] = [];
|
|
const unmark = () =>
|
|
[...marked, ...poppedPlaceIds.flatMap((id) => markerEl(id) ?? [])].forEach((el) =>
|
|
el.classList.remove(PLACE_POP_CLASS)
|
|
);
|
|
const frame = requestAnimationFrame(() => {
|
|
marked = poppedPlaceIds.flatMap((id) => markerEl(id) ?? []);
|
|
marked.forEach((el) => el.classList.add(PLACE_POP_CLASS));
|
|
});
|
|
const timer = window.setTimeout(unmark, POP_HOLD_MS);
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
window.clearTimeout(timer);
|
|
unmark();
|
|
};
|
|
}, [markerEl, poppedPlaceIds]);
|
|
|
|
useEffect(() => {
|
|
if (pulsePlaceId == null) return;
|
|
let marked: HTMLElement | null = null;
|
|
const frame = requestAnimationFrame(() => {
|
|
marked = markerEl(pulsePlaceId);
|
|
marked?.classList.add(PLACE_PULSE_CLASS);
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
marked?.classList.remove(PLACE_PULSE_CLASS);
|
|
markerEl(pulsePlaceId)?.classList.remove(PLACE_PULSE_CLASS);
|
|
};
|
|
}, [markerEl, pulsePlaceId]);
|
|
}
|