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
+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)