mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(assistant): trip assistant chat with your own AI connection
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.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import L from 'leaflet';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
|
||||
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
|
||||
import type { AssistantCamera } from './mapViewContract';
|
||||
|
||||
interface AssistantCameraControllerProps {
|
||||
camera: AssistantCamera | null | undefined;
|
||||
/** The planner's padding for its panels, in pixels per edge. */
|
||||
padding: ViewportPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the Leaflet map where the assistant asks: `fit` frames the points,
|
||||
* `pan` brings their centre into view only when it is outside. Zoomed in no
|
||||
* further than a day fit, the same scale the GL map stops at.
|
||||
*/
|
||||
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
|
||||
const map = useMap();
|
||||
const getContainer = useCallback(() => map.getContainer(), [map]);
|
||||
const userMovedAt = useUserMoveClock(getContainer, true);
|
||||
const request = useCameraRequest(camera, userMovedAt);
|
||||
|
||||
useEffect(() => {
|
||||
request((next) => {
|
||||
const duration = ASSISTANT_CAMERA_MS / 1000;
|
||||
if (next.mode === 'pan') {
|
||||
const centre = centreOf(next.points);
|
||||
if (!map.getBounds().contains(centre)) map.panTo(centre, { animate: next.animate, duration });
|
||||
return;
|
||||
}
|
||||
const box = coverPadding(padding, next.cover);
|
||||
map.fitBounds(L.latLngBounds(next.points), {
|
||||
paddingTopLeft: [box.left, box.top],
|
||||
paddingBottomRight: [box.right, box.bottom],
|
||||
maxZoom: MAX_ZOOM_RASTER,
|
||||
animate: next.animate,
|
||||
duration,
|
||||
});
|
||||
});
|
||||
}, [map, padding, request]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
|
||||
import type { AssistantGhost } from './mapViewContract';
|
||||
|
||||
interface AssistantGhostLayerProps {
|
||||
ghosts: readonly AssistantGhost[] | undefined;
|
||||
hoveredId: string | null | undefined;
|
||||
onHover?: (id: string | null) => void;
|
||||
onClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Above the place pins, so a suggestion on a busy day is never buried under the stops it joins. */
|
||||
const GHOST_Z_OFFSET = 900;
|
||||
|
||||
/**
|
||||
* The assistant's suggestions on the Leaflet map, outside the cluster group:
|
||||
* a suggestion stands on its own until it is decided. The name shows in the
|
||||
* map's own tooltip, built from text. A hover only toggles a class on the
|
||||
* element; the pins are rebuilt when the suggestions change, never on hover.
|
||||
*/
|
||||
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
|
||||
const map = useMap();
|
||||
const handlers = useRef<GhostHandlers>({ onHover, onClick });
|
||||
useLayoutEffect(() => {
|
||||
handlers.current = { onHover, onClick };
|
||||
}, [onHover, onClick]);
|
||||
const elements = useRef<ReadonlyMap<string, HTMLElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!ghosts?.length) return;
|
||||
const mounted = mountGhosts(
|
||||
ghosts,
|
||||
(el, ghost) => {
|
||||
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
|
||||
const label = document.createElement('span');
|
||||
label.textContent = ghost.name;
|
||||
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
|
||||
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
|
||||
.addTo(map);
|
||||
return { remove: () => marker.remove() };
|
||||
},
|
||||
handlers
|
||||
);
|
||||
elements.current = mounted.elements;
|
||||
return () => {
|
||||
mounted.dispose();
|
||||
elements.current = new Map();
|
||||
};
|
||||
}, [ghosts, map]);
|
||||
|
||||
useEffect(() => {
|
||||
setGhostHot(elements.current, hoveredId);
|
||||
}, [ghosts, hoveredId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* FE-MAP-ASSIST-LEAFLET-001 to 003
|
||||
*
|
||||
* The assistant on the Leaflet map, against real Leaflet: ghosts outside the
|
||||
* clusters that only change a class on hover, a camera that frames with the
|
||||
* same scale limit as a day fit and skips the request it mounted with, and
|
||||
* the pop on a real pin.
|
||||
*/
|
||||
import L from 'leaflet';
|
||||
import { MapContainer } from 'react-leaflet';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render } from '../../../tests/helpers/render';
|
||||
import { MAX_ZOOM_RASTER } from '../../utils/mapViewport';
|
||||
import { AssistantMapLeaflet } from './AssistantMapLeaflet';
|
||||
import type { AssistantCamera, AssistantMapProps } from './mapViewContract';
|
||||
|
||||
const PADDING = { top: 60, right: 40, bottom: 60, left: 40 };
|
||||
const NO_PIN = () => null;
|
||||
|
||||
const sized = ['clientWidth', 'clientHeight'] as const;
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
beforeAll(() => {
|
||||
for (const key of sized) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
|
||||
Object.defineProperty(HTMLElement.prototype, key, {
|
||||
configurable: true,
|
||||
get: () => (key === 'clientWidth' ? 1000 : 700),
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(() => {
|
||||
for (const key of sized) {
|
||||
const original = originals.get(key);
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
|
||||
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function onMap(assistant: AssistantMapProps, markerEl: (id: number) => HTMLElement | null = NO_PIN) {
|
||||
return (
|
||||
<MapContainer center={[41.9, 12.5]} zoom={12} style={{ width: 1000, height: 700 }}>
|
||||
<AssistantMapLeaflet assistant={assistant} padding={PADDING} markerEl={markerEl} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const GHOSTS = [{ id: 'c1', lat: 41.9, lng: 12.48, name: 'Pantheon' }];
|
||||
|
||||
describe('AssistantMapLeaflet', () => {
|
||||
it('FE-MAP-ASSIST-LEAFLET-001: a ghost stands on the map and is marked while its suggestion is hovered', () => {
|
||||
const onHover = vi.fn();
|
||||
const view = render(onMap({ assistantGhosts: GHOSTS, assistantHoveredId: null, onAssistantGhostHover: onHover }));
|
||||
const ghost = view.container.querySelector<HTMLElement>('.trek-ghost');
|
||||
expect(ghost?.getAttribute('aria-label')).toBe('Pantheon');
|
||||
ghost?.dispatchEvent(new Event('pointerenter'));
|
||||
expect(onHover).toHaveBeenCalledWith('c1');
|
||||
|
||||
view.rerender(onMap({ assistantGhosts: GHOSTS, assistantHoveredId: 'c1', onAssistantGhostHover: onHover }));
|
||||
expect(view.container.querySelectorAll('.trek-ghost')).toHaveLength(1);
|
||||
expect(view.container.querySelector('.trek-ghost')).toBe(ghost);
|
||||
expect(ghost?.classList.contains('is-hot')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MAP-ASSIST-LEAFLET-002: the camera skips the request it mounted with and frames a new one', () => {
|
||||
const fit = vi.spyOn(L.Map.prototype, 'fitBounds');
|
||||
const cam = (key: string): AssistantCamera => ({
|
||||
key,
|
||||
points: [
|
||||
[41.8, 12.4],
|
||||
[42, 12.6],
|
||||
],
|
||||
mode: 'fit',
|
||||
animate: false,
|
||||
auto: false,
|
||||
cover: { side: 'left', px: 400 },
|
||||
});
|
||||
const view = render(onMap({ assistantCamera: cam('1') }));
|
||||
expect(fit).not.toHaveBeenCalled();
|
||||
|
||||
view.rerender(onMap({ assistantCamera: cam('2') }));
|
||||
expect(fit).toHaveBeenCalledOnce();
|
||||
expect(fit.mock.calls[0]?.[1]).toMatchObject({
|
||||
paddingTopLeft: [400, 60],
|
||||
paddingBottomRight: [40, 60],
|
||||
maxZoom: MAX_ZOOM_RASTER,
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MAP-ASSIST-LEAFLET-003: a place that just arrived pops on its own pin', async () => {
|
||||
const pin = document.createElement('div');
|
||||
pin.appendChild(document.createElement('div'));
|
||||
render(onMap({ poppedPlaceIds: [5] }, (id) => (id === 5 ? pin : null)));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
expect(pin.classList.contains('trek-place-pop')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { AssistantCameraController } from './AssistantCameraController';
|
||||
import { AssistantGhostLayer } from './AssistantGhostLayer';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
|
||||
interface AssistantMapLeafletProps {
|
||||
assistant: AssistantMapProps;
|
||||
/** The planner's padding for its panels, for the camera's fits. */
|
||||
padding: ViewportPadding;
|
||||
/** The element of a place's pin, when it has one on the map now. */
|
||||
markerEl: (placeId: number) => HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the assistant does on the Leaflet map, mounted inside the map
|
||||
* container as one child, so MapView only hands its props on: the ghosts,
|
||||
* the camera, and the pop and pulse on the real pins.
|
||||
*/
|
||||
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
return (
|
||||
<>
|
||||
<AssistantGhostLayer
|
||||
ghosts={assistant.assistantGhosts}
|
||||
hoveredId={assistant.assistantHoveredId}
|
||||
onHover={assistant.onAssistantGhostHover}
|
||||
onClick={assistant.onAssistantGhostClick}
|
||||
/>
|
||||
<AssistantCameraController camera={assistant.assistantCamera} padding={padding} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, memo, type ComponentType } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { categoryIconSvg } from './categoryIconSvg'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
@@ -40,7 +40,7 @@ import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { AssistantMapLeaflet } from './AssistantMapLeaflet'
|
||||
import PlaceHoverCard from './PlaceHoverCard'
|
||||
import { ratingBadgeHtml } from './ratingBadge'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
@@ -75,12 +75,6 @@ import { MapLockPill } from './MapLockPill'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderIconMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
import type { MapViewProps } from './mapViewContract'
|
||||
|
||||
// Fix default marker icons for vite. `_getIconUrl` is a Leaflet-internal field
|
||||
@@ -834,6 +828,7 @@ export const MapView = memo(function MapView({
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
...assistant
|
||||
}: MapViewProps) {
|
||||
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
@@ -972,7 +967,7 @@ export const MapView = memo(function MapView({
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
// Drag a marker onto a day (#891). Pointer-driven, so it is off wherever
|
||||
// HTML5 drag does not exist — and the day plan is not on screen there anyway.
|
||||
const markersDraggable = !isTouchDevice
|
||||
const markersDraggable = !isTouchDevice && !assistant.markersLocked
|
||||
|
||||
/**
|
||||
* Reaching a stop that shares its coordinates with others.
|
||||
@@ -991,6 +986,7 @@ export const MapView = memo(function MapView({
|
||||
if (marker) placeMarkersRef.current.set(id, marker)
|
||||
else placeMarkersRef.current.delete(id)
|
||||
}, [])
|
||||
const assistantMarkerEl = useCallback((id: number) => (placeMarkersRef.current.get(id) as L.Marker | undefined)?.getElement() ?? null, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId == null) return
|
||||
@@ -1316,6 +1312,7 @@ export const MapView = memo(function MapView({
|
||||
</RouteViaMarker>
|
||||
))}
|
||||
<PluginMapMarkers tripId={tripId} />
|
||||
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
|
||||
<PluginMapLayers tripId={tripId} />
|
||||
</MapContainer>
|
||||
{isMobile && <LocationButton
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
|
||||
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { categoryIconSvg } from './categoryIconSvg'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
@@ -16,7 +17,6 @@ import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
@@ -50,13 +50,6 @@ import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefault
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderIconMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
|
||||
@@ -562,6 +555,7 @@ export function MapViewGL({
|
||||
glProvider = 'mapbox-gl',
|
||||
gl,
|
||||
onMapReady,
|
||||
...assistant
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
@@ -640,6 +634,7 @@ export function MapViewGL({
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
|
||||
const assistantGhostPinsRef = useRef<PlacePin[]>([])
|
||||
const profileFocusPinRef = useRef<PlacePin | null>(null)
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([])
|
||||
@@ -652,6 +647,7 @@ export function MapViewGL({
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
viaPinsRef.current.forEach(pin => pin.reposition())
|
||||
plannerWaypointPinsRef.current.forEach(pin => pin.reposition())
|
||||
assistantGhostPinsRef.current.forEach(pin => pin.reposition())
|
||||
profileFocusPinRef.current?.reposition()
|
||||
altLabelsRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
@@ -1659,7 +1655,7 @@ export function MapViewGL({
|
||||
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
|
||||
// on every reconcile, so the listeners go with the element and need no
|
||||
// teardown of their own.
|
||||
if (markersDraggableRef.current) makeMarkerDraggable(el, place.id)
|
||||
if (markersDraggableRef.current && !assistant.markersLocked) makeMarkerDraggable(el, place.id)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
// Clear the card right away — the flyTo that follows moves the marker
|
||||
@@ -1688,7 +1684,7 @@ export function MapViewGL({
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
if (existing) existing.remove()
|
||||
if (existing) { carryAssistantMarks(existing.el, el); existing.remove() }
|
||||
// Default (viewport-aligned) anchors keep the marker parallel to the
|
||||
// screen so its pixel centre lines up with the route line at any
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
@@ -1761,7 +1757,7 @@ export function MapViewGL({
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely, assistant.markersLocked])
|
||||
|
||||
// Search results stay separate from the planned itinerary.
|
||||
useEffect(() => {
|
||||
@@ -2018,6 +2014,7 @@ export function MapViewGL({
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
useAssistantMapGL({ mapRef, mapReady, gl, pinsRef: assistantGhostPinsRef, attachPin: attachPlannerWaypointPin, markersRef, padding: paddingOpts, assistant })
|
||||
|
||||
const prevFitKey = useRef<number | null>(-1)
|
||||
const pendingRouteFitRef = useRef<{ fitKey: number | null; routeKey: string } | null>(null)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// FE-MAP-ASSIST-CAMERA-001 to 004: what both renderers share when the assistant moves the map or marks a pin.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { centreOf, coverPadding, useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { carryAssistantMarks } from './useAssistantMapGL';
|
||||
|
||||
describe('assistantCamera', () => {
|
||||
it('FE-MAP-ASSIST-CAMERA-001: the window widens the padding on its side only, never narrows it', () => {
|
||||
const base = { top: 60, right: 340, bottom: 60, left: 40 };
|
||||
expect(coverPadding(base, null)).toBe(base);
|
||||
expect(coverPadding(base, { side: 'left', px: 400 })).toEqual({ ...base, left: 400 });
|
||||
expect(coverPadding(base, { side: 'right', px: 100 })).toEqual(base);
|
||||
});
|
||||
|
||||
it('FE-MAP-ASSIST-CAMERA-002: the centre is the middle of the box the points span', () => {
|
||||
expect(
|
||||
centreOf([
|
||||
[40, 10],
|
||||
[44, 12],
|
||||
[42, 14],
|
||||
])
|
||||
).toEqual([42, 12]);
|
||||
});
|
||||
|
||||
it('FE-MAP-ASSIST-CAMERA-003: a popped pin is marked for a moment, a pulsing one while it is hovered', async () => {
|
||||
const pins = new Map([
|
||||
[1, document.createElement('div')],
|
||||
[2, document.createElement('div')],
|
||||
]);
|
||||
const markerEl = (id: number) => pins.get(id) ?? null;
|
||||
const popped: readonly number[] = [1];
|
||||
const { rerender } = renderHook(({ pulse }) => useAssistantMarkerClasses(markerEl, popped, pulse), {
|
||||
initialProps: { pulse: 2 as number | null },
|
||||
});
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(resolve)));
|
||||
expect(pins.get(1)?.classList.contains('trek-place-pop')).toBe(true);
|
||||
expect(pins.get(2)?.classList.contains('trek-place-pulse')).toBe(true);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 450)));
|
||||
expect(pins.get(1)?.classList.contains('trek-place-pop')).toBe(false);
|
||||
|
||||
rerender({ pulse: null });
|
||||
expect(pins.get(2)?.classList.contains('trek-place-pulse')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-ASSIST-CAMERA-004: a pin the GL map rebuilds keeps its marks, and they end on the new pin', async () => {
|
||||
const pins = new Map([
|
||||
[1, document.createElement('div')],
|
||||
[2, document.createElement('div')],
|
||||
]);
|
||||
const markerEl = (id: number) => pins.get(id) ?? null;
|
||||
const popped: readonly number[] = [1];
|
||||
const { rerender } = renderHook(({ pulse }) => useAssistantMarkerClasses(markerEl, popped, pulse), {
|
||||
initialProps: { pulse: 2 as number | null },
|
||||
});
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(resolve)));
|
||||
|
||||
// A pan rebuilds both pins.
|
||||
for (const id of [1, 2]) {
|
||||
const rebuilt = document.createElement('div');
|
||||
carryAssistantMarks(pins.get(id) as HTMLElement, rebuilt);
|
||||
pins.set(id, rebuilt);
|
||||
}
|
||||
expect(pins.get(1)?.classList.contains('trek-place-pop')).toBe(true);
|
||||
expect(pins.get(2)?.classList.contains('trek-place-pulse')).toBe(true);
|
||||
expect(pins.get(2)?.classList.contains('trek-place-pop')).toBe(false);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 450)));
|
||||
expect(pins.get(1)?.classList.contains('trek-place-pop')).toBe(false);
|
||||
rerender({ pulse: null });
|
||||
expect(pins.get(2)?.classList.contains('trek-place-pulse')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* The assistant on the map: a suggested place drawn before it exists (a ghost),
|
||||
* the pulse when its row or pin is under the pointer, and the pop of a place
|
||||
* that just arrived. Both renderers position the outer element themselves (a
|
||||
* transform), so every animation runs on its first child and never fights
|
||||
* the position.
|
||||
*/
|
||||
|
||||
@keyframes trek-ghost-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trek-ghost-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 12px color-mix(in srgb, var(--accent) 0%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes trek-place-pop {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
65% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.trek-ghost-pin {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.trek-ghost {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trek-ghost-face {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--bg-card) 60%, transparent);
|
||||
color: var(--ghost-color, var(--text-secondary));
|
||||
box-shadow: var(--shadow-sm);
|
||||
animation: trek-ghost-enter 180ms var(--ease-out-quint) both;
|
||||
}
|
||||
|
||||
.trek-ghost.is-hot .trek-ghost-face {
|
||||
border-style: solid;
|
||||
animation:
|
||||
trek-ghost-enter 180ms var(--ease-out-quint) both,
|
||||
trek-ghost-pulse 900ms var(--ease-out-quint) 2;
|
||||
}
|
||||
|
||||
.trek-place-pulse > :first-child {
|
||||
animation: trek-ghost-pulse 900ms var(--ease-out-quint) 2;
|
||||
}
|
||||
|
||||
.trek-place-pop > :first-child {
|
||||
animation: trek-place-pop 220ms var(--ease-out-quint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-ghost-face {
|
||||
animation: trek-ghost-enter 120ms ease-out both;
|
||||
}
|
||||
.trek-ghost.is-hot .trek-ghost-face,
|
||||
.trek-place-pulse > :first-child {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 3px var(--accent);
|
||||
}
|
||||
.trek-place-pop > :first-child {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
[data-reduce-motion] .trek-ghost-face {
|
||||
animation: trek-ghost-enter 120ms ease-out both;
|
||||
}
|
||||
[data-reduce-motion] .trek-ghost.is-hot .trek-ghost-face,
|
||||
[data-reduce-motion] .trek-place-pulse > :first-child {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 3px var(--accent);
|
||||
}
|
||||
[data-reduce-motion] .trek-place-pop > :first-child {
|
||||
animation: trek-backdrop-enter 120ms ease-out;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// FE-MAP-GHOST-001 to 003: the assistant's suggested places as map pins, built once for both renderers.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { GHOST_HOT_CLASS, buildGhostElement, mountGhosts, setGhostHot } from './assistantGhostMarker';
|
||||
|
||||
const GHOST = { id: 'c1', lat: 41.9, lng: 12.48, name: 'Pantheon', color: '#ff0000', icon: 'Landmark' };
|
||||
|
||||
describe('assistantGhostMarker', () => {
|
||||
it('FE-MAP-GHOST-001: a name never becomes markup, and the colour goes in as a CSS variable', () => {
|
||||
const el = buildGhostElement({ ...GHOST, name: '<img src=x onerror=alert(1)>' });
|
||||
expect(el.querySelector('img')).toBeNull();
|
||||
expect(el.getAttribute('aria-label')).toBe('<img src=x onerror=alert(1)>');
|
||||
expect(el.style.getPropertyValue('--ghost-color')).toBe('#ff0000');
|
||||
expect(el.querySelector('.trek-ghost-face svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-GHOST-002: the pointer reports the hover, a click or Enter reports the ghost without reaching the map', () => {
|
||||
const onHover = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const removed = vi.fn();
|
||||
const map = document.createElement('div');
|
||||
const mapClick = vi.fn();
|
||||
map.addEventListener('click', mapClick);
|
||||
const mounted = mountGhosts(
|
||||
[GHOST],
|
||||
(el) => {
|
||||
map.appendChild(el);
|
||||
return { remove: removed };
|
||||
},
|
||||
{ current: { onHover, onClick } }
|
||||
);
|
||||
const el = mounted.elements.get('c1');
|
||||
el?.dispatchEvent(new Event('pointerenter'));
|
||||
el?.dispatchEvent(new Event('pointerleave'));
|
||||
el?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
el?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
expect(mapClick).not.toHaveBeenCalled();
|
||||
|
||||
mounted.dispose();
|
||||
expect(removed).toHaveBeenCalledOnce();
|
||||
expect(mounted.elements.size).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-MAP-GHOST-003: only the hovered ghost is marked', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
const elements = new Map([
|
||||
['a', a],
|
||||
['b', b],
|
||||
]);
|
||||
setGhostHot(elements, 'b');
|
||||
expect([a.classList.contains(GHOST_HOT_CLASS), b.classList.contains(GHOST_HOT_CLASS)]).toEqual([false, true]);
|
||||
setGhostHot(elements, null);
|
||||
expect(b.classList.contains(GHOST_HOT_CLASS)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import './assistantGhost.css';
|
||||
import { categoryIconSvg } from './categoryIconSvg';
|
||||
import type { AssistantGhost } from './mapViewContract';
|
||||
|
||||
/**
|
||||
* The assistant's suggested places on the map, built once for both renderers.
|
||||
*
|
||||
* A ghost is a plain element: the Leaflet map hands it to a divIcon, the GL
|
||||
* map positions it like its other hand-placed pins. Nothing from the ghost's
|
||||
* data goes in as markup. The name is an attribute and the renderer's own
|
||||
* label shows it as text, the category colour is a CSS variable set through
|
||||
* the style API, and the icon is looked up in the app's fixed icon table.
|
||||
*/
|
||||
|
||||
/** The ghost's box in CSS pixels, for the anchor of a Leaflet divIcon. */
|
||||
export const GHOST_SIZE = 30;
|
||||
/** On a ghost whose suggestion is under the pointer, here or in the chat. */
|
||||
export const GHOST_HOT_CLASS = 'is-hot';
|
||||
/** On a real pin that just arrived from an applied suggestion. */
|
||||
export const PLACE_POP_CLASS = 'trek-place-pop';
|
||||
/** On a real pin the hovered suggestion is about. */
|
||||
export const PLACE_PULSE_CLASS = 'trek-place-pulse';
|
||||
|
||||
const iconMarkup = new Map<string, string>();
|
||||
|
||||
/** The category's icon in the ghost's colour, rendered once per icon name. */
|
||||
function ghostIcon(name: string | undefined): string {
|
||||
const key = name ?? '';
|
||||
let markup = iconMarkup.get(key);
|
||||
if (markup === undefined) {
|
||||
markup = categoryIconSvg(name, 14, 'currentColor');
|
||||
iconMarkup.set(key, markup);
|
||||
}
|
||||
return markup;
|
||||
}
|
||||
|
||||
export function buildGhostElement(ghost: AssistantGhost): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'trek-ghost';
|
||||
el.dataset.ghostId = ghost.id;
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('aria-label', ghost.name);
|
||||
if (ghost.color) el.style.setProperty('--ghost-color', ghost.color);
|
||||
const face = document.createElement('div');
|
||||
face.className = 'trek-ghost-face';
|
||||
// A fixed glyph from the icon table, never anything the ghost carries.
|
||||
face.innerHTML = ghostIcon(ghost.icon);
|
||||
el.appendChild(face);
|
||||
return el;
|
||||
}
|
||||
|
||||
export interface GhostHandlers {
|
||||
onHover?: (id: string | null) => void;
|
||||
onClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
/** What a renderer gives back for a ghost it put on its map. */
|
||||
interface GhostPin {
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
interface MountedGhosts {
|
||||
elements: Map<string, HTMLElement>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts every ghost on the map through `place` and wires the pointer: entering
|
||||
* and leaving report the hover, a click (or Enter) reports the ghost without
|
||||
* reaching the map underneath. The handlers are read through a ref, so a
|
||||
* parent passing fresh callbacks does not rebuild the pins.
|
||||
*/
|
||||
export function mountGhosts(
|
||||
ghosts: readonly AssistantGhost[],
|
||||
place: (el: HTMLElement, ghost: AssistantGhost) => GhostPin,
|
||||
handlers: { readonly current: GhostHandlers }
|
||||
): MountedGhosts {
|
||||
const elements = new Map<string, HTMLElement>();
|
||||
const cleanups: (() => void)[] = [];
|
||||
for (const ghost of ghosts) {
|
||||
const el = buildGhostElement(ghost);
|
||||
const enter = () => handlers.current.onHover?.(ghost.id);
|
||||
const leave = () => handlers.current.onHover?.(null);
|
||||
const click = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
handlers.current.onClick?.(ghost.id);
|
||||
};
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') click(event);
|
||||
};
|
||||
el.addEventListener('pointerenter', enter);
|
||||
el.addEventListener('pointerleave', leave);
|
||||
el.addEventListener('click', click);
|
||||
el.addEventListener('keydown', key);
|
||||
const pin = place(el, ghost);
|
||||
elements.set(ghost.id, el);
|
||||
cleanups.push(() => {
|
||||
el.removeEventListener('pointerenter', enter);
|
||||
el.removeEventListener('pointerleave', leave);
|
||||
el.removeEventListener('click', click);
|
||||
el.removeEventListener('keydown', key);
|
||||
pin.remove();
|
||||
});
|
||||
}
|
||||
return {
|
||||
elements,
|
||||
dispose: () => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
elements.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks the hovered ghost, and only it, without rebuilding any pin. */
|
||||
export function setGhostHot(elements: ReadonlyMap<string, HTMLElement>, hoveredId: string | null | undefined): void {
|
||||
elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, id === hoveredId));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createElement } from 'react';
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup';
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons';
|
||||
|
||||
/**
|
||||
* A category's icon as markup for a hand-built map pin, the same on the
|
||||
* Leaflet and the GL map. The name only picks from the app's own icon table
|
||||
* (an unknown one falls back to the pin), so nothing it carries reaches the
|
||||
* markup. Every icon in that table is rendered by iconMarkup.parity.test.ts,
|
||||
* so a failure here is a bug to surface, not a case to hide.
|
||||
*/
|
||||
export function categoryIconSvg(
|
||||
iconName: string | null | undefined,
|
||||
size: number,
|
||||
color = 'white',
|
||||
strokeWidth = 2.5
|
||||
): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'];
|
||||
return renderIconMarkup(createElement(IconComponent, { size, color, strokeWidth }));
|
||||
}
|
||||
@@ -197,6 +197,58 @@ export interface MapViewProps {
|
||||
* it; the Leaflet renderer takes the prop and never calls it.
|
||||
*/
|
||||
onMapReady?: (map: CompassMap | null) => void;
|
||||
/** The assistant's suggestions that are not on the map yet, drawn as dashed pins outside the clusters. */
|
||||
assistantGhosts?: AssistantGhost[];
|
||||
/** The suggestion under the pointer, in the chat or on the map; its ghost pulses. */
|
||||
assistantHoveredId?: string | null;
|
||||
onAssistantGhostHover?: (id: string | null) => void;
|
||||
onAssistantGhostClick?: (id: string) => void;
|
||||
/** A place already on the map that the hovered suggestion is about; its own pin pulses instead of a ghost. */
|
||||
assistantPulsePlaceId?: number | null;
|
||||
/** Where the assistant asks the map to look; a new `key` is a new request. */
|
||||
assistantCamera?: AssistantCamera | null;
|
||||
/** Places that just arrived from an applied suggestion; their pins pop once. */
|
||||
poppedPlaceIds?: readonly number[];
|
||||
/** While the map shows the plan before a change, its pins cannot be dragged onto a day. */
|
||||
markersLocked?: boolean;
|
||||
}
|
||||
|
||||
/** The assistant's part of the contract, which both renderers hand to their assistant modules whole. */
|
||||
export type AssistantMapProps = Pick<
|
||||
MapViewProps,
|
||||
| 'assistantGhosts'
|
||||
| 'assistantHoveredId'
|
||||
| 'onAssistantGhostHover'
|
||||
| 'onAssistantGhostClick'
|
||||
| 'assistantPulsePlaceId'
|
||||
| 'assistantCamera'
|
||||
| 'poppedPlaceIds'
|
||||
| 'markersLocked'
|
||||
>;
|
||||
|
||||
/** A suggested place drawn before it exists. `color` and `icon` are its category's. */
|
||||
export interface AssistantGhost {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera move the assistant asks for. `fit` frames all points, `pan` only
|
||||
* brings their centre into view when it is outside. `auto` marks a move the
|
||||
* user did not ask for: the map skips it while the user is moving the map
|
||||
* themselves. `cover` is what the assistant's window hides of the map.
|
||||
*/
|
||||
export interface AssistantCamera {
|
||||
key: string;
|
||||
points: [number, number][];
|
||||
mode: 'fit' | 'pan';
|
||||
animate: boolean;
|
||||
auto: boolean;
|
||||
cover?: { side: 'left' | 'right'; px: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// FE-MAP-CAMERA-GL-001 to 003: the GL map moves where the assistant asks, on the same rules as the Leaflet map.
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MAX_ZOOM_GL } from '../../utils/mapViewport';
|
||||
import type { AssistantCamera } from './mapViewContract';
|
||||
import { useAssistantCameraGL } from './useAssistantCameraGL';
|
||||
|
||||
const PADDING = { top: 60, right: 40, bottom: 60, left: 40 };
|
||||
|
||||
class Bounds {
|
||||
points: [number, number][] = [];
|
||||
extend(lngLat: [number, number]) {
|
||||
this.points.push(lngLat);
|
||||
}
|
||||
}
|
||||
|
||||
function camera(key: string, over: Partial<AssistantCamera> = {}): AssistantCamera {
|
||||
return {
|
||||
key,
|
||||
points: [
|
||||
[41, 12],
|
||||
[43, 13],
|
||||
],
|
||||
mode: 'fit',
|
||||
animate: true,
|
||||
auto: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(first: AssistantCamera | null, inside = false) {
|
||||
const container = document.createElement('div');
|
||||
const map = {
|
||||
getContainer: () => container,
|
||||
getBounds: () => ({ contains: () => inside }),
|
||||
fitBounds: vi.fn(),
|
||||
easeTo: vi.fn(),
|
||||
};
|
||||
const mapRef = { current: map };
|
||||
const gl = { LngLatBounds: Bounds };
|
||||
const hook = renderHook(
|
||||
({ cam }) => useAssistantCameraGL({ mapRef, mapReady: true, gl, camera: cam, padding: PADDING }),
|
||||
{ initialProps: { cam: first } }
|
||||
);
|
||||
return { map, container, hook };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useAssistantCameraGL', () => {
|
||||
it('FE-MAP-CAMERA-GL-001: the request present at mount is skipped; a new one frames its points around the window', () => {
|
||||
const { map, hook } = setup(camera('1'));
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
|
||||
hook.rerender({ cam: camera('2', { cover: { side: 'right', px: 420 }, animate: false }) });
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
const [bounds, options] = map.fitBounds.mock.calls[0] ?? [];
|
||||
expect((bounds as Bounds).points).toEqual([
|
||||
[12, 41],
|
||||
[13, 43],
|
||||
]);
|
||||
expect(options).toEqual({ padding: { ...PADDING, right: 420 }, maxZoom: MAX_ZOOM_GL, duration: 0 });
|
||||
|
||||
hook.rerender({ cam: camera('2', { cover: null }) });
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('FE-MAP-CAMERA-GL-002: a pan moves only when the centre is out of view', () => {
|
||||
const inside = setup(null, true);
|
||||
inside.hook.rerender({ cam: camera('1', { mode: 'pan' }) });
|
||||
expect(inside.map.easeTo).not.toHaveBeenCalled();
|
||||
|
||||
const outside = setup(null, false);
|
||||
outside.hook.rerender({ cam: camera('1', { mode: 'pan' }) });
|
||||
expect(outside.map.easeTo).toHaveBeenCalledWith({ center: [12.5, 42], duration: 600 });
|
||||
});
|
||||
|
||||
it('FE-MAP-CAMERA-GL-003: a move nobody asked for waits while the user is moving the map', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-21T08:00:00Z'));
|
||||
const { map, container, hook } = setup(null);
|
||||
container.dispatchEvent(new Event('pointerdown'));
|
||||
hook.rerender({ cam: camera('1', { auto: true }) });
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
|
||||
vi.setSystemTime(new Date('2026-10-21T08:00:06Z'));
|
||||
hook.rerender({ cam: camera('2', { auto: true }) });
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { MAX_ZOOM_GL, type ViewportPadding } from '../../utils/mapViewport';
|
||||
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
|
||||
import type { AssistantCamera } from './mapViewContract';
|
||||
|
||||
/** The slice of the GL map the camera drives. */
|
||||
export interface CameraMap {
|
||||
getContainer: () => HTMLElement;
|
||||
getBounds: () => { contains: (lngLat: [number, number]) => boolean } | null;
|
||||
fitBounds: (bounds: unknown, options: { padding: ViewportPadding; maxZoom: number; duration: number }) => unknown;
|
||||
easeTo: (options: { center: [number, number]; duration: number }) => unknown;
|
||||
}
|
||||
|
||||
/** The engine's bounds class, handed in so this module never imports mapbox-gl or maplibre-gl itself. */
|
||||
export interface CameraEngine {
|
||||
LngLatBounds: new () => { extend: (lngLat: [number, number]) => unknown };
|
||||
}
|
||||
|
||||
interface AssistantCameraOptions {
|
||||
mapRef: { readonly current: CameraMap | null };
|
||||
mapReady: boolean;
|
||||
gl: CameraEngine;
|
||||
camera: AssistantCamera | null | undefined;
|
||||
/** The planner's padding for its panels, in pixels per edge. */
|
||||
padding: ViewportPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the GL map where the assistant asks, the twin of the Leaflet
|
||||
* AssistantCameraController: `fit` frames the points, `pan` eases to their
|
||||
* centre only when it is outside the view. It stops at the same scale as the
|
||||
* Leaflet map (one GL zoom step is a raster step lower).
|
||||
*/
|
||||
export function useAssistantCameraGL({ mapRef, mapReady, gl, camera, padding }: AssistantCameraOptions): void {
|
||||
const getContainer = useCallback(() => mapRef.current?.getContainer() ?? null, [mapRef]);
|
||||
const userMovedAt = useUserMoveClock(getContainer, mapReady);
|
||||
const request = useCameraRequest(camera, userMovedAt);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) return;
|
||||
request((next) => {
|
||||
const duration = next.animate ? ASSISTANT_CAMERA_MS : 0;
|
||||
if (next.mode === 'pan') {
|
||||
const [lat, lng] = centreOf(next.points);
|
||||
if (!map.getBounds()?.contains([lng, lat])) map.easeTo({ center: [lng, lat], duration });
|
||||
return;
|
||||
}
|
||||
const bounds = new gl.LngLatBounds();
|
||||
next.points.forEach(([lat, lng]) => bounds.extend([lng, lat]));
|
||||
map.fitBounds(bounds, { padding: coverPadding(padding, next.cover), maxZoom: MAX_ZOOM_GL, duration });
|
||||
});
|
||||
}, [gl, mapReady, mapRef, padding, request]);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// FE-MAP-GHOST-GL-001 to 002: the assistant's suggestions on the GL map, as hand-positioned pins.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AssistantGhost } from './mapViewContract';
|
||||
import { useAssistantGhostMarkersGL } from './useAssistantGhostMarkersGL';
|
||||
|
||||
const GHOSTS: AssistantGhost[] = [
|
||||
{ id: 'a', lat: 41.9, lng: 12.48, name: 'Pantheon' },
|
||||
{ id: 'b', lat: 41.89, lng: 12.49, name: '<b>Forum</b>' },
|
||||
];
|
||||
|
||||
function setup(hoveredId: string | null = null) {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const map = { getContainer: () => container, on: vi.fn(), off: vi.fn() };
|
||||
const attached: { el: HTMLElement; lng: number; lat: number; remove: ReturnType<typeof vi.fn> }[] = [];
|
||||
const pinsRef = { current: [] as { remove: () => void }[] };
|
||||
const onHover = vi.fn();
|
||||
const hook = renderHook(
|
||||
({ hovered }) =>
|
||||
useAssistantGhostMarkersGL({
|
||||
mapRef: { current: map },
|
||||
mapReady: true,
|
||||
pinsRef,
|
||||
attachPin: (el, lng, lat) => {
|
||||
container.appendChild(el);
|
||||
const pin = { el, lng, lat, remove: vi.fn(() => el.remove()) };
|
||||
attached.push(pin);
|
||||
return pin;
|
||||
},
|
||||
ghosts: GHOSTS,
|
||||
hoveredId: hovered,
|
||||
onHover,
|
||||
onClick: vi.fn(),
|
||||
}),
|
||||
{ initialProps: { hovered: hoveredId } }
|
||||
);
|
||||
return { container, attached, pinsRef, onHover, hook };
|
||||
}
|
||||
|
||||
describe('useAssistantGhostMarkersGL', () => {
|
||||
it('FE-MAP-GHOST-GL-001: puts each ghost where it belongs, hands the pins to the reposition, and names it as text', () => {
|
||||
const { container, attached, pinsRef, onHover } = setup();
|
||||
expect(attached.map((p) => [p.lng, p.lat])).toEqual([
|
||||
[12.48, 41.9],
|
||||
[12.49, 41.89],
|
||||
]);
|
||||
expect(pinsRef.current).toHaveLength(2);
|
||||
attached[1]?.el.dispatchEvent(new Event('pointerenter'));
|
||||
expect(onHover).toHaveBeenCalledWith('b');
|
||||
const label = container.querySelector('[data-testid="assistant-ghost-label"]');
|
||||
expect(label?.textContent).toBe('<b>Forum</b>');
|
||||
expect(label?.querySelector('b')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-GHOST-GL-002: a hover marks its ghost without rebuilding a pin, and unmounting takes them all off', () => {
|
||||
const { attached, pinsRef, hook } = setup();
|
||||
act(() => hook.rerender({ hovered: 'a' }));
|
||||
expect(attached).toHaveLength(2);
|
||||
expect(attached[0]?.el.classList.contains('is-hot')).toBe(true);
|
||||
expect(attached[1]?.el.classList.contains('is-hot')).toBe(false);
|
||||
|
||||
hook.unmount();
|
||||
expect(attached.every((p) => p.remove.mock.calls.length === 1)).toBe(true);
|
||||
expect(pinsRef.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { AssistantGhost } from './mapViewContract';
|
||||
|
||||
/** The slice of the GL map the ghosts need: its box, and move events to hide the label on. */
|
||||
export interface GhostMap {
|
||||
getContainer: () => HTMLElement;
|
||||
on?: (type: 'movestart', listener: () => void) => unknown;
|
||||
off?: (type: 'movestart', listener: () => void) => unknown;
|
||||
}
|
||||
|
||||
interface GhostPinGL {
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
interface AssistantGhostMarkersOptions<Pin extends GhostPinGL> {
|
||||
mapRef: { readonly current: GhostMap | null };
|
||||
mapReady: boolean;
|
||||
/** The pins live in the caller's ref so its render-clock reposition can reach them. */
|
||||
pinsRef: { current: Pin[] };
|
||||
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
|
||||
ghosts: readonly AssistantGhost[] | undefined;
|
||||
hoveredId: string | null | undefined;
|
||||
onHover?: (id: string | null) => void;
|
||||
onClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant's suggestions on the GL map, the twin of the Leaflet
|
||||
* AssistantGhostLayer on the same builder. Each ghost is a hand-positioned
|
||||
* pin; its name floats over it in the map's hover label, set as text. The
|
||||
* hover is its own small effect that toggles a class, so it never rebuilds
|
||||
* a pin.
|
||||
*/
|
||||
export function useAssistantGhostMarkersGL<Pin extends GhostPinGL>({
|
||||
mapRef,
|
||||
mapReady,
|
||||
pinsRef,
|
||||
attachPin,
|
||||
ghosts,
|
||||
hoveredId,
|
||||
onHover,
|
||||
onClick,
|
||||
}: AssistantGhostMarkersOptions<Pin>): void {
|
||||
const handlers = useRef<GhostHandlers>({ onHover, onClick });
|
||||
useLayoutEffect(() => {
|
||||
handlers.current = { onHover, onClick };
|
||||
}, [onHover, onClick]);
|
||||
const elements = useRef<ReadonlyMap<string, HTMLElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady || !ghosts?.length) return;
|
||||
const label = mapHoverLabel(map, 'assistant-ghost-label');
|
||||
const hide = () => label.hide();
|
||||
map.on?.('movestart', hide);
|
||||
const unbind: (() => void)[] = [];
|
||||
const mounted = mountGhosts(
|
||||
ghosts,
|
||||
(el, ghost) => {
|
||||
const pin = attachPin(el, ghost.lng, ghost.lat);
|
||||
pinsRef.current.push(pin);
|
||||
unbind.push(bindPinHoverLabel(el, label, map.getContainer(), { name: ghost.name, detail: null }));
|
||||
return pin;
|
||||
},
|
||||
handlers
|
||||
);
|
||||
elements.current = mounted.elements;
|
||||
return () => {
|
||||
map.off?.('movestart', hide);
|
||||
unbind.forEach((off) => off());
|
||||
mounted.dispose();
|
||||
label.remove();
|
||||
pinsRef.current = [];
|
||||
elements.current = new Map();
|
||||
};
|
||||
}, [attachPin, ghosts, mapReady, mapRef, pinsRef]);
|
||||
|
||||
useEffect(() => {
|
||||
setGhostHot(elements.current, hoveredId);
|
||||
}, [ghosts, hoveredId, mapReady]);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
|
||||
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
|
||||
|
||||
interface PinGL {
|
||||
el: HTMLElement;
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
interface AssistantMapGLOptions<Pin extends PinGL> {
|
||||
/** The live GL map, typed by what the parts read; the engine itself stays out of this module. */
|
||||
mapRef: { readonly current: (CameraMap & GhostMap) | null };
|
||||
mapReady: boolean;
|
||||
gl: CameraEngine;
|
||||
/** The ghosts' pins, in the ref the renderer repositions on its render clock. */
|
||||
pinsRef: { current: Pin[] };
|
||||
attachPin: (el: HTMLElement, lng: number, lat: number) => Pin;
|
||||
/** The renderer's place pins by place id, for the pop and the pulse. */
|
||||
markersRef: { readonly current: ReadonlyMap<number, Pin> };
|
||||
padding: ViewportPadding;
|
||||
assistant: AssistantMapProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the assistant's marks of a place pin on to the element that replaces
|
||||
* it. The GL map builds every place pin anew on each pan, zoom and selection,
|
||||
* so without this a pulse would end with the first move and a pop inside its
|
||||
* moment would be lost. Leaflet keeps its pins and never needs it.
|
||||
*/
|
||||
export function carryAssistantMarks(from: HTMLElement, to: HTMLElement): void {
|
||||
for (const mark of [PLACE_POP_CLASS, PLACE_PULSE_CLASS]) {
|
||||
if (from.classList.contains(mark)) to.classList.add(mark);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the assistant does on the GL map in one call, so MapViewGL only
|
||||
* hands its props on: the ghosts, the camera, and the pop and pulse on the
|
||||
* real pins. The twin of AssistantMapLeaflet.
|
||||
*/
|
||||
export function useAssistantMapGL<Pin extends PinGL>({
|
||||
mapRef,
|
||||
mapReady,
|
||||
gl,
|
||||
pinsRef,
|
||||
attachPin,
|
||||
markersRef,
|
||||
padding,
|
||||
assistant,
|
||||
}: AssistantMapGLOptions<Pin>): void {
|
||||
useAssistantGhostMarkersGL({
|
||||
mapRef,
|
||||
mapReady,
|
||||
pinsRef,
|
||||
attachPin,
|
||||
ghosts: assistant.assistantGhosts,
|
||||
hoveredId: assistant.assistantHoveredId,
|
||||
onHover: assistant.onAssistantGhostHover,
|
||||
onClick: assistant.onAssistantGhostClick,
|
||||
});
|
||||
useAssistantCameraGL({ mapRef, mapReady, gl, camera: assistant.assistantCamera, padding });
|
||||
const markerEl = useCallback((id: number) => markersRef.current.get(id)?.el ?? null, [markersRef]);
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
}
|
||||
Reference in New Issue
Block a user