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 = '
' +const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + +/** Hover-popup card for a planned place: optional photo, name, category row, address. */ +export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string { + const img = isDisplayablePhoto(photoUrl) + ? `
` + : '' + const category = + place.category_name && place.category_icon + ? `
${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}${esc(place.category_name)}
` + : '' + const address = place.address ? `
${esc(place.address)}
` : '' + return `${CARD_OPEN}${img}
${esc(place.name)}
${category}${address}
` +} + +/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */ +export function buildPoiPopupHtml(poi: Poi): string { + const cat = POI_CATEGORY_BY_KEY[poi.category] + const color = cat?.color || '#6b7280' + const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : '' + const head = `
${icon}${esc(poi.name)}
` + const address = poi.address ? `
${esc(poi.address)}
` : '' + return `${CARD_OPEN}${head}${address}` +} diff --git a/client/src/components/Map/usePoiExplore.ts b/client/src/components/Map/usePoiExplore.ts index d3c21a71..793d3c0f 100644 --- a/client/src/components/Map/usePoiExplore.ts +++ b/client/src/components/Map/usePoiExplore.ts @@ -4,6 +4,12 @@ import type { Poi } from './poiCategories' export interface Bbox { south: number; west: number; north: number; east: number } +// A request we cancelled on purpose (newer search superseded it) — not a failure. +function isAbortError(err: unknown): boolean { + const e = err as { name?: string; code?: string } | null + return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError' +} + /** * State for the map POI "explore" pill. Toggling a category fetches its OSM POIs * for the current viewport; panning/zooming does NOT auto-refetch — it just marks @@ -15,12 +21,18 @@ export function usePoiExplore() { const [byCat, setByCat] = useState>({}) const [loadingKeys, setLoadingKeys] = useState>(() => new Set()) const [moved, setMoved] = useState(false) + // Categories whose last fetch genuinely failed (all Overpass mirrors down), so + // the pill can offer a retry instead of looking like "no places here". + const [errorKeys, setErrorKeys] = useState>(() => new Set()) const bboxRef = useRef(null) // activeRef always mirrors the latest active set so async callbacks (fetch // completions) can check whether a category is still wanted. const activeRef = useRef(active) activeRef.current = active + // One in-flight AbortController per category, so re-toggling / re-searching + // cancels the previous (possibly slow) Overpass request instead of racing it. + const abortRef = useRef>({}) const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => { const next = new Set(prev) @@ -28,19 +40,41 @@ export function usePoiExplore() { return next }), []) + const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => { + if (on === prev.has(key)) return prev + const next = new Set(prev) + if (on) next.add(key); else next.delete(key) + return next + }), []) + const fetchCat = useCallback(async (key: string, bbox: Bbox) => { + abortRef.current[key]?.abort() + const ctrl = new AbortController() + abortRef.current[key] = ctrl setLoading(key, true) + setError(key, false) try { - const res = await mapsApi.pois(key, bbox) + const res = await mapsApi.pois(key, bbox, ctrl.signal) // Drop the result if the user toggled this category off while the (slow) // Overpass request was in flight — otherwise stale results re-appear. setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev)) - } catch { + } catch (err) { + // A superseded request was aborted on purpose — leave its state untouched + // so the newer request owns the spinner and results. + if (isAbortError(err)) return + // A real failure (every Overpass mirror down/timed out): surface it instead + // of a silent empty so the user can retry rather than assume "no places". setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev)) + if (activeRef.current.has(key)) setError(key, true) } finally { - setLoading(key, false) + // Only the latest controller for this key clears the spinner; a superseded + // one must not, or it would hide the newer request's in-flight state. + if (abortRef.current[key] === ctrl) { + setLoading(key, false) + delete abortRef.current[key] + } } - }, [setLoading]) + }, [setLoading, setError]) const onViewportChange = useCallback((bbox: Bbox) => { bboxRef.current = bbox @@ -53,6 +87,11 @@ export function usePoiExplore() { const toggle = useCallback((key: string) => { const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1 setMoved(false) + setErrorKeys(new Set()) + // Switching to another category (or turning off) — cancel any in-flight + // fetches so their results can't land after the selection changed. + Object.values(abortRef.current).forEach(c => c.abort()) + abortRef.current = {} if (isOnlyActive) { setActive(new Set()) setByCat({}) @@ -72,5 +111,5 @@ export function usePoiExplore() { const pois = useMemo(() => Object.values(byCat).flat(), [byCat]) - return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange } + return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange } } diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index ccc95d9a..77ebf78c 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${cat ? `${escHtml(cat.name)}` : ''} ${place.address ? `
${svgPin}${escHtml(place.address)}
` : ''} + ${(place.lat != null && place.lng != null) ? `
${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}
` : ''} ${place.description ? `
${escHtml(place.description)}
` : ''} ${chips ? `
${chips}
` : ''} ${place.notes ? `
${escHtml(place.notes)}
` : ''} diff --git a/client/src/components/Planner/DayPlanSidebar.constants.ts b/client/src/components/Planner/DayPlanSidebar.constants.ts index 1c1f2cee..bfbc6307 100644 --- a/client/src/components/Planner/DayPlanSidebar.constants.ts +++ b/client/src/components/Planner/DayPlanSidebar.constants.ts @@ -2,6 +2,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route, + Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift, } from 'lucide-react' export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText } @@ -27,6 +28,18 @@ export const NOTE_ICONS = [ { id: 'AlertTriangle', Icon: AlertTriangle }, { id: 'ShoppingBag', Icon: ShoppingBag }, { id: 'Bookmark', Icon: Bookmark }, + { id: 'Utensils', Icon: Utensils }, + { id: 'Wine', Icon: Wine }, + { id: 'ParkingSquare', Icon: ParkingSquare }, + { id: 'Fuel', Icon: Fuel }, + { id: 'Footprints', Icon: Footprints }, + { id: 'Mountain', Icon: Mountain }, + { id: 'Waves', Icon: Waves }, + { id: 'Sun', Icon: Sun }, + { id: 'Umbrella', Icon: Umbrella }, + { id: 'Music', Icon: Music }, + { id: 'Landmark', Icon: Landmark }, + { id: 'Gift', Icon: Gift }, ] const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon])) export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText } diff --git a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx index 87fc82e6..21dfd276 100644 --- a/client/src/components/Planner/DayPlanSidebarNoteModal.tsx +++ b/client/src/components/Planner/DayPlanSidebarNoteModal.tsx @@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance />