mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(roadtrip): add daily scheduling and configurable day endings
This commit is contained in:
@@ -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 })
|
||||
} }} />
|
||||
})}</>
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user