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:
Maurice
2026-11-06 11:51:16 +01:00
parent d7c4324afc
commit 4c0583845a
26 changed files with 713 additions and 107 deletions
+2 -2
View File
@@ -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,
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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,
+46 -2
View File
@@ -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} />)
+10 -51
View File
@@ -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} />
+13 -29
View File
@@ -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)
})
})
+37 -4
View File
@@ -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);
});
});
+23
View File
@@ -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');
});
});
+42
View File
@@ -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 };
}
+2 -2
View File
@@ -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,
}
}