mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
fix(map): keep the selected place findable on the map
Selecting a place no longer spiderfies a city-sized cluster: the reveal only fans open bubbles of up to 12 stops, or stacks whose stops all share one coordinate, so a zoomed-out trip with hundreds of places stays usable (#2990). The selected pin now carries a white border inside a pulsing blue ring on both renderers (static under reduced motion), and the map measures the place card instead of assuming a fixed 320px, so the selection pans the pin above even a tall card on portrait tablets and eases it back out when the card grows over it later (#2991). Closes #2990 Closes #2991
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
"react-hooks/refs": 553,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"react-hooks/exhaustive-deps": 115,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 38,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
"react-hooks/exhaustive-deps": 45
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1255,
|
||||
"src/components/Map/MapView.tsx": 1214,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2198,
|
||||
"src/components/Map/MapViewGL.tsx": 2183,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1287,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"src/components/Journey/journeyGeo.ts": 4,
|
||||
"src/components/Layout/Navbar.test.tsx": 1,
|
||||
"src/components/Map/MapView.test.tsx": 2,
|
||||
"src/components/Map/MapView.tsx": 8,
|
||||
"src/components/Map/MapView.tsx": 7,
|
||||
"src/components/Map/MapViewGL.tsx": 1,
|
||||
"src/components/Map/ReservationOverlay.test.tsx": 1,
|
||||
"src/components/Map/transitGeometry.ts": 1,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"components/Journey/journeyPhotoLayer.ts": 2,
|
||||
"components/Map/DawarichTrailLayer.tsx": 1,
|
||||
"components/Map/dawarichTrail.ts": 10,
|
||||
"components/Map/markerLook.ts": 1,
|
||||
"components/Map/useDawarichTrailGL.ts": 1,
|
||||
"components/Packing/PackingBringersCard.tsx": 1,
|
||||
"components/Planner/DayReorderPopup.tsx": 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { act, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -85,7 +85,7 @@ const clusterMock = vi.hoisted(() => {
|
||||
group: {
|
||||
getVisibleParent(marker: unknown) {
|
||||
clusterMock.asked.push(marker)
|
||||
return clusterMock.stacked ? { spiderfy } : marker
|
||||
return clusterMock.stacked ? { spiderfy, getChildCount: () => 2 } : marker
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1283,6 +1283,50 @@ describe('MapView selection panning (#921)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('a place card that grows over the selected pin (#2991)', () => {
|
||||
const projectable = mapMock as unknown as Record<string, unknown>
|
||||
// The flat projection of the mock puts lat 0.7 / lng 0.5 at (500, 700) in a 1000x1200 map.
|
||||
const covered = buildMapPlace({ id: 21, lat: 0.7, lng: 0.5 })
|
||||
const clear = buildMapPlace({ id: 22, lat: 0.2, lng: 0.5 })
|
||||
beforeEach(() => {
|
||||
projectable.project = vi.fn(() => ({ add: vi.fn(() => ({ x: 0, y: 0 })) }))
|
||||
projectable.unproject = vi.fn(() => [1, 1])
|
||||
projectable.getSize = vi.fn(() => ({ x: 1000, y: 1200 }))
|
||||
})
|
||||
afterEach(() => {
|
||||
delete projectable.project
|
||||
delete projectable.unproject
|
||||
delete projectable.getSize
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-090: pans the pin back out once the card measured taller than the allowance', () => {
|
||||
const places = [covered]
|
||||
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector />)
|
||||
mapMock.panTo.mockClear()
|
||||
|
||||
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector inspectorHeight={820} />)
|
||||
expect(mapMock.panTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-091: leaves a pin that is still in the open alone', () => {
|
||||
const places = [clear]
|
||||
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={22} hasInspector />)
|
||||
mapMock.panTo.mockClear()
|
||||
|
||||
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={22} hasInspector inspectorHeight={820} />)
|
||||
expect(mapMock.panTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-092: pans only once when the selection and the card arrive together', () => {
|
||||
const places = [covered]
|
||||
const { rerender } = render(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={null} />)
|
||||
mapMock.panTo.mockClear()
|
||||
|
||||
rerender(<MapView places={places} leftWidth={300} rightWidth={300} selectedPlaceId={21} hasInspector inspectorHeight={820} />)
|
||||
expect(mapMock.panTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-052: falls back to a plain pan when the map cannot project', () => {
|
||||
const places = [buildMapPlace({ id: 9, lat: 48, lng: 2 })]
|
||||
const { rerender } = render(<MapView places={places} selectedPlaceId={null} />)
|
||||
|
||||
@@ -49,7 +49,8 @@ import { PluginMapLayers } from './MapPluginLayers'
|
||||
import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { placeMarkerLook } from './markerLook'
|
||||
import { placeMarkerFrame, placeMarkerLook } from './markerLook'
|
||||
import { bottomChromePadding } from './mapChromePadding'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Day, Place, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
@@ -152,10 +153,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const { size, borderWidth } = look
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
const borderColor = isSelected ? '#111827' : safeHexColor(place.category_color, 'white')
|
||||
const shadow = isSelected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white'))
|
||||
const bgColor = safeHexColor(place.category_color, '#6b7280')
|
||||
|
||||
// Number badges (bottom-right), or the rating where there are none: a numbered stop
|
||||
@@ -187,7 +185,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
width:${size}px;height:${size}px;
|
||||
cursor:pointer;position:relative;
|
||||
">
|
||||
<div style="
|
||||
<div class="${frameClass}" style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
@@ -207,7 +205,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
|
||||
const fallbackIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
html: `<div class="${frameClass}" style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
@@ -259,46 +257,6 @@ function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number],
|
||||
return null
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
|
||||
follow: boolean
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
dayPlaces: Place[]
|
||||
selectedPlace: Place | null
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
}
|
||||
|
||||
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
|
||||
const map = useMap()
|
||||
const frame = useLngFrame()
|
||||
const prev = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (follow && selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||
// Pan to the selected place without changing zoom. Offset the centre by the
|
||||
// side-panel + bottom-inspector padding so the pin lands in the middle of the
|
||||
// *visible* map area rather than the geometric centre (where the bottom panel
|
||||
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
|
||||
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
|
||||
if (selected?.lat != null && selected?.lng != null) {
|
||||
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
|
||||
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
||||
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
|
||||
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
|
||||
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
|
||||
map.panTo(map.unproject(point), { animate: true })
|
||||
} else {
|
||||
map.panTo(latlng, { animate: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface MapControllerProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
@@ -467,7 +425,7 @@ import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
// identically. Heading is shown as a rotated conic SVG when available.
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { SelectionController } from './SelectionController'
|
||||
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
|
||||
import { frameLine, frameLng } from '../../utils/lngFrame'
|
||||
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
|
||||
@@ -655,6 +613,7 @@ export const MapView = memo(function MapView({
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
inspectorHeight = 0,
|
||||
hasDayDetail = false,
|
||||
reservations = [] as Reservation[],
|
||||
showReservationStats = false,
|
||||
@@ -735,10 +694,10 @@ export const MapView = memo(function MapView({
|
||||
return {
|
||||
top: 60,
|
||||
right: rightWidth + 40,
|
||||
bottom: hasInspector ? 320 : hasDayDetail ? 280 : 60,
|
||||
bottom: bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail }),
|
||||
left: leftWidth + 40,
|
||||
}
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
|
||||
|
||||
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
|
||||
|
||||
@@ -994,7 +953,7 @@ export const MapView = memo(function MapView({
|
||||
<MapController center={center} zoom={zoom} />
|
||||
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
|
||||
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
|
||||
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingBox={paddingBox} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
|
||||
|
||||
@@ -15,7 +15,9 @@ import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
import { bottomChromePadding } from './mapChromePadding'
|
||||
import { useSelectionCameraGL } from './useSelectionCameraGL'
|
||||
import { markerPhotoHtml, placePhotoFull, placePhotoUrl } from './placePhoto'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
@@ -51,7 +53,6 @@ import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefault
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { onRealWorld } from '../../utils/worldCopy'
|
||||
import { bindPoiDropTarget } from './poiDropTarget'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import {
|
||||
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
|
||||
@@ -124,10 +125,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
|
||||
const look = placeMarkerLook(place, selected)
|
||||
const { size, borderWidth } = look
|
||||
// See MapView: allow-listed rather than escaped, because this is a CSS context.
|
||||
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
|
||||
const shadow = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white'))
|
||||
const bgColor = safeHexColor(place.category_color, '#6b7280')
|
||||
|
||||
// The visual circle is `size` + 2*border on each side. To make the
|
||||
@@ -169,7 +167,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
|
||||
const hasPhoto = look.showPhoto && photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/'))
|
||||
if (hasPhoto) {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
<div class="${frameClass}" style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
@@ -183,7 +181,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
|
||||
`
|
||||
} else {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
<div class="${frameClass}" style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
@@ -455,6 +453,7 @@ export function MapViewGL({
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
inspectorHeight = 0,
|
||||
hasDayDetail = false,
|
||||
reservations = NO_RESERVATIONS,
|
||||
visibleConnectionIds = NO_CONNECTION_IDS,
|
||||
@@ -1917,9 +1916,9 @@ export function MapViewGL({
|
||||
const paddingOpts = useMemo(() => {
|
||||
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
const bottom = bottomChromePadding({ hasInspector, inspectorHeight, hasDayDetail })
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
}, [isMobile, leftWidth, rightWidth, hasInspector, inspectorHeight, hasDayDetail])
|
||||
useAssistantMapGL({ mapRef, mapReady, gl, pinsRef: assistantGhostPinsRef, attachPin: attachPlannerWaypointPin, markersRef, padding: paddingOpts, assistant })
|
||||
|
||||
const prevFitKey = useRef<number | null>(-1)
|
||||
@@ -2011,25 +2010,10 @@ export function MapViewGL({
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !selectedPlaceId || !followSelection) return
|
||||
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
|
||||
if (!target?.lat || !target?.lng) return
|
||||
try {
|
||||
map.flyTo({
|
||||
center: [target.lng, target.lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
// Account for the side panels and the bottom inspector / day-detail panel
|
||||
// so the selected pin lands in the centre of the *visible* map area rather
|
||||
// than the geometric centre (where the bottom panel would cover it).
|
||||
padding: paddingOpts,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useSelectionCameraGL({
|
||||
mapRef, selectedPlaceId, places, dayPlaces, selectedPlace,
|
||||
follow: followSelection, padding: paddingOpts, enableMapbox3d,
|
||||
})
|
||||
|
||||
// External center/zoom prop changes — jump without animation
|
||||
const jumpedToRef = useRef<[number, number] | null>(null)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type L from 'leaflet';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import type { Place } from '../../types';
|
||||
import { frameLng } from '../../utils/lngFrame';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import { isUnderChrome } from './mapChromePadding';
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget';
|
||||
|
||||
interface SelectionControllerProps {
|
||||
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
|
||||
follow: boolean;
|
||||
places: Place[];
|
||||
selectedPlaceId: number | null;
|
||||
dayPlaces: Place[];
|
||||
selectedPlace: Place | null;
|
||||
paddingBox: ViewportPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the Leaflet map goes when a place is selected: there, and out from under the
|
||||
* panels. The GL twin is `useSelectionCameraGL`.
|
||||
*/
|
||||
export function SelectionController({
|
||||
follow,
|
||||
places,
|
||||
selectedPlaceId,
|
||||
dayPlaces,
|
||||
selectedPlace,
|
||||
paddingBox,
|
||||
}: SelectionControllerProps) {
|
||||
const map = useMap();
|
||||
const frame = useLngFrame();
|
||||
const prev = useRef<number | null>(null);
|
||||
/** The panel geometry the last pan was measured against. */
|
||||
const pannedWith = useRef(paddingBox);
|
||||
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace);
|
||||
const lat = target?.lat ?? null;
|
||||
const lng = target?.lng == null ? null : frameLng(frame, target.lng);
|
||||
|
||||
useEffect(() => {
|
||||
const isNew = selectedPlaceId !== prev.current;
|
||||
prev.current = selectedPlaceId;
|
||||
if (!isNew || !follow || !selectedPlaceId || lat == null || lng == null) return;
|
||||
pannedWith.current = paddingBox;
|
||||
panToPlace(map, [lat, lng], paddingBox);
|
||||
}, [selectedPlaceId, follow, lat, lng, map, paddingBox]);
|
||||
|
||||
// The place card only learns its height after it laid out, and grows again when its
|
||||
// details arrive. If it grew over the pin the selection placed, bring the pin back out;
|
||||
// a pin still in the open part of the map stays where the traveller left it (#2991).
|
||||
useEffect(() => {
|
||||
if (pannedWith.current === paddingBox) return;
|
||||
pannedWith.current = paddingBox;
|
||||
if (!follow || !selectedPlaceId || lat == null || lng == null) return;
|
||||
if (typeof map.latLngToContainerPoint !== 'function') return;
|
||||
if (isUnderChrome(map.latLngToContainerPoint([lat, lng]), map.getSize(), paddingBox)) {
|
||||
panToPlace(map, [lat, lng], paddingBox);
|
||||
}
|
||||
}, [paddingBox, follow, selectedPlaceId, lat, lng, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pan to a place without changing zoom. The centre is offset by the side panels and the
|
||||
* bottom card, so the pin lands in the middle of the *visible* map area rather than the
|
||||
* geometric centre, where the card would cover it.
|
||||
*/
|
||||
function panToPlace(map: L.Map, latlng: [number, number], padding: ViewportPadding): void {
|
||||
if (typeof map.project !== 'function' || typeof map.unproject !== 'function') {
|
||||
map.panTo(latlng, { animate: true });
|
||||
return;
|
||||
}
|
||||
const { top, right, bottom, left } = padding;
|
||||
const point = map.project(latlng).add([(right - left) / 2, (bottom - top) / 2]);
|
||||
map.panTo(map.unproject(point), { animate: true });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Ref } from 'react';
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher';
|
||||
|
||||
/** The card's gap to the side panels where nothing else sits at the map's edge. */
|
||||
@@ -36,3 +37,13 @@ export function centredMapCard(
|
||||
const width = `min(800px, calc(${free} - ${PILL_CLEARANCE + EDGE_GAP}px))`;
|
||||
return { width, centre: `max(${centre}, calc(${leftWidth}px + ${PILL_CLEARANCE}px + ${width} / 2))` };
|
||||
}
|
||||
|
||||
/** How a card that floats over the map is placed there, read by `centredMapCard`. */
|
||||
export interface MapCardPlacement {
|
||||
leftWidth?: number;
|
||||
rightWidth?: number;
|
||||
/** Over the desktop map, beside the base-layer pill. */
|
||||
clearMapPill?: boolean;
|
||||
/** The card's outer element, measured so the map keeps the selected pin above it (#2991). */
|
||||
cardRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bottomChromePadding, isUnderChrome, PLACE_CARD_OFFSET_PX } from './mapChromePadding';
|
||||
|
||||
describe('bottomChromePadding', () => {
|
||||
it('MAPCHROME-001: keeps the old allowances when nothing was measured', () => {
|
||||
expect(bottomChromePadding({})).toBe(60);
|
||||
expect(bottomChromePadding({ hasDayDetail: true })).toBe(280);
|
||||
expect(bottomChromePadding({ hasInspector: true })).toBe(320);
|
||||
expect(bottomChromePadding({ hasInspector: true, hasDayDetail: true })).toBe(320);
|
||||
});
|
||||
|
||||
it('MAPCHROME-002: never goes below the allowance for a short card', () => {
|
||||
expect(bottomChromePadding({ hasInspector: true, inspectorHeight: 180 })).toBe(320);
|
||||
});
|
||||
|
||||
it('MAPCHROME-003: covers a tall card with room to spare above it (#2991)', () => {
|
||||
// A 60vh card on a portrait iPad, 1366px tall.
|
||||
const card = 820;
|
||||
const padding = bottomChromePadding({ hasInspector: true, inspectorHeight: card });
|
||||
expect(padding).toBeGreaterThan(card + PLACE_CARD_OFFSET_PX);
|
||||
});
|
||||
|
||||
it('MAPCHROME-004: ignores a measured card that is no longer open', () => {
|
||||
expect(bottomChromePadding({ hasInspector: false, inspectorHeight: 820 })).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUnderChrome', () => {
|
||||
const size = { x: 1000, y: 1200 };
|
||||
const padding = { top: 60, right: 340, bottom: 900, left: 340 };
|
||||
|
||||
it('MAPCHROME-005: reads the open strip between the panels as clear', () => {
|
||||
expect(isUnderChrome({ x: 500, y: 200 }, size, padding)).toBe(false);
|
||||
expect(isUnderChrome({ x: 500, y: 300 }, size, padding)).toBe(false);
|
||||
});
|
||||
|
||||
it('MAPCHROME-006: reads a point below the card top, beside a panel or under the header as covered', () => {
|
||||
expect(isUnderChrome({ x: 500, y: 301 }, size, padding)).toBe(true);
|
||||
expect(isUnderChrome({ x: 200, y: 200 }, size, padding)).toBe(true);
|
||||
expect(isUnderChrome({ x: 700, y: 200 }, size, padding)).toBe(true);
|
||||
expect(isUnderChrome({ x: 500, y: 40 }, size, padding)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
|
||||
/** How far above the map's bottom edge the place card floats (PlaceInspector's `bottom`). */
|
||||
export const PLACE_CARD_OFFSET_PX = 20;
|
||||
/** Map kept clear above the place card, so a pin panned there is not touching its edge. */
|
||||
const PLACE_CARD_CLEARANCE_PX = 40;
|
||||
/** What a place card is assumed to cover before it has been measured. */
|
||||
const PLACE_CARD_MIN_COVER_PX = 320;
|
||||
|
||||
export interface BottomChrome {
|
||||
hasInspector?: boolean;
|
||||
/** The place card's measured height in pixels, 0 or absent while it is not on screen. */
|
||||
inspectorHeight?: number;
|
||||
hasDayDetail?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the map's bottom edge the floating panels cover, the same on both renderers.
|
||||
*
|
||||
* The place card grows with its content up to 60vh, which on a portrait tablet reaches past
|
||||
* the middle of the map. A fixed allowance put the selected pin right under it (#2991), so
|
||||
* the card's real height counts once it is known; the fixed value stays the floor for the
|
||||
* moment between the selection and the card's first layout.
|
||||
*/
|
||||
export function bottomChromePadding({ hasInspector, inspectorHeight = 0, hasDayDetail }: BottomChrome): number {
|
||||
if (hasInspector)
|
||||
return Math.max(PLACE_CARD_MIN_COVER_PX, inspectorHeight + PLACE_CARD_OFFSET_PX + PLACE_CARD_CLEARANCE_PX);
|
||||
return hasDayDetail ? 280 : 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a point on the map's container sits under the panels around it rather than in
|
||||
* the open part of the map. Used to bring the selected pin back out after a panel grew
|
||||
* over it, and to leave the camera alone when it did not.
|
||||
*/
|
||||
export function isUnderChrome(
|
||||
point: { x: number; y: number },
|
||||
size: { x: number; y: number },
|
||||
padding: ViewportPadding
|
||||
): boolean {
|
||||
return (
|
||||
point.x < padding.left ||
|
||||
point.x > size.x - padding.right ||
|
||||
point.y < padding.top ||
|
||||
point.y > size.y - padding.bottom
|
||||
);
|
||||
}
|
||||
@@ -80,6 +80,11 @@ export interface MapViewProps {
|
||||
leftWidth?: number;
|
||||
rightWidth?: number;
|
||||
hasInspector?: boolean;
|
||||
/**
|
||||
* The place card's measured height in pixels while it is open. The selection pans the
|
||||
* pin above it rather than above a fixed allowance the card can outgrow (#2991).
|
||||
*/
|
||||
inspectorHeight?: number;
|
||||
hasDayDetail?: boolean;
|
||||
reservations?: Reservation[];
|
||||
visibleConnectionIds?: number[];
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster'
|
||||
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, revealInCluster } from './markerCluster'
|
||||
import { CLUSTER_OPTIONS, CLUSTER_RADIUS_PX, CLUSTER_UNTIL_ZOOM, REVEAL_FAN_MAX_CHILDREN, revealInCluster } from './markerCluster'
|
||||
import { COINCIDENT_RADIUS_PX, STACK_RADIUS_PX } from './coincidentPlaces'
|
||||
import { MAP_MAX_ZOOM } from '../../constants/mapDefaults'
|
||||
|
||||
@@ -118,4 +118,44 @@ describe('reaching a stop inside a bubble', () => {
|
||||
// And a parent from a group that cannot spiderfy is left alone rather than called.
|
||||
expect(revealInCluster({ getVisibleParent: () => ({}) }, {})).toBe(false)
|
||||
})
|
||||
|
||||
/** A city's worth of stops around HOTEL, a few hundred metres apart, as a big trip has them. */
|
||||
function city(count: number): [number, number][] {
|
||||
return Array.from({ length: count }, (_, i) => [HOTEL[0] + (i % 8) * 0.003, HOTEL[1] + Math.floor(i / 8) * 0.004])
|
||||
}
|
||||
const fanned = (markers: L.Marker[], start: [number, number][]) =>
|
||||
markers.some((marker, i) => marker.getLatLng().lat !== start[i][0] || marker.getLatLng().lng !== start[i][1])
|
||||
|
||||
it('MARKERCLUSTER-009: leaves a city-sized bubble of the overview closed (#2990)', () => {
|
||||
const coords = city(60)
|
||||
const { group, markers } = stopsOn(mapAt(5), coords)
|
||||
const bubble = group.getVisibleParent(markers[17])
|
||||
expect(bubble).not.toBe(markers[17])
|
||||
|
||||
expect(revealInCluster(group, markers[17])).toBe(false)
|
||||
expect(fanned(markers, coords)).toBe(false)
|
||||
})
|
||||
|
||||
it('MARKERCLUSTER-010: still opens a small bubble of the overview', () => {
|
||||
const coords = city(REVEAL_FAN_MAX_CHILDREN)
|
||||
const { group, markers } = stopsOn(mapAt(5), coords)
|
||||
expect(group.getVisibleParent(markers[0])).not.toBe(markers[0])
|
||||
|
||||
expect(revealInCluster(group, markers[0])).toBe(true)
|
||||
expect(fanned(markers, coords)).toBe(true)
|
||||
})
|
||||
|
||||
it('MARKERCLUSTER-011: opens a stack of any size when every stop shares one coordinate', () => {
|
||||
const coords = Array.from({ length: REVEAL_FAN_MAX_CHILDREN + 8 }, () => HOTEL)
|
||||
const { group, markers } = stopsOn(mapAt(16), coords)
|
||||
|
||||
expect(revealInCluster(group, markers[3])).toBe(true)
|
||||
expect(new Set(markers.map(marker => `${marker.getLatLng().lat},${marker.getLatLng().lng}`)).size).toBe(coords.length)
|
||||
})
|
||||
|
||||
it('MARKERCLUSTER-012: reads a bubble without bounds as one that cannot be told apart from a city', () => {
|
||||
const spiderfy = () => { throw new Error('must not fan') }
|
||||
const big = { spiderfy, getChildCount: () => REVEAL_FAN_MAX_CHILDREN + 1 }
|
||||
expect(revealInCluster({ getVisibleParent: () => big }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,19 +42,52 @@ export interface ClusterGroupLike {
|
||||
getVisibleParent?: (marker: unknown) => unknown
|
||||
}
|
||||
|
||||
/** The part of a cluster bubble that `revealInCluster` reads. */
|
||||
interface RevealableCluster {
|
||||
spiderfy: () => void
|
||||
getChildCount: () => number
|
||||
getBounds?: () => { getNorthEast: () => L.LatLng; getSouthWest: () => L.LatLng }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan open the bubble a stop is hidden inside, if it is in one.
|
||||
* The most stops a selection fans open around itself. Up to here the fan stays a small
|
||||
* ring around the bubble; a city-sized bubble from the overview would throw hundreds of
|
||||
* legs across the whole map instead (#2990).
|
||||
*/
|
||||
export const REVEAL_FAN_MAX_CHILDREN = 12
|
||||
|
||||
/**
|
||||
* Whether opening this bubble shows the selection or buries the map.
|
||||
*
|
||||
* A small bubble always opens. A bigger one only when every stop in it sits on the same
|
||||
* coordinate, the stack no zoom can take apart, which is the case the fan is there for.
|
||||
* Anything else is a pile of ordinary neighbours that zooming in separates, so it stays
|
||||
* closed and the selection is shown by the pan alone.
|
||||
*/
|
||||
function fansOpenOnSelect(cluster: RevealableCluster): boolean {
|
||||
if (cluster.getChildCount() <= REVEAL_FAN_MAX_CHILDREN) return true
|
||||
const bounds = cluster.getBounds?.()
|
||||
return !!bounds && bounds.getNorthEast().equals(bounds.getSouthWest())
|
||||
}
|
||||
|
||||
function isRevealableCluster(layer: unknown): layer is RevealableCluster {
|
||||
const candidate = layer as Partial<RevealableCluster> | null
|
||||
return !!candidate && typeof candidate.spiderfy === 'function' && typeof candidate.getChildCount === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan open the bubble a stop is hidden inside, if it is in one and small enough.
|
||||
*
|
||||
* Picking a stop from the places rail used to raise its pin out of the stack with a
|
||||
* z-index. A stop sharing its coordinates now sits in a cluster instead and has no pin
|
||||
* of its own to raise, so the bubble has to open for the selection to be visible at all.
|
||||
* Deliberately not `zoomToShowLayer`: the zoom belongs to the day fit, and the selection
|
||||
* only ever pans.
|
||||
* only ever pans. See `fansOpenOnSelect` for which bubbles are opened.
|
||||
*/
|
||||
export function revealInCluster(group: ClusterGroupLike | null | undefined, marker: unknown): boolean {
|
||||
if (!group || !marker || typeof group.getVisibleParent !== 'function') return false
|
||||
const parent = group.getVisibleParent(marker) as { spiderfy?: () => void } | null
|
||||
if (!parent || parent === marker || typeof parent.spiderfy !== 'function') return false
|
||||
const parent = group.getVisibleParent(marker)
|
||||
if (parent === marker || !isRevealableCluster(parent) || !fansOpenOnSelect(parent)) return false
|
||||
parent.spiderfy()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { placeMarkerFrame, placeMarkerLook, SELECTED_MARKER } from './markerLook';
|
||||
|
||||
describe('placeMarkerFrame', () => {
|
||||
it('MARKERLOOK-001: gives the selected pin the white border, blue ring and pulse class (#2991)', () => {
|
||||
expect(placeMarkerFrame(true, '#ff0000')).toEqual({
|
||||
borderColor: SELECTED_MARKER.border,
|
||||
shadow: SELECTED_MARKER.ring,
|
||||
className: SELECTED_MARKER.className,
|
||||
});
|
||||
expect(SELECTED_MARKER.ring).toContain('#0a84ff');
|
||||
});
|
||||
|
||||
it('MARKERLOOK-002: keeps every other pin in its category colour, without the class', () => {
|
||||
const frame = placeMarkerFrame(false, '#ff0000');
|
||||
expect(frame.borderColor).toBe('#ff0000');
|
||||
expect(frame.className).toBe('');
|
||||
expect(frame.shadow).not.toContain('#0a84ff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeMarkerLook', () => {
|
||||
it('MARKERLOOK-003: draws a selected pin larger than a planned one, even when it was compact', () => {
|
||||
expect(placeMarkerLook({ _compact: true }, true).size).toBeGreaterThan(placeMarkerLook({}, false).size);
|
||||
expect(placeMarkerLook({ _compact: true }, false).showPhoto).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import './selectedMarker.css'
|
||||
|
||||
/**
|
||||
* Two ways a place marker can be told apart from the rest, set on the place object the
|
||||
* planner hands to the map. Underscored because they are the map's reading of the plan,
|
||||
@@ -39,3 +41,24 @@ export function placeMarkerLook(place: PlaceMarkerFlags, selected: boolean): Pla
|
||||
key: `${compact ? 'c' : ''}${pending ? 'p' : ''}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How the selected place stands out from every other pin, the same on both renderers.
|
||||
*
|
||||
* Map paint rather than theme: it sits on tiles that are light, dark or a photo, beside
|
||||
* pins whose border is their category colour. The old near-black border and grey halo
|
||||
* vanished on dark tiles (#2991); a white border inside a saturated blue ring reads on
|
||||
* all of them, and the class adds a slow pulse (selectedMarker.css) that stops under reduced motion.
|
||||
*/
|
||||
export const SELECTED_MARKER = {
|
||||
border: '#ffffff', // theme-lint-disable: map paint, has to read on any tile
|
||||
ring: '0 0 0 3px #0a84ff, 0 4px 14px rgba(0,0,0,0.35)',
|
||||
className: 'trek-marker-selected',
|
||||
} as const
|
||||
|
||||
/** The border colour and shadow of a round place marker, selected or not. */
|
||||
export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } {
|
||||
return selected
|
||||
? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className }
|
||||
: { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* The selected place pin on either map (#2991). Its static ring is inline, so a browser
|
||||
without the animation still shows it; the pulse only widens a fading second ring
|
||||
around it, and the pin itself never moves. */
|
||||
@keyframes trek-marker-selected-pulse {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 0 3px #0a84ff,
|
||||
0 0 0 3px rgba(10, 132, 255, 0.55),
|
||||
0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px #0a84ff,
|
||||
0 0 0 16px rgba(10, 132, 255, 0),
|
||||
0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.trek-marker-selected {
|
||||
animation: trek-marker-selected-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Under reduced motion the ring stays, it just stops breathing. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-marker-selected {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { bottomChromePadding } from './mapChromePadding';
|
||||
import { useSelectionCameraGL } from './useSelectionCameraGL';
|
||||
|
||||
/** A 1000x1200 map whose projection is lng/lat times ten. */
|
||||
function glMap() {
|
||||
return {
|
||||
getZoom: vi.fn(() => 12),
|
||||
flyTo: vi.fn(),
|
||||
easeTo: vi.fn(),
|
||||
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
|
||||
getContainer: vi.fn(() => ({ clientWidth: 1000, clientHeight: 1200 })),
|
||||
};
|
||||
}
|
||||
|
||||
// Projected to (500, 700) and (500, 200).
|
||||
const low = { id: 1, lat: 70, lng: 50 };
|
||||
const high = { id: 2, lat: 20, lng: 50 };
|
||||
|
||||
function padding(inspectorHeight: number) {
|
||||
return { top: 60, right: 340, left: 340, bottom: bottomChromePadding({ hasInspector: true, inspectorHeight }) };
|
||||
}
|
||||
|
||||
function setup(selectedPlaceId: number, follow = true) {
|
||||
const map = glMap();
|
||||
const mapRef = { current: map };
|
||||
const view = renderHook(
|
||||
({ selected, pad }) =>
|
||||
useSelectionCameraGL({
|
||||
mapRef,
|
||||
selectedPlaceId: selected,
|
||||
places: [low, high],
|
||||
dayPlaces: [],
|
||||
selectedPlace: null,
|
||||
follow,
|
||||
padding: pad,
|
||||
enableMapbox3d: false,
|
||||
}),
|
||||
{ initialProps: { selected: selectedPlaceId, pad: padding(0) } }
|
||||
);
|
||||
return { map, ...view };
|
||||
}
|
||||
|
||||
describe('useSelectionCameraGL', () => {
|
||||
it('SELCAM-GL-001: flies to a new selection, padded by the panels, at least to zoom 14', () => {
|
||||
const { map } = setup(1);
|
||||
expect(map.flyTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ center: [50, 70], zoom: 14, padding: padding(0) })
|
||||
);
|
||||
});
|
||||
|
||||
it('SELCAM-GL-002: eases the pin back out when the place card grew over it (#2991)', () => {
|
||||
const { map, rerender } = setup(1);
|
||||
rerender({ selected: 1, pad: padding(900) });
|
||||
expect(map.easeTo).toHaveBeenCalledWith({ center: [50, 70], padding: padding(900), duration: 300 });
|
||||
expect(map.flyTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SELCAM-GL-003: leaves a pin that is still in the open where it is', () => {
|
||||
const { map, rerender } = setup(2);
|
||||
rerender({ selected: 2, pad: padding(900) });
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SELCAM-GL-004: moves only once when the selection and the card arrive together', () => {
|
||||
const { map, rerender } = setup(2);
|
||||
rerender({ selected: 1, pad: padding(900) });
|
||||
expect(map.flyTo).toHaveBeenCalledTimes(2);
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SELCAM-GL-005: does not move a locked map at all', () => {
|
||||
const { map, rerender } = setup(1, false);
|
||||
rerender({ selected: 1, pad: padding(900) });
|
||||
expect(map.flyTo).not.toHaveBeenCalled();
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ViewportPadding } from '../../utils/mapViewport';
|
||||
import { isUnderChrome } from './mapChromePadding';
|
||||
import { selectedPlaceTarget, type MapTarget } from './selectedPlaceTarget';
|
||||
|
||||
/** The part of a MapLibre/Mapbox map the selection camera drives. */
|
||||
interface SelectionCameraMap {
|
||||
getZoom: () => number;
|
||||
flyTo: (options: Record<string, unknown>) => unknown;
|
||||
easeTo: (options: Record<string, unknown>) => unknown;
|
||||
project?: (lngLat: [number, number]) => { x: number; y: number };
|
||||
getContainer: () => { clientWidth: number; clientHeight: number };
|
||||
}
|
||||
|
||||
interface SelectionCameraOptions<P extends MapTarget> {
|
||||
mapRef: { readonly current: SelectionCameraMap | null | undefined };
|
||||
selectedPlaceId: number | null;
|
||||
places: P[];
|
||||
dayPlaces: P[];
|
||||
selectedPlace: P | null;
|
||||
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
|
||||
follow: boolean;
|
||||
/** The side panels and the bottom card over the map, so the pin lands in the open part. */
|
||||
padding: ViewportPadding;
|
||||
enableMapbox3d: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the GL map goes when a place is selected: there, and out from under the panels.
|
||||
*
|
||||
* A selection flies to the pin, padded by the panels so it lands in the centre of the
|
||||
* visible map rather than the geometric one. The place card only learns its height after
|
||||
* it laid out and grows again when its details arrive; if it grew over the pin, the pin is
|
||||
* eased back out above it at the same zoom, and one still in the open stays put (#2991).
|
||||
*/
|
||||
export function useSelectionCameraGL<P extends MapTarget>({
|
||||
mapRef,
|
||||
selectedPlaceId,
|
||||
places,
|
||||
dayPlaces,
|
||||
selectedPlace,
|
||||
follow,
|
||||
padding,
|
||||
enableMapbox3d,
|
||||
}: SelectionCameraOptions<P>): void {
|
||||
/** The panel geometry the last move to the selection was measured against. */
|
||||
const movedWith = useRef(padding);
|
||||
/** The selection (and 3D mode) the camera last flew for: only a change of it flies. */
|
||||
const flownFor = useRef<string | null>(null);
|
||||
const target = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace);
|
||||
const lat = target?.lat ?? null;
|
||||
const lng = target?.lng ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const key = `${selectedPlaceId}:${enableMapbox3d}`;
|
||||
if (flownFor.current === key) return;
|
||||
flownFor.current = key;
|
||||
movedWith.current = padding;
|
||||
const map = mapRef.current;
|
||||
if (!map || !selectedPlaceId || !follow || !lat || !lng) return;
|
||||
try {
|
||||
map.flyTo({
|
||||
center: [lng, lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
padding,
|
||||
});
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [selectedPlaceId, enableMapbox3d, follow, lat, lng, mapRef, padding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (movedWith.current === padding) return;
|
||||
movedWith.current = padding;
|
||||
const map = mapRef.current;
|
||||
if (!map || !selectedPlaceId || !follow || lat == null || lng == null) return;
|
||||
if (typeof map.project !== 'function') return;
|
||||
try {
|
||||
const container = map.getContainer();
|
||||
const size = { x: container.clientWidth, y: container.clientHeight };
|
||||
if (!isUnderChrome(map.project([lng, lat]), size, padding)) return;
|
||||
map.easeTo({ center: [lng, lat], padding, duration: 300 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [padding, selectedPlaceId, follow, lat, lng, mapRef]);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { TRANSPORT_TYPES, getPlaceBookings } from '../../utils/dayMerge'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { centredMapCard } from '../Map/centredMapCard'
|
||||
import { centredMapCard, type MapCardPlacement } from '../Map/centredMapCard'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, getWeekdayIndex, type OpeningPeriod } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
import type { EndDayControlProps } from '../Roadtrip/EndDayControl'
|
||||
@@ -152,7 +152,7 @@ interface TripMember {
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
interface PlaceInspectorProps extends MapCardPlacement {
|
||||
roadtripActive?: boolean
|
||||
roadtripEndDay?: EndDayControlProps
|
||||
roadtripStay?: RoadtripStayControl
|
||||
@@ -188,9 +188,6 @@ interface PlaceInspectorProps {
|
||||
onUpdatePlace?: (placeId: number, data: Partial<Place>) => void
|
||||
/** Cast/clear the current user's star vote (#1435); enables the rating row. */
|
||||
onRate?: (placeId: number, rating: number | null) => Promise<void> | void
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
clearMapPill?: boolean // over the desktop map, beside the base-layer pill
|
||||
// ── Collection-mode props ──
|
||||
collectionStatus?: CollectionStatus
|
||||
onCopyToTrip?: () => void
|
||||
@@ -212,7 +209,7 @@ export default function PlaceInspector({
|
||||
assignments = {}, reservations = [], onEditTransport, onEditReservation, onOpenBooking,
|
||||
onClose, onEdit: editPlace, onDelete: deletePlace, onAssignToDay, onRemoveAssignment,
|
||||
files = [], onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace: updatePlace, onRate,
|
||||
leftWidth = 0, rightWidth = 0, clearMapPill = false,
|
||||
leftWidth = 0, rightWidth = 0, clearMapPill = false, cardRef,
|
||||
collectionStatus, onCopyToTrip, onSetStatus, onRemoveFromList, roadtripEndDay, roadtripStay, roadtripActive,
|
||||
}: PlaceInspectorProps) {
|
||||
// Editing the place is a place right. The planner hands the handlers over regardless, and a member
|
||||
@@ -369,7 +366,7 @@ export default function PlaceInspector({
|
||||
const mapCard = centredMapCard(leftWidth, rightWidth, { clearPill: clearMapPill })
|
||||
|
||||
return (
|
||||
<div
|
||||
<div ref={cardRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ParkingSquare } from 'lucide-react'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { isServiceStopType, serviceColor } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import { SELECTED_MARKER } from '../Map/markerLook'
|
||||
|
||||
/**
|
||||
* Smaller than a place marker on purpose.
|
||||
@@ -42,10 +43,9 @@ export function serviceMarkerHtml(stopType: string | null | undefined, selected:
|
||||
color: '#fff',
|
||||
strokeWidth: 2.4,
|
||||
}))
|
||||
const ring = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
|
||||
: '0 2px 6px rgba(0,0,0,0.25)'
|
||||
return `<div style="
|
||||
// Selected, it carries the same ring as a selected place pin, so the two read alike.
|
||||
const ring = selected ? SELECTED_MARKER.ring : '0 2px 6px rgba(0,0,0,0.25)'
|
||||
return `<div class="${selected ? SELECTED_MARKER.className : ''}" style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${serviceColor(stopType)};
|
||||
border:${BORDER}px solid #fff;box-shadow:${ring};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useMeasuredHeight, useReportedHeight } from './useReportedHeight';
|
||||
|
||||
function Card({ onHeight, open = true }: { onHeight?: (height: number) => void; open?: boolean }) {
|
||||
const ref = useReportedHeight(onHeight);
|
||||
return open ? <div ref={ref}>card</div> : null;
|
||||
}
|
||||
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
|
||||
afterEach(() => {
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', original);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('useReportedHeight', () => {
|
||||
it('REPORTED-HEIGHT-001: reports the height on mount and 0 once the element is gone', () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 412 });
|
||||
const onHeight = vi.fn();
|
||||
const { rerender } = render(<Card onHeight={onHeight} />);
|
||||
expect(onHeight).toHaveBeenLastCalledWith(412);
|
||||
|
||||
rerender(<Card onHeight={onHeight} open={false} />);
|
||||
expect(onHeight).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it('REPORTED-HEIGHT-002: reports again whenever the element resizes', () => {
|
||||
let height = 300;
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => height });
|
||||
const observed: { resize?: () => void } = {};
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(cb: () => void) {
|
||||
observed.resize = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect = disconnect;
|
||||
}
|
||||
);
|
||||
const onHeight = vi.fn();
|
||||
const { unmount } = render(<Card onHeight={onHeight} />);
|
||||
height = 820;
|
||||
expect(observed.resize).toBeTypeOf('function');
|
||||
observed.resize?.();
|
||||
expect(onHeight).toHaveBeenLastCalledWith(820);
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REPORTED-HEIGHT-003: does nothing without a listener', () => {
|
||||
expect(() => render(<Card />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMeasuredHeight', () => {
|
||||
it('REPORTED-HEIGHT-004: holds the measured height as state', () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 512 });
|
||||
function Probe() {
|
||||
const { height, measure } = useMeasuredHeight();
|
||||
return <div ref={measure} data-height={height} />;
|
||||
}
|
||||
const { container } = render(<Probe />);
|
||||
expect(container.firstElementChild?.getAttribute('data-height')).toBe('512');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A callback ref that reports its element's rendered height whenever it changes, and 0
|
||||
* once the element is gone. For a card floating over something that has to keep clear of
|
||||
* it, the way the map keeps the selected pin above the place card (#2991).
|
||||
*/
|
||||
export function useReportedHeight(onHeight: ((height: number) => void) | undefined) {
|
||||
const observer = useRef<ResizeObserver | null>(null);
|
||||
const report = useRef(onHeight);
|
||||
useLayoutEffect(() => {
|
||||
report.current = onHeight;
|
||||
}, [onHeight]);
|
||||
|
||||
const ref = useCallback((el: HTMLElement | null) => {
|
||||
observer.current?.disconnect();
|
||||
observer.current = null;
|
||||
if (!el) {
|
||||
report.current?.(0);
|
||||
return;
|
||||
}
|
||||
const publish = () => report.current?.(el.offsetHeight);
|
||||
publish();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer.current = new ResizeObserver(publish);
|
||||
observer.current.observe(el);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => observer.current?.disconnect(), []);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/**
|
||||
* The element's height as state, and `measure`, the callback ref that reads it: hand
|
||||
* `measure` to the element as its `ref`.
|
||||
*/
|
||||
export function useMeasuredHeight() {
|
||||
const [height, setHeight] = useState(0);
|
||||
const measure = useReportedHeight(setHeight);
|
||||
return { height, measure };
|
||||
}
|
||||
@@ -280,7 +280,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
dawarichEnabled={dawarichEnabled} dawarichTrail={dawarichTrail} dawarichHiddenDates={dawarichHiddenDates} dawarichTrailShown={dawarichTrailShown} toggleDawarichTrail={toggleDawarichTrail}
|
||||
mobileSidebarOpen={mobileSidebarOpen} showPlaceForm={showPlaceForm} showMembersModal={showMembersModal} showReservationModal={showReservationModal}
|
||||
poi={poi} tourMap={tourMap} leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} mapInsetLeft={mapInsetLeft} mapInsetRight={mapInsetRight}
|
||||
glMap={glMap} setGlMap={setGlMap} poiPillEnabled={poiPillEnabled} mapPois={mapPois} distanceUnit={distanceUnit}
|
||||
glMap={glMap} setGlMap={setGlMap} poiPillEnabled={poiPillEnabled} mapPois={mapPois} distanceUnit={distanceUnit} inspectorHeight={pageState.inspectorCard.height}
|
||||
/>
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
@@ -547,7 +547,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
leftWidth={isMobile ? 0 : leftPanelPx}
|
||||
rightWidth={isMobile ? 0 : rightPanelPx}
|
||||
clearMapPill
|
||||
clearMapPill cardRef={pageState.inspectorCard.measure}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ interface StagePageLocals {
|
||||
poiPillEnabled: boolean
|
||||
mapPois: Poi[]
|
||||
distanceUnit: Settings['distance_unit']
|
||||
/** The open place card's measured height, so the selected pin is panned above it. */
|
||||
inspectorHeight?: number
|
||||
}
|
||||
|
||||
export interface PlanMapStageProps
|
||||
@@ -73,7 +75,7 @@ export function PlanMapStage({
|
||||
dawarichEnabled, dawarichTrail, dawarichHiddenDates, dawarichTrailShown, toggleDawarichTrail,
|
||||
mobileSidebarOpen, showPlaceForm, showMembersModal, showReservationModal,
|
||||
poi, tourMap, leftPanelPx, rightPanelPx, mapInsetLeft, mapInsetRight,
|
||||
glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit,
|
||||
glMap, setGlMap, poiPillEnabled, mapPois, distanceUnit, inspectorHeight = 0,
|
||||
}: Readonly<PlanMapStageProps>): ReactElement {
|
||||
// ACT-02: a tour compared with its actual track lays the track into the day's lines, dotted.
|
||||
const compared = useActivityCompareMap({
|
||||
@@ -115,6 +117,7 @@ export function PlanMapStage({
|
||||
leftWidth={leftPanelPx}
|
||||
rightWidth={rightPanelPx}
|
||||
hasInspector={!!selectedPlace}
|
||||
inspectorHeight={selectedPlace ? inspectorHeight : 0}
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
showReservationStats={true}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPort
|
||||
import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync'
|
||||
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
|
||||
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
|
||||
import { useMeasuredHeight } from '../../hooks/useReportedHeight'
|
||||
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'
|
||||
import { useHighlightedRoute } from '../../components/Tours/bike/useHighlightedRoute'
|
||||
import { useLegVariantLines } from '../../components/Tours/planner/legVariantPaint'
|
||||
@@ -101,6 +102,8 @@ export function useTripPlannerPage() {
|
||||
}, [activeTab, closeGpxTour])
|
||||
|
||||
const poi = usePoiExplore()
|
||||
// The place card's height over the desktop map, so the selection pans its pin above it (#2991).
|
||||
const inspectorCard = useMeasuredHeight()
|
||||
const poiViewportChangeRef = useRef(poi.onViewportChange)
|
||||
poiViewportChangeRef.current = poi.onViewportChange
|
||||
// The place search looks where the plan map looks, and only while it is on screen.
|
||||
@@ -163,5 +166,6 @@ export function useTripPlannerPage() {
|
||||
// The day plan's tours: which places are tours, their planned times and where their stages run (MDT-02).
|
||||
dayPlanTours: { tourPlaceIds: planner.tourPlaceIds, plannedTourDurations, tourStageStops: planner.tourStageStops },
|
||||
placeForm: { close: closePlaceForm, ...placeFormContext(planner.editingPlace, planner.assignments, tours) },
|
||||
inspectorCard,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user