mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
214984f398
Adds an in-app assistant that reads and plans the open trip. Users connect their own provider (Sign in with ChatGPT, or an Anthropic, OpenAI, Gemini or OpenRouter key) or use the one the admin set up, including a custom OpenAI-compatible server. It is an addon and only shows while a connection is usable. The chat runs on the existing MCP tools, pinned to the trip. Every change is held as a change card the user applies or discards, and applied changes can be undone. On the desktop it is a floating window that can be moved and resized; on the phone it is fullscreen. Search results show as place cards that can go onto a day, weather lookups as a strip, proposed places as ghost markers on the map with a before/after switch, and an answer can be shared into the trip chat. Booking files can be dropped in and are read into change cards.
117 lines
4.6 KiB
TypeScript
117 lines
4.6 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;
|
|
|
|
/** 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), 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 {
|
|
const seen = useRef(camera?.key ?? null);
|
|
return (run) => {
|
|
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]);
|
|
}
|