feat(tours): complete planner follow-up scope

This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
This commit is contained in:
elmocito
2026-10-09 13:52:11 +02:00
committed by Maurice
parent af9f013c5e
commit 57ea219a43
56 changed files with 4315 additions and 462 deletions
@@ -94,6 +94,17 @@ function clickOn(el: Element) {
})
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: x },
clientY: { value: y },
pointerId: { value: 1 },
})
return event
}
// Two stops a few hundred metres apart in Nara: separate pins at this zoom, not a cluster.
const places = [
{ ...buildPlace({ id: 1, name: 'Todai-ji', lat: 34.689, lng: 135.8398 }), category_name: null, category_color: null, category_icon: null },
@@ -114,6 +125,143 @@ async function renderMap() {
}
describe('MapView on a real Leaflet map', () => {
it('RC-08: previews waypoint drags locally and commits only at pointer up without panning', async () => {
const waypoint = { id: 'via-1', lat: 34.689, lng: 135.8398, role: 'via' as const }
const waypoints = [waypoint]
const onMove = vi.fn(() => true)
const onSelect = vi.fn()
const onMapClick = vi.fn()
const view = render(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={null}
/>
)
const map = maps[maps.length - 1]
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
let marker: L.Marker | null = null
map.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
})
expect(marker).not.toBeNull()
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
const end = start.add([30, 20])
const expected = map.containerPointToLatLng(end)
const center = map.getCenter()
act(() => {
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
pin.dispatchEvent(mouse('mousedown', start.x, start.y))
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
})
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
expect(onMove).not.toHaveBeenCalled()
expect(waypoints[0]).toEqual(waypoint)
view.rerender(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
act(() => {
pin.dispatchEvent(pointer('pointerup', end.x, end.y))
pin.dispatchEvent(mouse('mouseup', end.x, end.y))
pin.dispatchEvent(mouse('click', end.x, end.y))
})
expect(onMove).toHaveBeenCalledOnce()
expect(onMove).toHaveBeenCalledWith(waypoint.id, expected.lat, expected.lng)
expect(onSelect).not.toHaveBeenCalled()
expect(onMapClick).not.toHaveBeenCalled()
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
view.rerender(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={[{ ...waypoint, lat: expected.lat, lng: expected.lng }]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[expected.lat, expected.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
expect(map.getCenter().lat).toBeCloseTo(center.lat, 6)
expect(map.getCenter().lng).toBeCloseTo(center.lng, 6)
})
it('RC-08: cancellation restores the marker and a plain click still selects without affecting map-add', async () => {
const waypoint = { id: 'start', lat: 34.689, lng: 135.8398, role: 'start' as const }
const onMove = vi.fn(() => true)
const onSelect = vi.fn()
const onMapClick = vi.fn()
const view = render(
<MapView
places={[]}
center={[waypoint.lat, waypoint.lng]}
zoom={15}
tileUrl={RASTER}
plannerWaypoints={[waypoint]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
/>
)
const map = maps[maps.length - 1]
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
let marker: L.Marker | null = null
map.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
})
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
const end = start.add([30, 20])
act(() => {
pin.dispatchEvent(pointer('pointerdown', start.x, start.y))
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
pin.dispatchEvent(pointer('pointercancel', end.x, end.y))
})
expect(marker!.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
expect(marker!.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
expect(onMove).not.toHaveBeenCalled()
clickOn(pin)
expect(onSelect).toHaveBeenCalledOnce()
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
expect(onMapClick).not.toHaveBeenCalled()
act(() => map.fire('click', { latlng: L.latLng(34.7, 135.85) }))
expect(onMapClick).toHaveBeenCalledOnce()
})
it('uses WGS-84 for Tours-local Topo over global Amap and preserves a Beijing click through layer switches', async () => {
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' } })
const beijing = { ...buildPlace({ id: 201, name: 'Beijing', lat: 39.9042, lng: 116.4074 }), category_name: null, category_color: null, category_icon: null }
@@ -442,6 +442,48 @@ describe('MapView', () => {
expect(mapMock.fitBounds).toHaveBeenCalled()
})
it('honors a location-search camera intent and preserves it through later route updates', () => {
const searchPoint: [number, number] = [35.0116, 135.7681];
const { rerender } = render(
<MapView
places={[]}
route={null}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
expect(mapMock.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 16 }));
const mapContainer = screen.getByTestId('map-container');
mapMock.fitBounds.mockClear();
rerender(
<MapView
places={[]}
route={[[searchPoint, [35.02, 135.77]]]}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint, [35.02, 135.77]]}
/>
);
expect(mapMock.fitBounds).not.toHaveBeenCalled();
expect(screen.getByTestId('map-container')).toBe(mapContainer);
rerender(
<MapView
places={[]}
followSelection={false}
focusKey="tour:1"
focusPoints={[
[46.8, 9.5],
[47.1, 10.1],
]}
/>
);
expect(mapMock.fitBounds).toHaveBeenCalledOnce();
});
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
const { rerender } = render(
+68 -17
View File
@@ -6,6 +6,7 @@ import MarkerClusterGroup from 'react-leaflet-cluster'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
import RoadtripViaMarkers from './RoadtripViaMarkers'
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
import HazardLayers from './HazardLayers'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -449,7 +450,7 @@ interface BoundsControllerProps {
*/
focusPoints?: [number, number][]
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number
focusKey?: number | string
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
@@ -757,16 +758,71 @@ const MemoMarker = memo(function MemoMarker({
)
})
function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon {
function plannerWaypointIcon(index: number, selected: boolean, draggable: boolean): L.DivIcon {
const size = selected ? 30 : 26
return L.divIcon({
className: '',
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer">${index + 1}</span>`,
html: `<span style="display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
})
}
function PlannerWaypointMarker({
point,
index,
selected,
onClick,
onMove,
}: {
point: { id: string; lat: number; lng: number };
index: number;
selected: boolean;
onClick?: (id: string) => void;
onMove?: (id: string, lat: number, lng: number) => boolean;
}) {
const map = useMap()
const [marker, setMarker] = useState<L.Marker | null>(null)
const handlersRef = useRef({ onClick, onMove })
handlersRef.current = { onClick, onMove }
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng])
const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected])
const eventHandlers = useMemo(() => ({
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation()
handlersRef.current.onClick?.(point.id)
},
}), [point.id])
useEffect(() => {
if (!marker || !onMove) return
const element = marker.getElement()
return wirePlannerWaypointDrag({
element,
initial: { lat: point.lat, lng: point.lng },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect()
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top])
return { lat: at.lat, lng: at.lng }
},
setPosition: ({ lat, lng }) => { marker.setLatLng([lat, lng]) },
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
})
}, [map, marker, onMove, point.id, point.lat, point.lng])
return (
<Marker
ref={setMarker}
position={position}
icon={icon}
zIndexOffset={selected ? 1200 : 1100}
eventHandlers={eventHandlers}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
)
}
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
@@ -836,6 +892,7 @@ export const MapView = memo(function MapView({
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
@@ -1307,20 +1364,14 @@ export const MapView = memo(function MapView({
) : null}
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
<Marker
<PlannerWaypointMarker
key={`planner-waypoint-${point.id}`}
position={[point.lat, point.lng]}
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
eventHandlers={{
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation()
onPlannerWaypointClick?.(point.id)
},
}}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
point={point}
index={index}
selected={point.id === selectedPlannerWaypointId}
onClick={onPlannerWaypointClick}
onMove={onPlannerWaypointMove}
/>
))}
{/* GPX imported route geometries */}
@@ -47,6 +47,7 @@ const glMap = vi.hoisted(() => ({
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => glCanvasContainer),
getContainer: vi.fn(() => glCanvasContainer),
getLayer: vi.fn().mockReturnValue(null),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
querySourceFeatures: vi.fn().mockReturnValue([]),
@@ -246,6 +247,17 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
} as any
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: x },
clientY: { value: y },
pointerId: { value: 1 },
})
return event
}
beforeEach(() => {
glMap.on.mockImplementation(() => glMap)
glMap.off.mockImplementation(() => glMap)
@@ -931,6 +943,55 @@ describe('MapViewGL', () => {
expect(glMap.easeTo).not.toHaveBeenCalled()
})
it('honors a location-search camera intent and preserves it through later route updates', async () => {
loadOnAttach();
const searchPoint: [number, number] = [35.0116, 135.7681];
const { rerender } = render(
<MapViewGL
places={[]}
route={null}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ maxZoom: 15 }));
const mapConstructions = (maplibregl.Map as any).mock.calls.length;
glMap.fitBounds.mockClear();
rerender(
<MapViewGL
places={[]}
route={[[searchPoint, [35.02, 135.77]]]}
followSelection={false}
focusKey="tour:0:search:1"
focusPoints={[searchPoint, [35.02, 135.77]]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).not.toHaveBeenCalled();
expect((maplibregl.Map as any).mock.calls).toHaveLength(mapConstructions);
rerender(
<MapViewGL
places={[]}
followSelection={false}
focusKey="tour:1"
focusPoints={[
[46.8, 9.5],
[47.1, 10.1],
]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
});
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => {
loadOnAttach()
const { rerender } = render(
@@ -982,6 +1043,126 @@ describe('MapViewGL', () => {
expect(glMap.fitBounds).toHaveBeenCalledOnce()
})
it.each(['maplibre-gl', 'mapbox-gl'] as const)('RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera', async (provider) => {
loadOnAttach()
glMap.project.mockImplementation((lngLat: [number, number]) => ({ x: lngLat[0] * 10, y: lngLat[1] * 10 }))
glMap.unproject.mockReturnValue({ lng: 2.3522, lat: 48.8566 })
glMap.getContainer.mockReturnValue(glCanvasContainer)
const waypoint = { id: 'via-1', lat: 48, lng: 11, role: 'via' as const }
const waypoints = [waypoint]
const onMove = vi.fn(() => true)
const onSelect = vi.fn()
const onMapClick = vi.fn()
const view = render(
<MapViewGL
places={[]}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={null}
glProvider={provider}
/>
)
await act(async () => {})
glMap.fitBounds.mockClear()
const pin = provider === 'maplibre-gl'
? glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement
: glMarkers.created.find(marker => marker.element.getAttribute('aria-label') === 'Waypoint 1')?.element as HTMLButtonElement
const startTransform = pin.style.transform
const libraryMarker = provider === 'mapbox-gl' ? glMarkers.created.find(marker => marker.element === pin) : undefined
const startLngLat = libraryMarker?.lngLat
act(() => {
pin.dispatchEvent(pointer('pointerdown', 10, 10))
pin.dispatchEvent(pointer('pointermove', 30, 40))
})
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform)
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat)
expect(onMove).not.toHaveBeenCalled()
expect(waypoints[0]).toEqual(waypoint)
view.rerender(
<MapViewGL
places={[]}
plannerWaypoints={waypoints}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[waypoint.lat, waypoint.lng]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
glProvider={provider}
/>
)
if (provider === 'maplibre-gl') expect(pin.style.transform).not.toBe(startTransform)
else expect(libraryMarker?.lngLat).not.toEqual(startLngLat)
act(() => {
pin.dispatchEvent(pointer('pointerup', 30, 40))
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(onMove).toHaveBeenCalledOnce()
expect(onMove).toHaveBeenCalledWith(waypoint.id, 48.8566, 2.3522)
expect(onSelect).not.toHaveBeenCalled()
expect(onMapClick).not.toHaveBeenCalled()
view.rerender(
<MapViewGL
places={[]}
plannerWaypoints={[{ ...waypoint, lat: 48.4, lng: 11.3 }]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
focusKey="tour:1"
focusPoints={[[48.4, 11.3]]}
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 48.1, lng: 11.1, sampleIndex: 0 }}
glProvider={provider}
/>
)
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
})
it('RC-08: pointer cancellation restores the MapLibre pin and plain clicks still select and add separately', async () => {
loadOnAttach()
const waypoint = { id: 'start', lat: 48, lng: 11, role: 'start' as const }
const onMove = vi.fn(() => true)
const onSelect = vi.fn()
const onMapClick = vi.fn()
render(
<MapViewGL
places={[]}
plannerWaypoints={[waypoint]}
onPlannerWaypointClick={onSelect}
onPlannerWaypointMove={onMove}
onMapClick={onMapClick}
glProvider="maplibre-gl"
/>
)
await act(async () => {})
const pin = glCanvasContainer.querySelector('[aria-label="Waypoint 1"]') as HTMLButtonElement
const initialTransform = pin.style.transform
act(() => {
pin.dispatchEvent(pointer('pointerdown', 10, 10))
pin.dispatchEvent(pointer('pointermove', 30, 40))
pin.dispatchEvent(pointer('pointercancel', 30, 40))
})
expect(pin.style.transform).toBe(initialTransform)
expect(onMove).not.toHaveBeenCalled()
act(() => {
pin.dispatchEvent(pointer('pointerdown', 10, 10))
pin.dispatchEvent(pointer('pointerup', 10, 10))
pin.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
expect(onSelect).toHaveBeenCalledOnce()
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
expect(onMapClick).not.toHaveBeenCalled()
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
loadOnAttach()
const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 })
+25 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
import { renderIconMarkup } from '../../utils/iconMarkup'
@@ -622,6 +623,7 @@ export function MapViewGL({
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
@@ -708,6 +710,9 @@ 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 plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
const plannerWaypointsDraggable = !!onPlannerWaypointMove
const profileFocusPinRef = useRef<PlacePin | null>(null)
/** The drive-time pills on the offered routes; same treatment. */
const altLabelsRef = useRef<PlacePin[]>([])
@@ -744,6 +749,7 @@ export function MapViewGL({
if (!map || !mapReady) return
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
const dragCleanups: (() => void)[] = []
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId
@@ -754,18 +760,34 @@ export function MapViewGL({
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
const swallow = (event: Event) => event.stopPropagation()
const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) }
const select = (event: Event) => { event.stopPropagation(); plannerWaypointHandlersRef.current.onPlannerWaypointClick?.(point.id) }
el.addEventListener('pointerdown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('click', select)
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
const pin = attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat)
plannerWaypointPinsRef.current.push(pin)
if (plannerWaypointsDraggable) {
el.style.cursor = 'grab'
dragCleanups.push(wirePlannerWaypointDrag({
element: el,
initial: { lat: point.lat, lng: point.lng },
coordinateAt: (clientX, clientY) => {
const rect = map.getContainer().getBoundingClientRect()
const at = map.unproject([clientX - rect.left, clientY - rect.top])
return { lat: at.lat, lng: at.lng }
},
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
onCommit: ({ lat, lng }) => plannerWaypointHandlersRef.current.onPlannerWaypointMove?.(point.id, lat, lng) ?? false,
}))
}
}
return () => {
dragCleanups.forEach(cleanup => cleanup())
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
}
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
}, [gl, mapReady, plannerWaypoints, plannerWaypointsDraggable, selectedPlannerWaypointId])
useEffect(() => () => {
profileFocusPinRef.current?.remove()
+3 -1
View File
@@ -111,6 +111,8 @@ export interface MapViewProps {
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */
onPlannerWaypointMove?: (id: string, lat: number, lng: number) => boolean;
routeProfileFocus?: RouteProfileFocus | null;
viewBaseLayer?: TourBaseLayer;
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
@@ -123,7 +125,7 @@ export interface MapViewProps {
*/
focusPoints?: [number, number][];
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number;
focusKey?: number | string;
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
function pointer(type: string, x: number, y: number, pointerId = 1, pointerType: 'mouse' | 'touch' = 'mouse'): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
button: { value: 0 },
clientX: { value: x },
clientY: { value: y },
pointerId: { value: pointerId },
pointerType: { value: pointerType },
});
return event;
}
describe('Tour waypoint marker drag lifecycle', () => {
it('previews during a drag and commits once on pointer up, swallowing only the generated click', () => {
const element = document.createElement('button');
const initial = { lat: 48, lng: 11 };
const preview = vi.fn();
const onCommit = vi.fn(() => true);
const cleanup = wirePlannerWaypointDrag({
element,
initial,
coordinateAt: (clientX, clientY) => ({ lat: clientY / 100, lng: clientX / 100 }),
setPosition: preview,
onCommit,
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(pointer('pointermove', 11, 11));
expect(preview).not.toHaveBeenCalled();
expect(onCommit).not.toHaveBeenCalled();
element.dispatchEvent(pointer('pointermove', 20, 30));
expect(preview).toHaveBeenLastCalledWith({ lat: 0.3, lng: 0.2 });
expect(onCommit).not.toHaveBeenCalled();
element.dispatchEvent(pointer('pointerup', 30, 40));
expect(preview).toHaveBeenLastCalledWith({ lat: 0.4, lng: 0.3 });
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith({ lat: 0.4, lng: 0.3 });
const generatedClick = new MouseEvent('click', { bubbles: true, cancelable: true });
element.dispatchEvent(generatedClick);
expect(generatedClick.defaultPrevented).toBe(true);
const nextClick = new MouseEvent('click', { bubbles: true, cancelable: true });
element.dispatchEvent(nextClick);
expect(nextClick.defaultPrevented).toBe(false);
cleanup();
});
it('restores the starting coordinate on pointer cancellation and does not commit', () => {
const element = document.createElement('button');
const initial = { lat: 48, lng: 11 };
const preview = vi.fn();
const onCommit = vi.fn();
const cleanup = wirePlannerWaypointDrag({
element,
initial,
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
setPosition: preview,
onCommit,
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(pointer('pointermove', 20, 30));
element.dispatchEvent(pointer('pointercancel', 20, 30));
expect(preview).toHaveBeenLastCalledWith(initial);
expect(onCommit).not.toHaveBeenCalled();
cleanup();
});
it('restores the preview when the canonical commit is rejected', () => {
const element = document.createElement('button');
const initial = { lat: 48, lng: 11 };
const preview = vi.fn();
const cleanup = wirePlannerWaypointDrag({
element,
initial,
coordinateAt: (clientX, clientY) => ({ lat: clientY, lng: clientX }),
setPosition: preview,
onCommit: () => false,
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(pointer('pointermove', 20, 30));
element.dispatchEvent(pointer('pointerup', 20, 30));
expect(preview).toHaveBeenLastCalledWith(initial);
cleanup();
});
it('keeps pointer, mouse, and touch starts from bubbling into map pan handlers', () => {
const mapSurface = document.createElement('div');
const element = document.createElement('button');
mapSurface.append(element);
const mapPan = vi.fn();
mapSurface.addEventListener('pointerdown', mapPan);
mapSurface.addEventListener('mousedown', mapPan);
mapSurface.addEventListener('touchstart', mapPan);
const cleanup = wirePlannerWaypointDrag({
element,
initial: { lat: 48, lng: 11 },
coordinateAt: () => ({ lat: 48, lng: 11 }),
setPosition: vi.fn(),
onCommit: vi.fn(),
});
element.dispatchEvent(pointer('pointerdown', 10, 10));
element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
element.dispatchEvent(new Event('touchstart', { bubbles: true, cancelable: true }));
expect(mapPan).not.toHaveBeenCalled();
cleanup();
});
it('commits a touch-pointer drag once at release', () => {
const element = document.createElement('button');
const onCommit = vi.fn(() => true);
const cleanup = wirePlannerWaypointDrag({
element,
initial: { lat: 48, lng: 11 },
coordinateAt: (clientX, clientY) => ({ lat: clientY / 10, lng: clientX / 10 }),
setPosition: vi.fn(),
onCommit,
});
element.dispatchEvent(pointer('pointerdown', 5, 5, 9, 'touch'));
element.dispatchEvent(pointer('pointermove', 25, 35, 9, 'touch'));
expect(onCommit).not.toHaveBeenCalled();
element.dispatchEvent(pointer('pointerup', 25, 35, 9, 'touch'));
expect(onCommit).toHaveBeenCalledOnce();
expect(onCommit).toHaveBeenCalledWith({ lat: 3.5, lng: 2.5 });
cleanup();
});
it('restores existing touch and selection styles when editing drag wiring is removed', () => {
const element = document.createElement('button');
element.style.touchAction = 'pan-x';
element.style.userSelect = 'text';
const cleanup = wirePlannerWaypointDrag({
element,
initial: { lat: 48, lng: 11 },
coordinateAt: () => ({ lat: 48, lng: 11 }),
setPosition: vi.fn(),
onCommit: vi.fn(),
});
expect(element.style.touchAction).toBe('none');
expect(element.style.userSelect).toBe('none');
cleanup();
expect(element.style.touchAction).toBe('pan-x');
expect(element.style.userSelect).toBe('text');
});
});
@@ -0,0 +1,124 @@
export interface PlannerWaypointCoordinate {
lat: number;
lng: number;
}
interface PlannerWaypointDragOptions {
element: HTMLElement;
initial: PlannerWaypointCoordinate;
coordinateAt: (clientX: number, clientY: number) => PlannerWaypointCoordinate;
setPosition: (coordinate: PlannerWaypointCoordinate) => void;
onCommit: (coordinate: PlannerWaypointCoordinate) => boolean | void;
}
const DRAG_THRESHOLD_PX = 4;
export function wirePlannerWaypointDrag({
element,
initial,
coordinateAt,
setPosition,
onCommit,
}: PlannerWaypointDragOptions): () => void {
let pointerId: number | null = null;
let start: { x: number; y: number } | null = null;
let dragged = false;
let suppressClick = false;
const originalTouchAction = element.style.touchAction;
const originalUserSelect = element.style.userSelect;
const stopMapGesture = (event: Event) => event.stopPropagation();
const restore = () => setPosition(initial);
const cancel = (event?: Event) => {
if (pointerId === null) return;
event?.stopPropagation();
restore();
pointerId = null;
start = null;
dragged = false;
suppressClick = false;
};
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 || pointerId !== null) {
event.stopPropagation();
return;
}
event.stopPropagation();
pointerId = event.pointerId;
start = { x: event.clientX, y: event.clientY };
dragged = false;
suppressClick = false;
try {
element.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is unavailable in some test DOMs and older browsers.
}
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerId !== pointerId || !start) return;
event.stopPropagation();
if (!dragged && Math.hypot(event.clientX - start.x, event.clientY - start.y) < DRAG_THRESHOLD_PX) return;
dragged = true;
event.preventDefault();
setPosition(coordinateAt(event.clientX, event.clientY));
};
const onPointerUp = (event: PointerEvent) => {
if (event.pointerId !== pointerId || !start) return;
event.stopPropagation();
const travelled = Math.hypot(event.clientX - start.x, event.clientY - start.y);
if (dragged || travelled >= DRAG_THRESHOLD_PX) {
const coordinate = coordinateAt(event.clientX, event.clientY);
setPosition(coordinate);
const accepted = onCommit(coordinate) !== false;
suppressClick = true;
if (!accepted) restore();
}
pointerId = null;
start = null;
dragged = false;
};
const onClickCapture = (event: MouseEvent) => {
if (!suppressClick) return;
suppressClick = false;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const onTouchCancel = (event: Event) => cancel(event);
element.addEventListener('pointerdown', onPointerDown);
element.addEventListener('pointermove', onPointerMove);
element.addEventListener('pointerup', onPointerUp);
element.addEventListener('pointercancel', cancel);
element.addEventListener('lostpointercapture', cancel);
element.addEventListener('click', onClickCapture, true);
element.addEventListener('mousedown', stopMapGesture);
element.addEventListener('touchstart', stopMapGesture, { passive: true });
element.addEventListener('touchmove', stopMapGesture, { passive: true });
element.addEventListener('touchend', stopMapGesture, { passive: true });
element.addEventListener('touchcancel', onTouchCancel, { passive: true });
element.style.touchAction = 'none';
element.style.userSelect = 'none';
return () => {
cancel();
element.removeEventListener('pointerdown', onPointerDown);
element.removeEventListener('pointermove', onPointerMove);
element.removeEventListener('pointerup', onPointerUp);
element.removeEventListener('pointercancel', cancel);
element.removeEventListener('lostpointercapture', cancel);
element.removeEventListener('click', onClickCapture, true);
element.removeEventListener('mousedown', stopMapGesture);
element.removeEventListener('touchstart', stopMapGesture);
element.removeEventListener('touchmove', stopMapGesture);
element.removeEventListener('touchend', stopMapGesture);
element.removeEventListener('touchcancel', onTouchCancel);
element.style.touchAction = originalTouchAction;
element.style.userSelect = originalUserSelect;
};
}