feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this"

This commit is contained in:
Maurice
2026-10-22 15:50:47 +02:00
parent 8547006742
commit 59313dfaff
135 changed files with 12185 additions and 234 deletions
@@ -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}
+1 -2
View File
@@ -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));
}
+19 -1
View File
@@ -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]);
}
+11 -3
View File
@@ -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,