feat(roadtrip): add daily scheduling and configurable day endings

This commit is contained in:
Maurice
2026-09-11 22:28:02 +02:00
parent 4f8605b529
commit 491cfccc56
124 changed files with 3512 additions and 354 deletions
@@ -0,0 +1,37 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Marker, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
}) {
const map = useMap()
const { t } = useTranslation()
const [zoom, setZoom] = useState(() => map.getZoom())
const popup = useRef<L.Popup | null>(null)
useEffect(() => () => { popup.current?.remove() }, [pois, enabled, zoom])
useEffect(() => {
const update = () => setZoom(map.getZoom())
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng])) : [], [pois, enabled, map, zoom])
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
if (!enabled) return <>{children}</>
return <>{groups.map(group => {
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
const label = t('roadtrip.poi.found', { count: group.pois.length })
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
zIndexOffset={600} eventHandlers={{ click: () => {
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popup.current?.remove()
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
} }} />
})}</>
}
+23 -2
View File
@@ -248,9 +248,9 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBe(3)
})
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
it.each([false, true])('FE-COMP-MAPVIEW-010: place clustering stays enabled with roadtrip=%s', (roadtrip) => {
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
render(<MapView places={places} clusterLoosely={roadtrip} />)
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
@@ -680,6 +680,27 @@ describe('MapView plugin route vias', () => {
expect(markersWithZ('800')[0].textContent).toContain('Supercharger · 1 h 30 min')
})
it('hides night badges at wide zoom and shows the full description when zoomed in', () => {
mapMock.getZoom.mockReturnValue(5)
const label = 'Tagesende von Tag 1 um 18:00 Uhr'
render(<MapView routeVias={[via({ label, hoverCard: true, nightPause: { day: 1, atPlace: true } })]} />)
expect(markersWithZ('800')).toHaveLength(0)
mapMock.getZoom.mockReturnValue(6)
act(() => { mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]() })
const badge = markersWithZ('800')[0]
expect(iconHtmlOf(badge)).toContain('data-night-pause="place"')
fireEvent.click(screen.getByTestId('marker-hover-trigger'))
expect(screen.getByRole('tooltip')).toHaveTextContent(label)
mapMock.getZoom.mockReturnValue(5)
act(() => {
mapMock.on.mock.calls.find(([events]) => events === 'movestart zoomstart')![1]()
mapMock.on.mock.calls.find(([events]) => events === 'moveend zoomend')![1]()
})
expect(markersWithZ('800')).toHaveLength(0)
expect(screen.queryByRole('tooltip')).toBeNull()
mapMock.getZoom.mockReturnValue(10)
})
it('FE-COMP-MAPVIEW-041: a dwell under an hour is shown in minutes alone', () => {
render(<MapView routeVias={[via({ dwellSeconds: 1500 })]} />)
expect(markersWithZ('800')[0].textContent).toContain('25 min')
+66 -34
View File
@@ -44,6 +44,12 @@ import { visibleRouteReservations } from '../../utils/reservationRoutes'
import { safeHexColor } from '../../utils/safeColor'
import { escapeHtml } from '@trek/shared'
import type { Day, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import NightPauseTooltip from './NightPauseTooltip'
import ClusteredPois from './ClusteredPois'
import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
@@ -98,6 +104,26 @@ function formatViaDwell(seconds: number): string {
return h > 0 ? `${h} h ${m} min` : `${m} min`
}
function nightPauseIcon(via: RouteVia): L.DivIcon {
const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}`
const cached = viaIconCache.get(key)
if (cached) return cached
const icon = L.divIcon({ className: 'night-pause-marker', html: nightPauseMarker(via), iconSize: [0, 0], iconAnchor: [0, 0] })
viaIconCache.set(key, icon)
return icon
}
function RouteViaMarker({ via, controls, eventHandlers, children }: {
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
}) {
const [marker, setMarker] = useState<L.Marker | null>(null)
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
{children}
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
</Marker>
}
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
@@ -226,15 +252,16 @@ function createPoiIcon(category: string, brandWikidata?: string | null) {
// away under a stationary cursor, so the browser never fires mouseout — and
// mouseover/mousemove during the pan animation would immediately re-set the
// tooltip we just cleared (#1404).
function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) {
function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { current: boolean }; onMoveStart: () => void; onZoom: (zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const start = () => { movingRef.current = true; onMoveStart() }
const end = () => { movingRef.current = false }
const end = () => { movingRef.current = false; onZoom(map.getZoom()) }
onZoom(map.getZoom())
map.on('movestart zoomstart', start)
map.on('moveend zoomend', end)
return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) }
}, [map, movingRef, onMoveStart])
}, [map, movingRef, onMoveStart, onZoom])
return null
}
@@ -638,6 +665,7 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
dayBoundaryControls,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -712,11 +740,12 @@ export const MapView = memo(function MapView({
})
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
const [mapZoom, setMapZoom] = useState(0)
const mapMovingRef = useRef(false)
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
const handleMarkerHover = useCallback((place: MapHoverInfo, x: number, y: number) => {
if (hoverDisabled || mapMovingRef.current) return
setHoveredPlace(place)
setTooltipPos({ x, y })
@@ -920,6 +949,7 @@ export const MapView = memo(function MapView({
), [gpxTracks, hasCasingPane])
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
@@ -1001,32 +1031,25 @@ export const MapView = memo(function MapView({
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} onZoom={setMapZoom} />
<ViewportController onViewportChange={onViewportChange} />
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
zoomed right out, and a day's stops merging into one dot is exactly the shape
it is being looked at for. Rendered outside the group rather than with the
clustering disabled by option, so nothing is left to a plugin's own idea of
what "disabled" means. */}
{clusterLoosely ? markers : (
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
)}
<MarkerClusterGroup
chunkedLoading
chunkInterval={30}
chunkDelay={0}
maxClusterRadius={30}
disableClusteringAtZoom={11}
spiderfyOnMaxZoom
showCoverageOnHover={false}
zoomToBoundsOnClick
animate={false}
iconCreateFunction={clusterIconCreateFunction}
>
{markers}
</MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
@@ -1130,19 +1153,25 @@ export const MapView = memo(function MapView({
roadRoutes={transportRoutes}
/>
{poiMarkers}
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
{(routeVias as RouteVia[]).map((v, i) => (
<Marker key={`route-via-${i}`} position={[v.lat, v.lng]} icon={routeViaIcon(v.tone)} zIndexOffset={800}>
{(v.label || v.dwellSeconds != null) && (
<Tooltip direction="top" offset={[0, -8]}>
{(routeVias as RouteVia[]).filter(v => !v.nightPause || mapZoom >= NIGHT_PAUSE_MIN_ZOOM).map((v, i) => (
<RouteViaMarker key={v.nightPause ? `night-${v.nightPause.day}` : `route-via-${i}`} via={v} controls={dayBoundaryControls}
eventHandlers={v.hoverCard ? {
mouseover: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
mousemove: e => handleMarkerHover({ name: v.label, routeVia: v }, e.originalEvent.clientX, e.originalEvent.clientY),
mouseout: handleMarkerHoverOut,
} : undefined}
>
{(!v.hoverCard || isTouchDevice) && (v.label || v.dwellSeconds != null) && (
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
{v.label}
{v.label && v.dwellSeconds != null ? ' · ' : ''}
{v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : ''}
</Tooltip>
)}
</Marker>
</RouteViaMarker>
))}
<PluginMapMarkers tripId={tripId} />
<PluginMapLayers tripId={tripId} />
@@ -1162,7 +1191,10 @@ export const MapView = memo(function MapView({
</div>
</div>
{TooltipOverlay && (
{TooltipOverlay && hoveredPlace.routeVia?.nightPause && (
<NightPauseTooltip label={hoveredPlace.name ?? ''} x={tooltipPos.x} y={tooltipPos.y} />
)}
{TooltipOverlay && !hoveredPlace.routeVia?.nightPause && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: tooltipPos.x + 14,
@@ -1389,6 +1389,30 @@ describe('MapViewGL', () => {
expect(glMarkers.created[1].element.innerHTML).toContain('#6b7280')
})
it('clusters roadtrip stations and zooms before offering overlapping stations individually', async () => {
loadOnAttach()
const poi: Poi = {
osm_id: 'n1', name: 'Station A', lat: 48.854, lng: 2.332, category: 'charging_station',
poi_type: 'charging_station', address: null, website: null, phone: null,
opening_hours: null, cuisine: null, source: 'openstreetmap',
}
const other = { ...poi, osm_id: 'n2', name: 'Station B' }
const onPoiClick = vi.fn()
render(<MapViewGL places={[]} fitKey={1} pois={[poi, other]} clusterLoosely onPoiClick={onPoiClick} />)
await act(async () => {})
const cluster = glMarkers.created.find(pin => pin.element.querySelector('[data-poi-cluster="2"]'))!
expect(cluster).toBeDefined()
act(() => { cluster.element.click() })
expect(glMap.fitBounds).toHaveBeenCalledWith(expect.anything(), { padding: 70, maxZoom: 18 })
expect(onPoiClick).not.toHaveBeenCalled()
vi.mocked(glMap.getZoom).mockReturnValueOnce(18)
act(() => { cluster.element.click() })
const calls = vi.mocked(glPopup.setDOMContent).mock.calls
const list = calls[calls.length - 1][0]
act(() => { list.querySelectorAll('button')[1].click() })
expect(onPoiClick).toHaveBeenCalledWith(other)
})
it('FE-COMP-MAPVIEWGL-043: plugin markers render as tone dots with a text-only popup', async () => {
loadOnAttach()
const markers: PluginMapMarker[] = [
+110 -36
View File
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -24,6 +25,11 @@ import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlM
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Day, Place, Reservation, RouteVia } from '../../types'
import type { MapHoverInfo } from './mapHover'
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
@@ -120,6 +126,7 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
dayBoundaryControls?: DayBoundaryControls
/** The dashed last bit to a place the road network does not reach. */
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
@@ -197,7 +204,7 @@ interface Props {
onMapReady?: (map: any | null) => void
}
/** How eagerly place markers merge into a cluster, outside road trip mode. */
/** How eagerly place markers merge into a cluster. */
const CLUSTER_RADIUS = 30
const CLUSTER_MAX_ZOOM = 10
@@ -609,6 +616,7 @@ export function MapViewGL({
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
dayBoundaryControls,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
@@ -651,6 +659,7 @@ export function MapViewGL({
gl,
onMapReady,
}: Props) {
const { t } = useTranslation()
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -675,7 +684,7 @@ export function MapViewGL({
const [mapReady, setMapReady] = useState(false)
// Hover tooltip — a cursor-following name/category/address card, matching the
// Leaflet map's overlay exactly (no anchored popup, no photo thumbnail).
const [hoverPlace, setHoverPlace] = useState<(Place & { category_color?: string | null; category_icon?: string | null; category_name?: string | null }) | null>(null)
const [hoverPlace, setHoverPlace] = useState<MapHoverInfo | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const hoverIdRef = useRef<number | null>(null)
// True while the camera is moving (flyTo after a click, pan, zoom). Marker
@@ -1781,18 +1790,6 @@ export function MapViewGL({
return
}
// Road trip mode does not cluster at all: the whole drive has to stay readable
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
// is being looked at for. The source is emptied rather than reconfigured — the
// cluster options can only be set when a GeoJSON source is created, and dropping and
// re-adding a source under three layers to change a radius is not worth it when the
// markers are drawn from `places` anyway.
if (clusterLoosely) {
source.setData({ type: 'FeatureCollection', features: [] })
reconcileMarkers(validPlaces)
return
}
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
@@ -1833,29 +1830,63 @@ export function MapViewGL({
}
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
// planned-place markers so they don't cluster or get confused with them).
// Search results stay separate from the planned itinerary.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
const redraw = () => {
popupRef.current?.remove()
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat])) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
for (const group of groups) {
if (group.pois.length > 1) {
const el = document.createElement('button')
el.type = 'button'
el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;'
el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length }))
el.innerHTML = poiClusterMarkup(group.pois.length)
el.addEventListener('click', event => {
event.stopPropagation()
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => {
popupRef.current?.remove(); onPoiClickRef.current?.(poi)
})).addTo(map)
} else {
const bounds = new gl.LngLatBounds()
group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat]))
map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM })
}
})
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat))
continue
}
const poi = group.pois[0]
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
// Dragging a hit onto the drive puts it where it is passed, rather than where the
// corridor happened to project it. Only wired when someone is listening for it.
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
}
}
}, [pois, mapReady, glProvider])
redraw()
if (clusterLoosely) map.on('moveend', redraw)
return () => {
map.off('moveend', redraw)
poiCleanupRef.current.forEach(off => off())
poiCleanupRef.current = []
poiMarkersRef.current.forEach(pin => pin.remove())
poiMarkersRef.current = []
popupRef.current?.remove()
}
}, [pois, mapReady, glProvider, clusterLoosely, t])
// Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an
// error or missing tripId just means no plugin overlays, the core map is fine.
@@ -1887,11 +1918,30 @@ export function MapViewGL({
if (!map || !mapReady) return
routeViaMarkersRef.current.forEach(m => m.remove())
routeViaMarkersRef.current = []
const pauses: HTMLElement[] = []
const unbind: (() => void)[] = []
for (const v of routeVias) {
const el = document.createElement('div')
el.style.cssText = 'width:13px;height:13px;cursor:pointer;'
const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default
el.innerHTML = `<span style="display:block;width:13px;height:13px;border-radius:50%;background:#fff;border:3.5px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.35);box-sizing:border-box"></span>`
if (v.nightPause) {
el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;'
el.innerHTML = nightPauseMarker(v)
el.setAttribute('aria-label', v.label ?? '')
pauses.push(el)
}
if (v.hoverCard) {
const show = (ev: MouseEvent) => {
if (hoverDisabledRef.current || camMovingRef.current) return
hoverIdRef.current = null
setHoverPlace({ name: v.label, routeVia: v })
setHoverPos({ x: ev.clientX, y: ev.clientY })
}
el.addEventListener('mouseenter', show)
el.addEventListener('mousemove', show)
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
}
if (v.label || v.dwellSeconds != null) {
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
el.addEventListener('click', (ev) => {
@@ -1899,9 +1949,30 @@ export function MapViewGL({
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
})
}
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)
routeViaMarkersRef.current.push(pin)
if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, {
project: (lat, lng) => {
const point = map.project([lng, lat])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) },
lock: () => {
const enabled = map.dragPan.isEnabled()
map.dragPan.disable()
return () => { if (enabled) map.dragPan.enable() }
},
}))
}
}, [routeVias, mapReady, glProvider])
const updateZoom = () => {
const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM
for (const el of pauses) el.style.display = visible ? '' : 'none'
}
updateZoom()
map.on('zoomend', updateZoom)
return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) }
}, [routeVias, mapReady, glProvider, dayBoundaryControls])
// Reconcile plugin markers (imperative, same lifecycle as the POI markers).
useEffect(() => {
@@ -2230,7 +2301,10 @@ export function MapViewGL({
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
{!hoverDisabled && hoverPlace?.routeVia?.nightPause && hoverPos && !isMobile && routeVias.includes(hoverPlace.routeVia) && (
<NightPauseTooltip label={hoverPlace.name ?? ''} x={hoverPos.x} y={hoverPos.y} />
)}
{!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
<div data-testid="tooltip" style={{
position: 'fixed',
left: hoverPos.x + 14,
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { useMap } from 'react-leaflet'
import type L from 'leaflet'
import type { RouteVia } from '../../types'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
const map = useMap()
useEffect(() => {
const el = marker?.getElement()
if (!marker || !el || !controls) return
return bindDayBoundaryDrag(el, via, controls, {
project: (lat, lng) => {
const point = map.latLngToContainerPoint([lat, lng])
const rect = map.getContainer().getBoundingClientRect()
return { x: point.x + rect.left, y: point.y + rect.top }
},
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
lock: () => {
const enabled = map.dragging.enabled()
map.dragging.disable()
return () => { if (enabled) map.dragging.enable() }
},
})
}, [map, marker, via, controls])
return null
}
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import NightPauseTooltip from './NightPauseTooltip'
afterEach(() => vi.restoreAllMocks())
it('keeps the full pause description inside the viewport at its lower right edge', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
const label = 'Tagesende von Tag 12 um 18:00 Uhr'
render(<NightPauseTooltip label={label} x={window.innerWidth - 5} y={window.innerHeight - 5} />)
const tooltip = screen.getByRole('tooltip')
expect(tooltip).toHaveTextContent(label)
expect(parseFloat(tooltip.style.left) + 240).toBeLessThanOrEqual(window.innerWidth - 12)
expect(parseFloat(tooltip.style.top) + 56).toBeLessThanOrEqual(window.innerHeight - 12)
expect(tooltip.style.overflowWrap).toBe('anywhere')
})
it('keeps descriptions visible at the upper left edge', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 240, height: 56 } as DOMRect)
render(<NightPauseTooltip label="End of day 1 at 18:00" x={-50} y={-50} />)
expect(screen.getByRole('tooltip')).toHaveStyle({ left: '12px', top: '12px' })
})
@@ -0,0 +1,24 @@
import { useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function NightPauseTooltip({ label, x, y }: { label: string; x: number; y: number }) {
const card = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const place = () => {
const node = card.current!
const { width, height } = node.getBoundingClientRect()
const left = x + 14 + width > window.innerWidth - 12 ? x - width - 14 : x + 14
node.style.left = `${Math.max(12, Math.min(left, window.innerWidth - width - 12))}px`
node.style.top = `${Math.max(12, Math.min(y - 10, window.innerHeight - height - 12))}px`
}
place()
window.addEventListener('resize', place)
return () => window.removeEventListener('resize', place)
}, [label, x, y])
return createPortal(
<div ref={card} role="tooltip" data-testid="tooltip"
className="pointer-events-none fixed z-[var(--z-toast)] rounded-lg border border-edge-faint bg-surface-card px-3 py-2 font-semibold leading-relaxed text-content shadow-popover"
style={{ left: x + 14, top: y - 10, maxWidth: 'min(16rem, calc(100vw - 24px))', overflowWrap: 'anywhere', fontFamily: 'var(--font-system)', fontSize: 12 }}
>{label}</div>, document.body,
)
}
@@ -0,0 +1,48 @@
import { expect, it, vi } from 'vitest'
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
import type { RoadtripStop } from '../Roadtrip/useRoadtripRoutes'
function setup() {
const el = document.createElement('span')
document.body.append(el)
const save = vi.fn(async () => true), setPosition = vi.fn(), unlock = vi.fn()
const stop = (assignmentId: number, lng: number) => ({ assignmentId, lat: 0, lng }) as RoadtripStop
const dispose = bindDayBoundaryDrag(el, { lat: 0, lng: 0.5, tone: 'default', nightPause: { day: 1, atPlace: false, manual: true, position: 0.5 } }, {
path: [{ from: stop(1, 0), to: stop(2, 1), position: 0, line: [[0, 0], [0, 1]] }], move: save, hint: 'Move',
}, { project: (lat, lng) => ({ x: lng * 1000, y: lat * 1000 }), setPosition, lock: () => unlock })
const pointer = (node: EventTarget, type: string, x: number) => node.dispatchEvent(new MouseEvent(type, { clientX: x, clientY: 0, bubbles: true, cancelable: true, button: 0 }))
return { el, save, setPosition, unlock, dispose, pointer }
}
it('saves the snapped visit once on release and restores map dragging', async () => {
const s = setup()
s.pointer(s.el, 'pointerdown', 500)
s.pointer(document, 'pointermove', 990)
expect(s.save).not.toHaveBeenCalled()
s.pointer(document, 'pointerup', 990)
expect(s.save).toHaveBeenCalledWith(1, { day_number: 1, from_assignment_id: 2, to_assignment_id: null, fraction: 1 })
expect(s.unlock).toHaveBeenCalledOnce()
s.dispose()
})
it('escape cancels without saving and removes document handlers', () => {
const s = setup()
s.pointer(s.el, 'pointerdown', 500)
s.pointer(document, 'pointermove', 300)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
s.pointer(document, 'pointerup', 300)
expect(s.save).not.toHaveBeenCalled()
expect(s.setPosition).toHaveBeenLastCalledWith(0, 0.5)
expect(s.unlock).toHaveBeenCalledOnce()
s.dispose()
})
it('a right click clears the manual override and a click alone does not save', async () => {
const s = setup()
s.pointer(s.el, 'pointerdown', 500)
s.pointer(document, 'pointerup', 500)
expect(s.save).not.toHaveBeenCalled()
s.el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
expect(s.save).toHaveBeenCalledWith(1, null)
s.dispose()
})
@@ -0,0 +1,109 @@
import type { RoadtripDayBoundary } from '@trek/shared'
import type { RouteVia } from '../../types'
import { nearestDayBoundary, type DayBoundaryLeg, type DayBoundaryTarget, type ProjectPoint } from '../Roadtrip/dayBoundaryPath'
export interface DayBoundaryControls {
path: DayBoundaryLeg[]
move: (day: number, boundary: RoadtripDayBoundary | null) => Promise<boolean>
hint: string
}
interface DragMap {
project: ProjectPoint
setPosition: (lat: number, lng: number) => void
lock: () => () => void
}
export function bindDayBoundaryDrag(el: HTMLElement, via: RouteVia, controls: DayBoundaryControls, map: DragMap) {
const pause = via.nightPause!
const handle = el.querySelector<HTMLElement>('[data-night-pause]') ?? el
handle.style.cursor = 'grab'
handle.style.touchAction = 'none'
handle.tabIndex = 0
handle.setAttribute('role', 'button')
handle.setAttribute('aria-label', via.label ?? '')
handle.setAttribute('aria-description', controls.hint)
let origin: { x: number; y: number } | null = null
let target: DayBoundaryTarget | null = null
let unlock: (() => void) | null = null
let moved = false
let saving = false
let disposed = false
const restore = () => map.setPosition(via.lat, via.lng)
const detach = () => {
document.removeEventListener('pointermove', move)
document.removeEventListener('pointerup', up)
document.removeEventListener('pointercancel', cancel)
document.removeEventListener('keydown', escape)
unlock?.()
unlock = null
origin = null
handle.style.cursor = 'grab'
}
const cancel = () => { detach(); restore() }
const escape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); cancel() } }
const commit = async (boundary: RoadtripDayBoundary | null) => {
saving = true
handle.setAttribute('aria-busy', 'true')
try { if (!await controls.move(pause.day, boundary) && !disposed) restore() }
finally { saving = false; handle.removeAttribute('aria-busy') }
}
const move = (event: PointerEvent) => {
if (!origin) return
if (!moved && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) < 4) return
moved = true
event.preventDefault()
target = nearestDayBoundary(controls.path, pause.day, { x: event.clientX, y: event.clientY }, map.project, pause.minPosition, pause.maxPosition)
if (target) map.setPosition(target.lat, target.lng)
}
const up = () => {
const next = moved ? target : null
detach()
if (next) void commit(next.boundary)
else restore()
}
const down = (event: PointerEvent) => {
if (event.button !== 0 || saving) return
event.stopPropagation()
event.preventDefault()
origin = { x: event.clientX, y: event.clientY }
target = null
moved = false
unlock = map.lock()
handle.style.cursor = 'grabbing'
document.addEventListener('pointermove', move, { passive: false })
document.addEventListener('pointerup', up)
document.addEventListener('pointercancel', cancel)
document.addEventListener('keydown', escape)
}
const click = (event: Event) => {
if (moved || saving) { event.preventDefault(); event.stopImmediatePropagation() }
}
const reset = (event: Event) => {
event.preventDefault()
event.stopPropagation()
if (!saving && pause.manual) void commit(null)
}
const key = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') return reset(event)
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || saving) return
event.preventDefault()
event.stopPropagation()
const position = (pause.position ?? 0) + (event.key === 'ArrowLeft' ? -0.05 : 0.05)
const leg = controls.path.find(leg => position >= leg.position && position <= leg.position + 1)
if (!leg || position <= (pause.minPosition ?? -1) || position >= (pause.maxPosition ?? Infinity)) return
void commit({ day_number: pause.day, from_assignment_id: leg.from.assignmentId, to_assignment_id: leg.to.assignmentId, fraction: position - leg.position })
}
handle.addEventListener('pointerdown', down)
el.addEventListener('click', click, true)
handle.addEventListener('contextmenu', reset)
handle.addEventListener('keydown', key)
return () => {
disposed = true
detach()
handle.removeEventListener('pointerdown', down)
el.removeEventListener('click', click, true)
handle.removeEventListener('contextmenu', reset)
handle.removeEventListener('keydown', key)
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { RouteVia } from '../../types'
export interface MapHoverInfo {
name?: string | null
address?: string | null
category_name?: string | null
category_icon?: string | null
category_color?: string | null
routeVia?: RouteVia
}
@@ -0,0 +1,17 @@
import { createElement } from 'react'
import { Moon } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type { RouteVia } from '../../types'
const moon = renderIconMarkup(createElement(Moon, { size: 13, strokeWidth: 1.8, 'aria-hidden': true }))
export const NIGHT_PAUSE_MIN_ZOOM = 6
export function nightPauseMarker(via: RouteVia): string {
const pause = via.nightPause!
const position = pause.atPlace ? 'left:27px;top:-27px;' : 'left:0;top:-38px;transform:translateX(-50%);'
const stem = pause.atPlace
? 'left:20px;top:-7px;width:10px;height:1px;transform:rotate(-35deg);transform-origin:left center;'
: 'left:-.5px;top:-14px;width:1px;height:14px;'
return `<span aria-hidden="true" style="position:absolute;${stem}background:var(--text-muted);pointer-events:none;"></span><span data-night-pause="${pause.atPlace ? 'place' : 'route'}" style="position:absolute;${position}display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 8px;box-sizing:border-box;border:1px solid var(--border-primary);border-radius:9px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);font-family:var(--font-system);font-size:calc(11px * var(--fs-scale-caption,1));font-weight:600;line-height:1;white-space:nowrap;cursor:pointer;">${moon}<span>${escapeHtml(String(pause.day))}</span></span>`
}
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import type { Poi } from './poiCategories'
import { clusterPois, poiClusterList } from './poiClusters'
const station = (id: string, lng: number): Poi => ({
osm_id: id, name: id, lat: 48, lng, category: 'charging_station',
poi_type: 'charging_station', address: null, website: null, phone: null,
opening_hours: null, cuisine: null, source: 'openstreetmap',
})
const project = (poi: Poi) => ({ x: poi.lng, y: poi.lat })
describe('station clusters', () => {
it('groups across grid edges, counts unique stations and preserves the originals', () => {
const a = station('a', 47), b = station('b', 49), c = station('c', 150)
const groups = clusterPois([c, b, a, a], project)
expect(groups.map(group => group.pois.length)).toEqual([2, 1])
expect(groups[0]).toMatchObject({ lat: 48, lng: 48 })
expect(groups[0].pois[0]).toBe(a)
expect(a.lng).toBe(47)
expect(clusterPois([a, b, c], project)).toEqual(groups)
})
it('separates stations as their projected distance grows on zoom', () => {
const pois = [station('a', 1), station('b', 2)]
expect(clusterPois(pois, project)).toHaveLength(1)
expect(clusterPois(pois, poi => ({ x: poi.lng * 100, y: 0 }))).toHaveLength(2)
})
it('keeps the center near the date line', () => {
const groups = clusterPois([station('a', 179), station('b', -179)], () => ({ x: 0, y: 0 }))
expect(groups[0].lng).toBe(180)
})
it('offers coincident stations individually without interpreting their names as HTML', () => {
const poi = { ...station('a', 1), name: '<img src=x>' }
const select = vi.fn()
const list = poiClusterList([poi], select)
expect(list.querySelector('img')).toBeNull()
expect(list.textContent).toBe(poi.name)
list.querySelector('button')!.click()
expect(select).toHaveBeenCalledWith(poi)
})
})
+55
View File
@@ -0,0 +1,55 @@
import type { Poi } from './poiCategories'
export const POI_CLUSTER_RADIUS = 48
export const POI_CLUSTER_DETAIL_ZOOM = 18
export interface PoiCluster { pois: Poi[]; lat: number; lng: number }
export function clusterPois(pois: Poi[], project: (poi: Poi) => { x: number; y: number }): PoiCluster[] {
const groups: (PoiCluster & { x: number; y: number })[] = []
const cells = new Map<string, number[]>()
const unique = new Map(pois.map(poi => [poi.osm_id, poi]))
for (const poi of [...unique.values()].sort((a, b) => a.osm_id.localeCompare(b.osm_id))) {
const point = project(poi)
const cx = Math.floor(point.x / POI_CLUSTER_RADIUS), cy = Math.floor(point.y / POI_CLUSTER_RADIUS)
let closest: typeof groups[number] | undefined
let distance = POI_CLUSTER_RADIUS
for (let x = cx - 1; x <= cx + 1; x++) for (let y = cy - 1; y <= cy + 1; y++) {
for (const index of cells.get(`${x},${y}`) ?? []) {
const group = groups[index]
const gap = Math.hypot(point.x - group.x, point.y - group.y)
if (gap < distance) { closest = group; distance = gap }
}
}
if (closest) closest.pois.push(poi)
else {
const key = `${cx},${cy}`
const indices = cells.get(key) ?? []
indices.push(groups.length)
cells.set(key, indices)
groups.push({ pois: [poi], lat: poi.lat, lng: poi.lng, ...point })
}
}
return groups.map(group => ({
pois: group.pois,
lat: group.pois.reduce((sum, poi) => sum + poi.lat, 0) / group.pois.length,
lng: group.lng + group.pois.reduce((sum, poi) => sum + ((poi.lng - group.lng + 540) % 360 - 180), 0) / group.pois.length,
}))
}
export function poiClusterMarkup(count: number): string {
return `<span data-poi-cluster="${count}" style="box-sizing:border-box;display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:3px solid var(--bg-card);background:var(--accent);color:var(--accent-text);box-shadow:var(--shadow-md);font-family:var(--font-system);font-size:calc(13px * var(--fs-scale-caption,1));font-weight:600;cursor:pointer;">${count}</span>`
}
export function poiClusterList(pois: Poi[], select: (poi: Poi) => void): HTMLElement {
const list = document.createElement('div')
list.className = 'flex max-h-64 flex-col gap-1 overflow-y-auto p-1'
for (const poi of pois) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'rounded-lg px-3 py-2 text-start text-caption font-medium text-content hover:bg-surface-hover focus-visible:bg-surface-hover'
button.textContent = poi.name
button.addEventListener('click', event => { event.stopPropagation(); select(poi) })
list.append(button)
}
return list
}