mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this"
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* FE-MAP-ROUTE-001 to FE-MAP-ROUTE-004
|
||||
*
|
||||
* The chat's routes on the Leaflet map: a rim under each line and nothing
|
||||
* but an invisible fat line taking the pointer; dashes for a way not chosen,
|
||||
* faint for the way a change replaces; the hard grades over the line, a disc
|
||||
* at the start and a ring at the end; the named stops only while lit; and
|
||||
* the pointer onto, along and off a line reported by its key; a line that
|
||||
* lights up brought to the front. What Leaflet makes of the options is
|
||||
* Leaflet's: the paths come back as data here.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode, Ref } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import { AssistantGhostLineLayer } from './AssistantGhostLineLayer';
|
||||
|
||||
type Handlers = Record<string, ((event?: unknown) => void) | undefined>;
|
||||
|
||||
const bringToFront = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('react-leaflet', async () => {
|
||||
const { useImperativeHandle } = await vi.importActual<typeof import('react')>('react');
|
||||
return {
|
||||
FeatureGroup: ({ children, ref }: { children?: ReactNode; ref?: Ref<{ bringToFront: () => void }> }) => {
|
||||
useImperativeHandle(ref, () => ({ bringToFront }));
|
||||
return <div data-testid="group">{children}</div>;
|
||||
},
|
||||
Polyline: ({
|
||||
positions,
|
||||
pathOptions,
|
||||
interactive,
|
||||
eventHandlers,
|
||||
}: {
|
||||
positions: unknown;
|
||||
pathOptions?: Record<string, unknown>;
|
||||
interactive?: boolean;
|
||||
eventHandlers?: Handlers;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="polyline"
|
||||
data-points={JSON.stringify(positions)}
|
||||
data-color={String(pathOptions?.color ?? '')}
|
||||
data-weight={String(pathOptions?.weight ?? '')}
|
||||
data-opacity={String(pathOptions?.opacity ?? '')}
|
||||
data-dash={String(pathOptions?.dashArray ?? '')}
|
||||
data-dash-named={String(pathOptions ? 'dashArray' in pathOptions : false)}
|
||||
data-interactive={String(interactive)}
|
||||
onMouseEnter={() => eventHandlers?.mouseover?.()}
|
||||
onMouseLeave={() => eventHandlers?.mouseout?.()}
|
||||
onMouseMove={() => eventHandlers?.mousemove?.({ latlng: { lat: 47.78, lng: 12.6 } })}
|
||||
onClick={() => eventHandlers?.click?.({ latlng: { lat: 47.79, lng: 12.61 } })}
|
||||
/>
|
||||
),
|
||||
CircleMarker: ({
|
||||
center,
|
||||
pathOptions,
|
||||
children,
|
||||
}: {
|
||||
center: [number, number];
|
||||
pathOptions?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<div data-testid="disc" data-center={JSON.stringify(center)} data-fill={String(pathOptions?.fillColor ?? '')}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => <span data-testid="stop-name">{children}</span>,
|
||||
};
|
||||
});
|
||||
|
||||
const LINE: AssistantRouteLine = {
|
||||
id: 'c1:look:route',
|
||||
ref: 'c1',
|
||||
lines: [
|
||||
[
|
||||
[47.77, 12.59],
|
||||
[47.78, 12.6],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
],
|
||||
color: '#0a84ff',
|
||||
casing: '#ffffff',
|
||||
ends: { start: [47.77, 12.59], end: [47.79, 12.62] },
|
||||
stops: [{ lat: 47.78, lng: 12.6, label: 'Strausshütte' }],
|
||||
tints: [
|
||||
{
|
||||
line: [
|
||||
[47.78, 12.6],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
color: '#7c3aed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const paths = () => screen.queryAllByTestId('polyline');
|
||||
/** What a path was asked for, as plain data. */
|
||||
const asked = (el: HTMLElement | undefined) => ({ ...el?.dataset });
|
||||
|
||||
describe('AssistantGhostLineLayer', () => {
|
||||
it('FE-MAP-ROUTE-001: a rim, the line, its hard grade and its ends; only the invisible line takes the pointer', () => {
|
||||
render(<AssistantGhostLineLayer lines={[LINE]} />);
|
||||
const [rim, core, grade, hit] = paths();
|
||||
expect(asked(rim)).toMatchObject({ color: '#ffffff', weight: '7', interactive: 'false' });
|
||||
expect(asked(core)).toMatchObject({ color: '#0a84ff', weight: '4', dash: '', interactive: 'false' });
|
||||
expect(asked(grade)).toMatchObject({ color: '#7c3aed', interactive: 'false' });
|
||||
expect(asked(hit)).toMatchObject({ weight: '16', opacity: '0', interactive: 'undefined' });
|
||||
const discs = screen.getAllByTestId('disc');
|
||||
expect(discs.map((disc) => disc.dataset.fill)).toEqual(['#0a84ff', '#ffffff']);
|
||||
// The stops show only while the line is lit.
|
||||
expect(screen.queryByTestId('stop-name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-002: dashed, faint, a loop with one disc, and the lit line with its named stops', () => {
|
||||
const loop: AssistantRouteLine = { ...LINE, dashed: true, dim: true, ends: { start: [47.77, 12.59], end: null } };
|
||||
const { rerender } = render(<AssistantGhostLineLayer lines={[loop]} />);
|
||||
expect(asked(paths()[1])).toMatchObject({ dash: '6 7', opacity: '0.45' });
|
||||
expect(screen.getAllByTestId('disc')).toHaveLength(1);
|
||||
|
||||
rerender(<AssistantGhostLineLayer lines={[{ ...LINE, hot: true }]} />);
|
||||
expect(screen.getByTestId('stop-name')).toHaveTextContent('Strausshütte');
|
||||
|
||||
rerender(<AssistantGhostLineLayer lines={[]} />);
|
||||
expect(paths()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-003: the pointer onto, along and off a line, and a tap on it, report its key and where', () => {
|
||||
const onHover = vi.fn();
|
||||
const onPoint = vi.fn();
|
||||
render(<AssistantGhostLineLayer lines={[LINE]} onHover={onHover} onPoint={onPoint} />);
|
||||
const hit = paths()[3] as HTMLElement;
|
||||
fireEvent.mouseEnter(hit);
|
||||
fireEvent.mouseMove(hit);
|
||||
fireEvent.click(hit);
|
||||
fireEvent.mouseLeave(hit);
|
||||
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
|
||||
expect(onPoint.mock.calls).toEqual([
|
||||
['c1', 47.78, 12.6],
|
||||
['c1', 47.79, 12.61],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-004: a dashed way that lights up loses its dashes and comes to the front; a calm one stays put', () => {
|
||||
bringToFront.mockClear();
|
||||
const way: AssistantRouteLine = { ...LINE, dashed: true };
|
||||
const other: AssistantRouteLine = { ...LINE, id: 'c2:look:route', ref: 'c2' };
|
||||
const { rerender } = render(<AssistantGhostLineLayer lines={[way, other]} />);
|
||||
expect(asked(paths()[1])).toMatchObject({ dash: '6 7' });
|
||||
expect(bringToFront).not.toHaveBeenCalled();
|
||||
|
||||
// The same line, now lit: its dash is asked away by name, so Leaflet's merged style drops it.
|
||||
rerender(<AssistantGhostLineLayer lines={[other, { ...way, hot: true }]} />);
|
||||
const lit = screen.getAllByTestId('group')[1];
|
||||
const core = lit?.querySelectorAll<HTMLElement>('[data-testid="polyline"]')[1];
|
||||
expect(asked(core)).toMatchObject({ dash: '', dashNamed: 'true' });
|
||||
expect(bringToFront).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
|
||||
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
|
||||
const CASING_WEIGHT = 7;
|
||||
const CORE_WEIGHT = 4;
|
||||
const HIT_WEIGHT = 16;
|
||||
/** A way not chosen, or one joined straight between its stops. */
|
||||
const DASH = '6 7';
|
||||
/** The way a change replaces, beside the new one. */
|
||||
const DIM_OPACITY = 0.45;
|
||||
const END_RADIUS = 5;
|
||||
const STOP_RADIUS = 4;
|
||||
const ROUND = { lineCap: 'round', lineJoin: 'round' } as const;
|
||||
|
||||
interface LineHandlers {
|
||||
onHover?: (ref: string | null) => void;
|
||||
onPoint?: (ref: string, lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One route the chat shows: its rim and its line (dashed while it is a way
|
||||
* not chosen or only its stops joined straight, faint while it is the way a
|
||||
* change replaces), its hard grades over it, a disc at its start and a ring
|
||||
* at its end, its named stops with their names while it is lit, and a wide
|
||||
* invisible line on top that reports the pointer: onto it, along it (for the route's profile)
|
||||
* and off it. Nothing but the invisible line takes the pointer, so the plan's
|
||||
* own lines and pins stay where a click lands. Leaflet keeps each path where
|
||||
* it was first drawn, whatever order the lines come in later, so a line that
|
||||
* lights up is brought to the front by hand.
|
||||
*/
|
||||
function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & LineHandlers) {
|
||||
const group = useRef<LeafletFeatureGroup | null>(null);
|
||||
useEffect(() => {
|
||||
if (line.hot) group.current?.bringToFront();
|
||||
}, [line.hot]);
|
||||
const opacity = line.dim ? DIM_OPACITY : 1;
|
||||
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
|
||||
return (
|
||||
<FeatureGroup ref={group}>
|
||||
<Polyline
|
||||
positions={line.lines}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.casing, weight: CASING_WEIGHT, opacity: opacity * 0.9, ...ROUND }}
|
||||
/>
|
||||
<Polyline
|
||||
positions={line.lines}
|
||||
interactive={false}
|
||||
pathOptions={{
|
||||
color: line.color,
|
||||
weight: CORE_WEIGHT,
|
||||
opacity,
|
||||
// Always named: Leaflet merges a new style into the old one, so a dash left out would stay drawn.
|
||||
dashArray: line.dashed ? DASH : undefined,
|
||||
...ROUND,
|
||||
}}
|
||||
/>
|
||||
{line.tints?.map((tint, index) => (
|
||||
<Polyline
|
||||
key={index}
|
||||
positions={tint.line}
|
||||
interactive={false}
|
||||
pathOptions={{ color: tint.color, weight: CORE_WEIGHT, opacity, ...ROUND }}
|
||||
/>
|
||||
))}
|
||||
{line.ends && (
|
||||
<CircleMarker
|
||||
center={line.ends.start}
|
||||
radius={END_RADIUS}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.casing, weight: 2, fillColor: line.color, fillOpacity: 1, opacity }}
|
||||
/>
|
||||
)}
|
||||
{line.ends?.end && (
|
||||
<CircleMarker
|
||||
center={line.ends.end}
|
||||
radius={END_RADIUS}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.color, weight: 3, fillColor: line.casing, fillOpacity: 1, opacity }}
|
||||
/>
|
||||
)}
|
||||
{line.hot &&
|
||||
line.stops?.map((stop, index) => (
|
||||
<CircleMarker
|
||||
key={index}
|
||||
center={[stop.lat, stop.lng]}
|
||||
radius={STOP_RADIUS}
|
||||
interactive={false}
|
||||
pathOptions={{ color: line.casing, weight: 2, fillColor: line.color, fillOpacity: 1 }}
|
||||
>
|
||||
<Tooltip permanent direction="top" className="map-tooltip">
|
||||
{stop.label}
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
<Polyline
|
||||
positions={line.lines}
|
||||
bubblingMouseEvents={false}
|
||||
pathOptions={{ color: line.casing, weight: HIT_WEIGHT, opacity: 0 }}
|
||||
eventHandlers={{
|
||||
mouseover: () => onHover?.(line.ref),
|
||||
mouseout: () => onHover?.(null),
|
||||
mousemove: point,
|
||||
click: point,
|
||||
}}
|
||||
/>
|
||||
</FeatureGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantGhostLineLayerProps extends LineHandlers {
|
||||
lines: readonly AssistantRouteLine[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes the chat shows on the Leaflet map, in the order they come
|
||||
* (the store's route model puts the one under the pointer last; here a line
|
||||
* brings itself to the front when it lights up). Over the plan's lines,
|
||||
* under its pins. The twin of
|
||||
* useAssistantGhostLinesGL.
|
||||
*/
|
||||
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
|
||||
if (!lines?.length) return null;
|
||||
return (
|
||||
<>
|
||||
{lines.map((line) => (
|
||||
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useAssistantMarkerClasses } from './assistantCamera';
|
||||
import { AssistantCameraController } from './AssistantCameraController';
|
||||
import { AssistantGhostLayer } from './AssistantGhostLayer';
|
||||
import { AssistantGhostLineLayer } from './AssistantGhostLineLayer';
|
||||
import { useReportViewLeaflet } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
|
||||
@@ -15,15 +16,20 @@ interface AssistantMapLeafletProps {
|
||||
|
||||
/**
|
||||
* 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, the pop and pulse on the real pins, and the part of the map
|
||||
* the assistant is told is on screen.
|
||||
* container as one child, so MapView only hands its props on: the routes
|
||||
* its cards show, the ghosts, the camera, the pop and pulse on the real
|
||||
* pins, and the part of the map the assistant is told is on screen.
|
||||
*/
|
||||
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
|
||||
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
|
||||
useReportViewLeaflet();
|
||||
return (
|
||||
<>
|
||||
<AssistantGhostLineLayer
|
||||
lines={assistant.assistantRouteLines}
|
||||
onHover={assistant.onAssistantRouteHover}
|
||||
onPoint={assistant.onAssistantRoutePoint}
|
||||
/>
|
||||
<AssistantGhostLayer
|
||||
ghosts={assistant.assistantGhosts}
|
||||
hoveredId={assistant.assistantHoveredId}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
// 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
|
||||
@@ -57,8 +58,6 @@ import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
// points the source reports as currently unclustered. Grouping is always on,
|
||||
// matching the Leaflet map's MarkerClusterGroup.
|
||||
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -64,6 +64,19 @@
|
||||
animation: trek-ghost-enter 180ms var(--ease-out-quint) both;
|
||||
}
|
||||
|
||||
/* A spot a card marks (a via of a drive): a small dashed dot in the middle of the ghost's box, no icon. */
|
||||
.trek-ghost--point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.trek-ghost--point .trek-ghost-face {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.trek-ghost.is-hot .trek-ghost-face {
|
||||
border-style: solid;
|
||||
animation:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MAP-GHOST-001 to 003: the assistant's suggested places as map pins, built once for both renderers.
|
||||
// FE-MAP-GHOST-001 to 004: 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';
|
||||
|
||||
@@ -54,4 +54,28 @@ describe('assistantGhostMarker', () => {
|
||||
setGhostHot(elements, null);
|
||||
expect(b.classList.contains(GHOST_HOT_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-GHOST-004: a spot a card marks is a dot without an icon, and every ghost of one change lights by its call', () => {
|
||||
const point = buildGhostElement({ id: 'c1:via:0', ref: 'c1', lat: 41.9, lng: 12.5, name: 'Via', variant: 'point' });
|
||||
expect(point.classList.contains('trek-ghost--point')).toBe(true);
|
||||
expect(point.querySelector('.trek-ghost-face svg')).toBeNull();
|
||||
|
||||
const onHover = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const ghosts = [
|
||||
{ id: 'c1:stop:0', ref: 'c1', lat: 41.9, lng: 12.5, name: 'Stop', variant: 'pin' as const },
|
||||
{ id: 'c1:via:0', ref: 'c1', lat: 41.91, lng: 12.5, name: 'Via', variant: 'point' as const },
|
||||
{ id: 'c2', lat: 41.92, lng: 12.5, name: 'Other' },
|
||||
];
|
||||
const mounted = mountGhosts(ghosts, () => ({ remove: vi.fn() }), { current: { onHover, onClick } });
|
||||
mounted.elements.get('c1:via:0')?.dispatchEvent(new Event('pointerenter'));
|
||||
mounted.elements.get('c1:stop:0')?.dispatchEvent(new MouseEvent('click'));
|
||||
expect(onHover).toHaveBeenCalledWith('c1');
|
||||
expect(onClick).toHaveBeenCalledWith('c1');
|
||||
|
||||
setGhostHot(mounted.elements, 'c1');
|
||||
const hot = [...mounted.elements.values()].map((el) => el.classList.contains(GHOST_HOT_CLASS));
|
||||
expect(hot).toEqual([true, true, false]);
|
||||
mounted.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,17 +34,22 @@ function ghostIcon(name: string | undefined): string {
|
||||
return markup;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ghost's element: a dashed pin with its category's icon, or for a spot a
|
||||
* card marks (`point`) a small dashed dot without one.
|
||||
*/
|
||||
export function buildGhostElement(ghost: AssistantGhost): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'trek-ghost';
|
||||
el.className = ghost.variant === 'point' ? 'trek-ghost trek-ghost--point' : 'trek-ghost';
|
||||
el.dataset.ghostId = ghost.id;
|
||||
el.dataset.ghostRef = ghost.ref ?? 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);
|
||||
if (ghost.variant !== 'point') face.innerHTML = ghostIcon(ghost.icon);
|
||||
el.appendChild(face);
|
||||
return el;
|
||||
}
|
||||
@@ -79,11 +84,12 @@ export function mountGhosts(
|
||||
const cleanups: (() => void)[] = [];
|
||||
for (const ghost of ghosts) {
|
||||
const el = buildGhostElement(ghost);
|
||||
const enter = () => handlers.current.onHover?.(ghost.id);
|
||||
const ref = ghost.ref ?? ghost.id;
|
||||
const enter = () => handlers.current.onHover?.(ref);
|
||||
const leave = () => handlers.current.onHover?.(null);
|
||||
const click = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
handlers.current.onClick?.(ghost.id);
|
||||
handlers.current.onClick?.(ref);
|
||||
};
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') click(event);
|
||||
@@ -111,7 +117,7 @@ export function mountGhosts(
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks the hovered ghost, and only it, without rebuilding any pin. */
|
||||
/** Marks the hovered ghosts (every one of a change lit by its call), and only them, 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));
|
||||
elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, (el.dataset.ghostRef ?? id) === hoveredId));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DawarichTrack, RoadtripHazard, RoadtripVia } from '@trek/shared';
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types';
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import type { RouteProfileFocus } from '../../utils/routeGeometry';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
@@ -209,6 +210,12 @@ export interface MapViewProps {
|
||||
assistantCamera?: AssistantCamera | null;
|
||||
/** Places that just arrived from an applied suggestion; their pins pop once. */
|
||||
poppedPlaceIds?: readonly number[];
|
||||
/** The routes the chat shows beside the plan: proposed ways, held changes, the way a change replaces. */
|
||||
assistantRouteLines?: AssistantRouteLine[];
|
||||
/** The pointer onto a route line (its `ref`) or off every one; the phone leaves it out, a tap points instead. */
|
||||
onAssistantRouteHover?: (ref: string | null) => void;
|
||||
/** The pointer moving along a route line, or a tap on it: where on the map, for the route's profile. */
|
||||
onAssistantRoutePoint?: (ref: string, lat: number, lng: number) => void;
|
||||
/** While the map shows the plan before a change, its pins cannot be dragged onto a day. */
|
||||
markersLocked?: boolean;
|
||||
}
|
||||
@@ -224,9 +231,17 @@ export type AssistantMapProps = Pick<
|
||||
| 'assistantCamera'
|
||||
| 'poppedPlaceIds'
|
||||
| 'markersLocked'
|
||||
| 'assistantRouteLines'
|
||||
| 'onAssistantRouteHover'
|
||||
| 'onAssistantRoutePoint'
|
||||
| 'routeProfileFocus'
|
||||
>;
|
||||
|
||||
/** A suggested place drawn before it exists. `color` and `icon` are its category's. */
|
||||
/**
|
||||
* A suggested place drawn before it exists. `color` and `icon` are its
|
||||
* category's. A spot a card marks (a road trip's new via) is a small
|
||||
* `point` instead of a pin, and lights with its change's row by `ref`.
|
||||
*/
|
||||
export interface AssistantGhost {
|
||||
id: string;
|
||||
lat: number;
|
||||
@@ -234,6 +249,9 @@ export interface AssistantGhost {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
variant?: 'pin' | 'point';
|
||||
/** The key it lights by, when several ghosts belong to one change; its `id` otherwise. */
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The GL map's cluster bubbles for the trip's places (MapViewGL draws them):
|
||||
* named here so the layers drawn over them, the assistant's route lines among
|
||||
* them, can leave a tap on a bubble to the bubble.
|
||||
*/
|
||||
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
||||
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* FE-MAP-ROUTE-GL-001 to FE-MAP-ROUTE-GL-005
|
||||
*
|
||||
* The chat's routes on the GL map, the twin of the Leaflet layer: one
|
||||
* GeoJSON source (lines with their rims and dashes, grades, ends, the lit
|
||||
* line's stops) added the first time there is a route and again after a
|
||||
* basemap rebuilds the style (never into a style still loading), and the
|
||||
* invisible line reporting the pointer, except where a place pin or a
|
||||
* cluster bubble over it takes the pointer.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import {
|
||||
type LineMap,
|
||||
ROUTE_LINE_LAYERS,
|
||||
routeLineFeatures,
|
||||
useAssistantGhostLinesGL,
|
||||
} from './useAssistantGhostLinesGL';
|
||||
|
||||
const LINE: AssistantRouteLine = {
|
||||
id: 'c1:look:route',
|
||||
ref: 'c1',
|
||||
lines: [
|
||||
[
|
||||
[47.77, 12.59],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
],
|
||||
color: '#0a84ff',
|
||||
casing: '#ffffff',
|
||||
ends: { start: [47.77, 12.59], end: [47.79, 12.62] },
|
||||
stops: [{ lat: 47.78, lng: 12.6, label: 'Strausshütte' }],
|
||||
tints: [
|
||||
{
|
||||
line: [
|
||||
[47.78, 12.6],
|
||||
[47.79, 12.62],
|
||||
],
|
||||
color: '#7c3aed',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type Listener = (event?: unknown) => void;
|
||||
|
||||
/** A stand-in GL map that keeps its sources, layers and listeners, and can rebuild its style. */
|
||||
function fakeMap() {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const sources = new Map<string, { data: unknown; setData: (data: unknown) => void }>();
|
||||
const layers: string[] = [];
|
||||
const listeners: { type: string; layer: string | null; fn: Listener }[] = [];
|
||||
const map = {
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string, source: { data: unknown }) => {
|
||||
const entry = {
|
||||
data: source.data,
|
||||
setData: (data: unknown) => {
|
||||
entry.data = data;
|
||||
},
|
||||
};
|
||||
sources.set(id, entry);
|
||||
}),
|
||||
addLayer: vi.fn((layer: { id: string }) => layers.push(layer.id)),
|
||||
getLayer: vi.fn((): unknown => undefined),
|
||||
isStyleLoaded: vi.fn(() => true),
|
||||
queryRenderedFeatures: vi.fn((): unknown[] => []),
|
||||
getContainer: () => container,
|
||||
on: vi.fn((type: string, layerOrFn: string | Listener, fn?: Listener) => {
|
||||
if (typeof layerOrFn === 'function') listeners.push({ type, layer: null, fn: layerOrFn });
|
||||
else if (fn) listeners.push({ type, layer: layerOrFn, fn });
|
||||
}),
|
||||
off: vi.fn((type: string, layerOrFn: string | Listener, fn?: Listener) => {
|
||||
const target = typeof layerOrFn === 'function' ? layerOrFn : fn;
|
||||
const index = listeners.findIndex((l) => l.type === type && l.fn === target);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}),
|
||||
};
|
||||
const fire = (type: string, layer: string | null, event?: unknown) =>
|
||||
listeners.filter((l) => l.type === type && l.layer === layer).forEach((l) => l.fn(event));
|
||||
const restyle = () => {
|
||||
sources.clear();
|
||||
layers.length = 0;
|
||||
fire('styledata', null);
|
||||
};
|
||||
const data = () => sources.get('assistant-route-lines')?.data as ReturnType<typeof routeLineFeatures> | undefined;
|
||||
return { map, container, layers, listeners, fire, restyle, data };
|
||||
}
|
||||
|
||||
function mount(fake: ReturnType<typeof fakeMap>, lines: AssistantRouteLine[], handlers = {}) {
|
||||
const mapRef = { current: fake.map as unknown as LineMap };
|
||||
return renderHook(
|
||||
(props: { lines: AssistantRouteLine[] }) =>
|
||||
useAssistantGhostLinesGL({ mapRef, mapReady: true, lines: props.lines, ...handlers }),
|
||||
{ initialProps: { lines } }
|
||||
);
|
||||
}
|
||||
|
||||
describe('useAssistantGhostLinesGL', () => {
|
||||
it('FE-MAP-ROUTE-GL-001: a route is its line, its grade and its ends, in GeoJSON order; its stops only while lit', () => {
|
||||
const calm = routeLineFeatures([{ ...LINE, dim: true, dashed: true }]);
|
||||
expect(calm.features.map((f) => f.properties.kind)).toEqual(['line', 'tint', 'end', 'end']);
|
||||
expect(calm.features[0]?.properties).toMatchObject({ ref: 'c1', dashed: true, hot: false, opacity: 0.45 });
|
||||
expect(calm.features[0]?.geometry).toEqual({
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[12.59, 47.77],
|
||||
[12.62, 47.79],
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(calm.features[3]?.properties).toMatchObject({ fill: '#ffffff', stroke: '#0a84ff', strokeWidth: 3 });
|
||||
|
||||
const lit = routeLineFeatures([{ ...LINE, hot: true }]);
|
||||
expect(lit.features[lit.features.length - 1]).toMatchObject({
|
||||
properties: { kind: 'stop', label: 'Strausshütte' },
|
||||
geometry: { type: 'Point', coordinates: [12.6, 47.78] },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-GL-002: nothing is added before a route; then one source and its layers, new data on change, back after a restyle', () => {
|
||||
const fake = fakeMap();
|
||||
const hook = mount(fake, []);
|
||||
expect(fake.map.addSource).not.toHaveBeenCalled();
|
||||
|
||||
hook.rerender({ lines: [LINE] });
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
|
||||
expect(fake.layers).toEqual([
|
||||
ROUTE_LINE_LAYERS.casing,
|
||||
ROUTE_LINE_LAYERS.line,
|
||||
ROUTE_LINE_LAYERS.dashed,
|
||||
ROUTE_LINE_LAYERS.tint,
|
||||
ROUTE_LINE_LAYERS.ends,
|
||||
ROUTE_LINE_LAYERS.stops,
|
||||
ROUTE_LINE_LAYERS.hit,
|
||||
]);
|
||||
expect(fake.data()?.features).toHaveLength(4);
|
||||
|
||||
hook.rerender({ lines: [{ ...LINE, hot: true }] });
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
|
||||
expect(fake.data()?.features).toHaveLength(5);
|
||||
|
||||
fake.restyle();
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(2);
|
||||
expect(fake.data()?.features).toHaveLength(5);
|
||||
|
||||
hook.rerender({ lines: [] });
|
||||
expect(fake.data()?.features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("FE-MAP-ROUTE-GL-003: the pointer onto, along and off a route, a tap on it, and a stop's name; unmounting lets go", () => {
|
||||
const fake = fakeMap();
|
||||
const onHover = vi.fn();
|
||||
const onPoint = vi.fn();
|
||||
const hook = mount(fake, [LINE], { onHover, onPoint });
|
||||
const feature = { features: [{ properties: { ref: 'c1' } }] };
|
||||
fake.fire('mousemove', ROUTE_LINE_LAYERS.hit, { ...feature, lngLat: { lng: 12.6, lat: 47.78 } });
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, { ...feature, lngLat: { lng: 12.61, lat: 47.79 } });
|
||||
fake.fire('mouseleave', ROUTE_LINE_LAYERS.hit);
|
||||
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
|
||||
expect(onPoint.mock.calls).toEqual([
|
||||
['c1', 47.78, 12.6],
|
||||
['c1', 47.79, 12.61],
|
||||
]);
|
||||
|
||||
const stop = { features: [{ properties: { label: 'Strausshütte' } }], lngLat: { lng: 12.6, lat: 47.78 } };
|
||||
fake.fire('mousemove', ROUTE_LINE_LAYERS.stops, { ...stop, point: { x: 40, y: 50 } });
|
||||
expect(fake.container.querySelector('[data-testid="assistant-route-label"]')).toHaveTextContent('Strausshütte');
|
||||
|
||||
hook.unmount();
|
||||
expect(fake.listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-GL-004: a tap or a pointer on a place pin or a cluster bubble over a route is left to the pin', () => {
|
||||
const fake = fakeMap();
|
||||
const onHover = vi.fn();
|
||||
const onPoint = vi.fn();
|
||||
mount(fake, [LINE], { onHover, onPoint });
|
||||
const point = { x: 40, y: 50 };
|
||||
const at = { features: [{ properties: { ref: 'c1' } }], lngLat: { lng: 12.6, lat: 47.78 }, point };
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'maplibregl-marker';
|
||||
const photo = marker.appendChild(document.createElement('img'));
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, { ...at, originalEvent: { target: photo } });
|
||||
fake.fire('mousemove', ROUTE_LINE_LAYERS.hit, { ...at, originalEvent: { target: photo } });
|
||||
expect(onPoint).not.toHaveBeenCalled();
|
||||
expect(onHover.mock.calls).toEqual([[null]]);
|
||||
|
||||
fake.map.getLayer.mockReturnValue({});
|
||||
fake.map.queryRenderedFeatures.mockReturnValue([{ id: 7 }]);
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, at);
|
||||
expect(onPoint).not.toHaveBeenCalled();
|
||||
expect(fake.map.queryRenderedFeatures).toHaveBeenCalledWith(at.point, {
|
||||
layers: ['trip-place-clusters-circle', 'trip-place-clusters-count'],
|
||||
});
|
||||
|
||||
// Off the bubbles the line takes the tap again.
|
||||
fake.map.queryRenderedFeatures.mockReturnValue([]);
|
||||
fake.fire('click', ROUTE_LINE_LAYERS.hit, at);
|
||||
expect(onPoint).toHaveBeenCalledWith('c1', 47.78, 12.6);
|
||||
});
|
||||
|
||||
it('FE-MAP-ROUTE-GL-005: a route that comes while a new basemap is loading is put on once the style is ready', () => {
|
||||
const fake = fakeMap();
|
||||
fake.map.isStyleLoaded.mockReturnValue(false);
|
||||
mount(fake, [LINE]);
|
||||
expect(fake.map.addSource).not.toHaveBeenCalled();
|
||||
|
||||
fake.map.isStyleLoaded.mockReturnValue(true);
|
||||
fake.fire('styledata', null);
|
||||
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
|
||||
expect(fake.data()?.features).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import { mapHoverLabel } from './mapHoverLabel';
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
|
||||
|
||||
const SOURCE_ID = 'assistant-route-lines';
|
||||
export const ROUTE_LINE_LAYERS = {
|
||||
casing: `${SOURCE_ID}-casing`,
|
||||
line: `${SOURCE_ID}-line`,
|
||||
dashed: `${SOURCE_ID}-line-dashed`,
|
||||
tint: `${SOURCE_ID}-tint`,
|
||||
ends: `${SOURCE_ID}-ends`,
|
||||
stops: `${SOURCE_ID}-stops`,
|
||||
hit: `${SOURCE_ID}-hit`,
|
||||
} as const;
|
||||
|
||||
/** The way a change replaces, beside the new one. */
|
||||
const DIM_OPACITY = 0.45;
|
||||
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
||||
|
||||
type Position = [number, number];
|
||||
interface Feature {
|
||||
type: 'Feature';
|
||||
properties: Record<string, string | number | boolean>;
|
||||
geometry:
|
||||
| { type: 'MultiLineString'; coordinates: Position[][] }
|
||||
| { type: 'LineString'; coordinates: Position[] }
|
||||
| { type: 'Point'; coordinates: Position };
|
||||
}
|
||||
interface FeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
/** What a pointer event on a layer hands over: where, and the feature under it. */
|
||||
interface LayerEvent {
|
||||
lngLat: { lng: number; lat: number };
|
||||
point?: { x: number; y: number };
|
||||
features?: { properties?: Record<string, unknown> | null }[];
|
||||
/** The DOM event behind it: a place pin is a DOM marker over the canvas, and its taps reach the layers too. */
|
||||
originalEvent?: { target?: EventTarget | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of the GL map the route lines need. Every call is optional, so a
|
||||
* stand-in map without them draws nothing instead of throwing; Mapbox and
|
||||
* MapLibre both fit.
|
||||
*/
|
||||
export interface LineMap {
|
||||
getSource?(id: string): { setData?: (data: FeatureCollection) => void } | undefined;
|
||||
addSource?(id: string, source: { type: 'geojson'; data: FeatureCollection }): unknown;
|
||||
addLayer?(layer: Record<string, unknown>): unknown;
|
||||
getLayer?(id: string): unknown;
|
||||
isStyleLoaded?(): boolean;
|
||||
queryRenderedFeatures?(point: { x: number; y: number }, options: { layers: string[] }): unknown[];
|
||||
getContainer?(): HTMLElement;
|
||||
getCanvas?(): HTMLElement;
|
||||
on?(type: 'styledata', listener: () => void): unknown;
|
||||
on?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
|
||||
off?(type: 'styledata', listener: () => void): unknown;
|
||||
off?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
|
||||
}
|
||||
|
||||
/** [lat, lng] as the store keeps points, [lng, lat] as GeoJSON wants them. */
|
||||
const flip = ([lat, lng]: [number, number]): Position => [lng, lat];
|
||||
|
||||
const pointFeature = (properties: Feature['properties'], coordinates: Position): Feature => ({
|
||||
type: 'Feature',
|
||||
properties,
|
||||
geometry: { type: 'Point', coordinates },
|
||||
});
|
||||
|
||||
/**
|
||||
* The routes as one GeoJSON collection: each route its line (with its rim
|
||||
* colour, whether it is dashed, how faint, whether it is lit), its hard
|
||||
* grades, its two ends, and its named stops while it is lit. Pure, for the
|
||||
* tests.
|
||||
*/
|
||||
export function routeLineFeatures(lines: readonly AssistantRouteLine[]): FeatureCollection {
|
||||
const features: Feature[] = [];
|
||||
for (const line of lines) {
|
||||
const opacity = line.dim ? DIM_OPACITY : 1;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
kind: 'line',
|
||||
ref: line.ref,
|
||||
color: line.color,
|
||||
casing: line.casing,
|
||||
dashed: !!line.dashed,
|
||||
hot: !!line.hot,
|
||||
opacity,
|
||||
},
|
||||
geometry: { type: 'MultiLineString', coordinates: line.lines.map((part) => part.map(flip)) },
|
||||
});
|
||||
for (const tint of line.tints ?? []) {
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: { kind: 'tint', color: tint.color, opacity },
|
||||
geometry: { type: 'LineString', coordinates: tint.line.map(flip) },
|
||||
});
|
||||
}
|
||||
if (line.ends) {
|
||||
const start = { kind: 'end', fill: line.color, stroke: line.casing, strokeWidth: 2, opacity };
|
||||
features.push(pointFeature(start, flip(line.ends.start)));
|
||||
if (line.ends.end) {
|
||||
const end = { kind: 'end', fill: line.casing, stroke: line.color, strokeWidth: 3, opacity };
|
||||
features.push(pointFeature(end, flip(line.ends.end)));
|
||||
}
|
||||
}
|
||||
if (!line.hot) continue;
|
||||
for (const stop of line.stops ?? []) {
|
||||
const properties = { kind: 'stop', fill: line.color, stroke: line.casing, label: stop.label };
|
||||
features.push(pointFeature(properties, [stop.lng, stop.lat]));
|
||||
}
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
const kindIs = (kind: string) => ['==', ['get', 'kind'], kind];
|
||||
|
||||
/** The layers, bottom first: rims, lines, dashed lines, grades, ends, stops, and the invisible line on top. */
|
||||
function addRouteLineLayers(map: LineMap): void {
|
||||
map.addSource?.(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
const source = SOURCE_ID;
|
||||
const line = (id: string, filter: unknown[], paint: Record<string, unknown>) =>
|
||||
map.addLayer?.({ id, type: 'line', source, filter, paint, layout: ROUND });
|
||||
const solid = ['all', kindIs('line'), ['==', ['get', 'dashed'], false]];
|
||||
const dashed = ['all', kindIs('line'), ['==', ['get', 'dashed'], true]];
|
||||
const opacity = ['get', 'opacity'];
|
||||
line(ROUTE_LINE_LAYERS.casing, kindIs('line'), {
|
||||
'line-color': ['get', 'casing'],
|
||||
'line-width': 7,
|
||||
'line-opacity': ['*', opacity, 0.9],
|
||||
});
|
||||
line(ROUTE_LINE_LAYERS.line, solid, { 'line-color': ['get', 'color'], 'line-width': 4, 'line-opacity': opacity });
|
||||
// Dashes are counted in line widths: 6 px on, 7 px off at a width of 4, as the Leaflet map draws them.
|
||||
line(ROUTE_LINE_LAYERS.dashed, dashed, {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 4,
|
||||
'line-opacity': opacity,
|
||||
'line-dasharray': [1.5, 1.75],
|
||||
});
|
||||
line(ROUTE_LINE_LAYERS.tint, kindIs('tint'), {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 4,
|
||||
'line-opacity': opacity,
|
||||
});
|
||||
const disc = (id: string, filter: unknown[], radius: number) =>
|
||||
map.addLayer?.({
|
||||
id,
|
||||
type: 'circle',
|
||||
source,
|
||||
filter,
|
||||
paint: {
|
||||
'circle-radius': radius,
|
||||
'circle-color': ['get', 'fill'],
|
||||
'circle-stroke-color': ['get', 'stroke'],
|
||||
'circle-stroke-width': ['coalesce', ['get', 'strokeWidth'], 2],
|
||||
'circle-opacity': ['coalesce', opacity, 1],
|
||||
'circle-stroke-opacity': ['coalesce', opacity, 1],
|
||||
},
|
||||
});
|
||||
disc(ROUTE_LINE_LAYERS.ends, kindIs('end'), 5);
|
||||
disc(ROUTE_LINE_LAYERS.stops, kindIs('stop'), 4);
|
||||
line(ROUTE_LINE_LAYERS.hit, kindIs('line'), { 'line-color': ['get', 'casing'], 'line-width': 16, 'line-opacity': 0 });
|
||||
}
|
||||
|
||||
interface AssistantGhostLinesOptions {
|
||||
mapRef: { readonly current: LineMap | null };
|
||||
mapReady: boolean;
|
||||
lines: readonly AssistantRouteLine[] | undefined;
|
||||
onHover?: (ref: string | null) => void;
|
||||
onPoint?: (ref: string, lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
/** The ref of the route line under a pointer event, if any. */
|
||||
function refOf(event: LayerEvent): string | null {
|
||||
const ref = event.features?.[0]?.properties?.ref;
|
||||
return typeof ref === 'string' ? ref : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pointer is on something of the plan's that lies over the route
|
||||
* on screen: a place pin (a DOM marker) or a cluster bubble (drawn under the
|
||||
* route's layers, but the bubble is what a tap there means). The plan's own
|
||||
* tracks leave such a tap alone the same way.
|
||||
*/
|
||||
function onPlanPin(map: LineMap, event: LayerEvent): boolean {
|
||||
const target = event.originalEvent?.target;
|
||||
if (target instanceof Element && target.closest('.mapboxgl-marker, .maplibregl-marker')) return true;
|
||||
if (!event.point || !map.queryRenderedFeatures || !map.getLayer?.(PLACE_CLUSTER_CIRCLE_LAYER_ID)) return false;
|
||||
const layers = [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID];
|
||||
return map.queryRenderedFeatures(event.point, { layers }).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes the chat shows on the GL map, the twin of the Leaflet
|
||||
* AssistantGhostLineLayer: one GeoJSON source and its layers, added the
|
||||
* first time there is a route to draw and again whenever the basemap
|
||||
* rebuilds the style, the data set anew when the routes change. The
|
||||
* invisible line reports the pointer onto, along and off a route; a stop
|
||||
* under the pointer shows its name in the map's hover label.
|
||||
*/
|
||||
export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onPoint }: AssistantGhostLinesOptions) {
|
||||
const handlers = useRef({ onHover, onPoint });
|
||||
useLayoutEffect(() => {
|
||||
handlers.current = { onHover, onPoint };
|
||||
}, [onHover, onPoint]);
|
||||
const data = useRef<FeatureCollection>(routeLineFeatures([]));
|
||||
const added = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady || typeof map.getSource !== 'function') return;
|
||||
data.current = routeLineFeatures(lines ?? []);
|
||||
// Nothing is added to a map that never had a route to draw.
|
||||
if (!added.current && data.current.features.length === 0) return;
|
||||
added.current = true;
|
||||
if (!map.getSource(SOURCE_ID)) {
|
||||
// A basemap still loading takes no layers; its styledata puts the routes on once it is ready.
|
||||
if (map.isStyleLoaded?.() === false) return;
|
||||
addRouteLineLayers(map);
|
||||
}
|
||||
map.getSource(SOURCE_ID)?.setData?.(data.current);
|
||||
}, [lines, mapReady, mapRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady || typeof map.on !== 'function' || typeof map.off !== 'function') return;
|
||||
const label = mapHoverLabel(map, 'assistant-route-label');
|
||||
// A new basemap takes every layer with it: put the routes back, as the plan's own tracks are.
|
||||
const restore = () => {
|
||||
if (!added.current || !map.getSource || map.getSource(SOURCE_ID)) return;
|
||||
addRouteLineLayers(map);
|
||||
map.getSource(SOURCE_ID)?.setData?.(data.current);
|
||||
};
|
||||
const move = (event: LayerEvent) => {
|
||||
const ref = refOf(event);
|
||||
if (ref === null) return;
|
||||
// On a pin over the line the pin is what the pointer is on: the line goes out as if left.
|
||||
if (onPlanPin(map, event)) {
|
||||
handlers.current.onHover?.(null);
|
||||
return;
|
||||
}
|
||||
handlers.current.onHover?.(ref);
|
||||
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
|
||||
};
|
||||
const leave = () => handlers.current.onHover?.(null);
|
||||
const click = (event: LayerEvent) => {
|
||||
const ref = refOf(event);
|
||||
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
|
||||
};
|
||||
const stop = (event: LayerEvent) => {
|
||||
const name = event.features?.[0]?.properties?.label;
|
||||
if (typeof name === 'string' && event.point) label.show({ name, detail: null }, event.point);
|
||||
};
|
||||
const hideLabel = () => label.hide();
|
||||
map.on('styledata', restore);
|
||||
map.on('mousemove', ROUTE_LINE_LAYERS.hit, move);
|
||||
map.on('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
|
||||
map.on('click', ROUTE_LINE_LAYERS.hit, click);
|
||||
map.on('mousemove', ROUTE_LINE_LAYERS.stops, stop);
|
||||
map.on('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
|
||||
return () => {
|
||||
map.off?.('styledata', restore);
|
||||
map.off?.('mousemove', ROUTE_LINE_LAYERS.hit, move);
|
||||
map.off?.('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
|
||||
map.off?.('click', ROUTE_LINE_LAYERS.hit, click);
|
||||
map.off?.('mousemove', ROUTE_LINE_LAYERS.stops, stop);
|
||||
map.off?.('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
|
||||
label.remove();
|
||||
};
|
||||
}, [mapReady, mapRef]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
|
||||
import { useReportViewGL } from './assistantViewport';
|
||||
import type { AssistantMapProps } from './mapViewContract';
|
||||
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
|
||||
import { useAssistantGhostLinesGL, type LineMap } from './useAssistantGhostLinesGL';
|
||||
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
|
||||
|
||||
interface PinGL {
|
||||
@@ -14,7 +15,7 @@ interface PinGL {
|
||||
|
||||
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 };
|
||||
mapRef: { readonly current: (CameraMap & GhostMap & LineMap) | null };
|
||||
mapReady: boolean;
|
||||
gl: CameraEngine;
|
||||
/** The ghosts' pins, in the ref the renderer repositions on its render clock. */
|
||||
@@ -40,8 +41,8 @@ export function carryAssistantMarks(from: HTMLElement, to: HTMLElement): void {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* hands its props on: the routes its cards show, the ghosts, the camera, and
|
||||
* the pop and pulse on the real pins. The twin of AssistantMapLeaflet.
|
||||
*/
|
||||
export function useAssistantMapGL<Pin extends PinGL>({
|
||||
mapRef,
|
||||
@@ -53,6 +54,13 @@ export function useAssistantMapGL<Pin extends PinGL>({
|
||||
padding,
|
||||
assistant,
|
||||
}: AssistantMapGLOptions<Pin>): void {
|
||||
useAssistantGhostLinesGL({
|
||||
mapRef,
|
||||
mapReady,
|
||||
lines: assistant.assistantRouteLines,
|
||||
onHover: assistant.onAssistantRouteHover,
|
||||
onPoint: assistant.onAssistantRoutePoint,
|
||||
});
|
||||
useAssistantGhostMarkersGL({
|
||||
mapRef,
|
||||
mapReady,
|
||||
|
||||
Reference in New Issue
Block a user