diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 5fe940ea..f74fa9d8 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -559,8 +559,10 @@ export const mapsApi = {
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
+ // Overpass can be slow on a fresh (uncached) area, so this call gets a longer
+ // timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
- apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
+ apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
diff --git a/client/src/components/Map/MapCompassPill.tsx b/client/src/components/Map/MapCompassPill.tsx
new file mode 100644
index 00000000..65dc3289
--- /dev/null
+++ b/client/src/components/Map/MapCompassPill.tsx
@@ -0,0 +1,48 @@
+import { useEffect, useState } from 'react'
+import { Navigation } from 'lucide-react'
+import type mapboxgl from 'mapbox-gl'
+
+/**
+ * Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
+ * pitched, so this shows the current bearing (the arrow points to north) and snaps
+ * the camera back to north + flat on click. Rendered next to the POI "explore" pill
+ * (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
+ * so its height and transparency match the POI pill exactly.
+ */
+export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
+ const [bearing, setBearing] = useState(() => map.getBearing())
+
+ useEffect(() => {
+ const update = () => setBearing(map.getBearing())
+ update()
+ map.on('rotate', update)
+ return () => { map.off('rotate', update) }
+ }, [map])
+
+ return (
+
+
+
+ )
+}
diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx
index 5a305a70..5cd83aa6 100644
--- a/client/src/components/Map/MapViewGL.test.tsx
+++ b/client/src/components/Map/MapViewGL.test.tsx
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
+ Popup: vi.fn(() => ({
+ setLngLat: vi.fn().mockReturnThis(),
+ setHTML: vi.fn().mockReturnThis(),
+ addTo: vi.fn().mockReturnThis(),
+ remove: vi.fn(),
+ })),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx
index ac7b593a..8c6ece11 100644
--- a/client/src/components/Map/MapViewGL.tsx
+++ b/client/src/components/Map/MapViewGL.tsx
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
+import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -53,6 +54,7 @@ interface Props {
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
+ onMapReady?: (map: mapboxgl.Map | null) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -167,6 +169,7 @@ export function MapViewGL({
pois = [],
onPoiClick,
onViewportChange,
+ onMapReady,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -186,10 +189,15 @@ export function MapViewGL({
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef([])
+ // Single reusable hover popup (name/category/address card) shared by planned
+ // places and POI markers — mirrors the Leaflet map's hover tooltip.
+ const popupRef = useRef(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
+ const onMapReadyRef = useRef(onMapReady)
+ onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -212,6 +220,16 @@ export function MapViewGL({
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
+ popupRef.current = new mapboxgl.Popup({
+ closeButton: false,
+ closeOnClick: false,
+ offset: 18,
+ maxWidth: '240px',
+ className: 'trek-map-popup',
+ })
+ // Hand the map out so the trip planner can render its own compass pill next to
+ // the POI pill (a custom round control instead of Mapbox's default top-right one).
+ onMapReadyRef.current?.(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map
@@ -357,6 +375,8 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
+ if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
+ onMapReadyRef.current?.(null)
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
@@ -430,6 +450,10 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
+ // Markers are about to be rebuilt; drop any open hover popup first. A marker
+ // recreated under the pointer (e.g. when its photo streams in) never fires
+ // mouseleave, which would otherwise leave the popup orphaned on the map.
+ popupRef.current?.remove()
const ids = new Set(places.map(p => p.id))
markersRef.current.forEach((marker, id) => {
@@ -450,6 +474,12 @@ export function MapViewGL({
ev.stopPropagation()
onClickRefs.current.marker?.(place.id)
})
+ el.addEventListener('mouseenter', () => {
+ popupRef.current?.setLngLat([place.lng, place.lat])
+ .setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
+ .addTo(map)
+ })
+ el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id)
@@ -471,11 +501,15 @@ export function MapViewGL({
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 = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
- el.title = poi.name
+ 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) })
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
diff --git a/client/src/components/Map/PoiCategoryPill.tsx b/client/src/components/Map/PoiCategoryPill.tsx
index f68fbd81..2eaf4c0e 100644
--- a/client/src/components/Map/PoiCategoryPill.tsx
+++ b/client/src/components/Map/PoiCategoryPill.tsx
@@ -1,4 +1,4 @@
-import { RotateCw } from 'lucide-react'
+import { RotateCw, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { POI_CATEGORIES } from './poiCategories'
@@ -7,6 +7,8 @@ interface Props {
active: Set
onToggle: (key: string) => void
loadingKeys?: Set
+ /** categories whose last fetch failed → show a retry affordance */
+ errorKeys?: Set
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
@@ -15,8 +17,9 @@ interface Props {
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
-export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
+export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
+ const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
aria-label={t(cat.labelKey)}
className={on ? '' : 'text-content-muted'}
style={{
+ position: 'relative',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
) : (
)}
+ {on && !loading && errorKeys?.has(cat.key) && (
+
+ )}
)
})}
- {moved && active.size > 0 && (
+ {(moved || anyError) && active.size > 0 && (
)}
diff --git a/client/src/components/Map/placePopup.ts b/client/src/components/Map/placePopup.ts
new file mode 100644
index 00000000..ae79577e
--- /dev/null
+++ b/client/src/components/Map/placePopup.ts
@@ -0,0 +1,68 @@
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
+import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
+import type { Place } from '../../types'
+
+// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
+// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
+// no equivalent, so these produce the same card as an HTML string for a
+// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
+
+type PlaceWithCategory = Place & {
+ category_color?: string | null
+ category_icon?: string | null
+ category_name?: string | null
+}
+
+function esc(s: string | null | undefined): string {
+ if (!s) return ''
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+// Render a lucide category icon to an inline SVG string in the given colour.
+function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
+ const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
+ try {
+ return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
+ } catch {
+ return ''
+ }
+}
+
+// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
+// into an — everything else is a fetch seed, not a displayable URL.
+function isDisplayablePhoto(url: string | null | undefined): url is string {
+ return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
+}
+
+const CARD_OPEN = '