From 1378c95078c545124888cf19bf64bcb572cf8e44 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:42:16 +0200 Subject: [PATCH] Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(maps): add an OSM POI search endpoint (category within a viewport) New /api/maps/pois queries OpenStreetMap via Overpass for places of a category (restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design — it never calls Google, even when a Google key is configured. * feat(map): explore nearby places on the trip map (OSM category pill) A floating, icon-only pill over the planner map lets you toggle a POI category and see those OpenStreetMap places in the current view; clicking a marker opens the add-place form pre-filled (name, address, website, phone). Single-select with a 'search this area' action after the map moves. Renders on both the Leaflet and Mapbox maps, and can be turned off in settings (discussion #841). * fix(planner): anchor timed places when optimising and route transports by location - The day optimiser no longer reshuffles places that have a set time — they stay anchored to their time, like locked places. - The route now uses a transport's departure/arrival location as a waypoint when it has one (e.g. a flight's airport), instead of breaking the route at every booking; transports without a location are ignored for routing but still show their leg's distance/duration under the booking. * feat(admin): instance-wide Mapbox defaults in default user settings Admins can set a shared Mapbox token (plus style, 3D and quality) as instance defaults, so the whole instance can use Mapbox without each user pasting their own key. Users without their own value inherit it via the existing admin-defaults merge; the shared token is stored encrypted (discussion #920). --- client/src/api/client.ts | 3 + .../Admin/DefaultUserSettingsTab.tsx | 108 ++++++++++++++++++ client/src/components/Map/MapView.tsx | 58 +++++++++- client/src/components/Map/MapViewGL.tsx | 47 ++++++++ client/src/components/Map/PoiCategoryPill.tsx | 87 ++++++++++++++ client/src/components/Map/poiCategories.ts | 43 +++++++ client/src/components/Map/usePoiExplore.ts | 76 ++++++++++++ .../src/components/Planner/DayPlanSidebar.tsx | 27 ++++- .../src/components/Planner/PlaceFormModal.tsx | 5 +- .../Settings/DisplaySettingsTab.tsx | 31 +++++ client/src/hooks/useRouteCalculation.ts | 33 ++++-- client/src/pages/TripPlannerPage.tsx | 15 ++- .../src/pages/tripPlanner/useTripPlanner.ts | 22 +++- client/src/store/settingsStore.ts | 1 + client/src/types.ts | 1 + server/src/nest/maps/maps.controller.ts | 21 ++++ server/src/nest/maps/maps.service.ts | 6 + server/src/services/mapsService.ts | 108 ++++++++++++++++++ server/src/services/settingsService.ts | 23 +++- shared/src/i18n/ar/admin.ts | 10 ++ shared/src/i18n/ar/map.ts | 9 ++ shared/src/i18n/ar/settings.ts | 2 + shared/src/i18n/br/admin.ts | 10 ++ shared/src/i18n/br/map.ts | 9 ++ shared/src/i18n/br/settings.ts | 2 + shared/src/i18n/cs/admin.ts | 10 ++ shared/src/i18n/cs/map.ts | 9 ++ shared/src/i18n/cs/settings.ts | 2 + shared/src/i18n/de/admin.ts | 10 ++ shared/src/i18n/de/map.ts | 9 ++ shared/src/i18n/de/settings.ts | 2 + shared/src/i18n/en/admin.ts | 12 ++ shared/src/i18n/en/map.ts | 9 ++ shared/src/i18n/en/settings.ts | 3 + shared/src/i18n/es/admin.ts | 10 ++ shared/src/i18n/es/map.ts | 9 ++ shared/src/i18n/es/settings.ts | 2 + shared/src/i18n/fr/admin.ts | 10 ++ shared/src/i18n/fr/map.ts | 9 ++ shared/src/i18n/fr/settings.ts | 2 + shared/src/i18n/gr/admin.ts | 10 ++ shared/src/i18n/gr/map.ts | 9 ++ shared/src/i18n/gr/settings.ts | 2 + shared/src/i18n/hu/admin.ts | 10 ++ shared/src/i18n/hu/map.ts | 9 ++ shared/src/i18n/hu/settings.ts | 2 + shared/src/i18n/id/admin.ts | 10 ++ shared/src/i18n/id/map.ts | 9 ++ shared/src/i18n/id/settings.ts | 2 + shared/src/i18n/it/admin.ts | 10 ++ shared/src/i18n/it/map.ts | 9 ++ shared/src/i18n/it/settings.ts | 2 + shared/src/i18n/ja/admin.ts | 10 ++ shared/src/i18n/ja/map.ts | 9 ++ shared/src/i18n/ja/settings.ts | 2 + shared/src/i18n/ko/admin.ts | 10 ++ shared/src/i18n/ko/map.ts | 9 ++ shared/src/i18n/ko/settings.ts | 2 + shared/src/i18n/nl/admin.ts | 10 ++ shared/src/i18n/nl/map.ts | 9 ++ shared/src/i18n/nl/settings.ts | 2 + shared/src/i18n/pl/admin.ts | 10 ++ shared/src/i18n/pl/map.ts | 9 ++ shared/src/i18n/pl/settings.ts | 2 + shared/src/i18n/ru/admin.ts | 10 ++ shared/src/i18n/ru/map.ts | 9 ++ shared/src/i18n/ru/settings.ts | 2 + shared/src/i18n/tr/admin.ts | 10 ++ shared/src/i18n/tr/map.ts | 9 ++ shared/src/i18n/tr/settings.ts | 2 + shared/src/i18n/uk/admin.ts | 10 ++ shared/src/i18n/uk/map.ts | 9 ++ shared/src/i18n/uk/settings.ts | 2 + shared/src/i18n/zh-TW/admin.ts | 10 ++ shared/src/i18n/zh-TW/map.ts | 9 ++ shared/src/i18n/zh-TW/settings.ts | 2 + shared/src/i18n/zh/admin.ts | 10 ++ shared/src/i18n/zh/map.ts | 9 ++ shared/src/i18n/zh/settings.ts | 2 + 79 files changed, 1118 insertions(+), 20 deletions(-) create mode 100644 client/src/components/Map/PoiCategoryPill.tsx create mode 100644 client/src/components/Map/poiCategories.ts create mode 100644 client/src/components/Map/usePoiExplore.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index c0df237f..f2840ac9 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -557,6 +557,9 @@ export const mapsApi = { placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')), 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. + 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 }), } export const airportsApi = { diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index e77af8df..5baa85fa 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -22,8 +22,22 @@ type Defaults = { time_format?: string blur_booking_codes?: boolean map_tile_url?: string + map_provider?: string + mapbox_access_token?: string + mapbox_style?: string + mapbox_3d_enabled?: boolean + mapbox_quality_mode?: boolean } +const MAPBOX_STYLE_PRESETS = [ + { name: 'Standard', url: 'mapbox://styles/mapbox/standard' }, + { name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' }, + { name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' }, + { name: 'Light', url: 'mapbox://styles/mapbox/light-v11' }, + { name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' }, + { name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' }, +] + function OptionRow({ label, hint, @@ -77,11 +91,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement { const [defaults, setDefaults] = useState({}) const [loaded, setLoaded] = useState(false) const [mapTileUrl, setMapTileUrl] = useState('') + const [mapboxToken, setMapboxToken] = useState('') + const [mapboxStyle, setMapboxStyle] = useState('') useEffect(() => { adminApi.getDefaultUserSettings().then((data: Defaults) => { setDefaults(data) setMapTileUrl(data.map_tile_url || '') + setMapboxToken(data.mapbox_access_token || '') + setMapboxStyle(data.mapbox_style || '') setLoaded(true) }).catch(() => setLoaded(true)) }, []) @@ -101,6 +119,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement { const updated = await adminApi.updateDefaultUserSettings({ [key]: null }) setDefaults(updated) if (key === 'map_tile_url') setMapTileUrl('') + if (key === 'mapbox_access_token') setMapboxToken('') + if (key === 'mapbox_style') setMapboxStyle('') toast.success(t('admin.defaultSettings.reset')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) @@ -267,6 +287,94 @@ export default function DefaultUserSettingsTab(): React.ReactElement { })} + + {/* ── Map provider / instance-wide Mapbox ───────────────────────── */} +
+ {t('admin.defaultSettings.mapProvider')} } + hint={t('admin.defaultSettings.mapProviderHint')} + > + {([ + { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') }, + { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') }, + ] as const).map(opt => ( + save({ map_provider: opt.value })} + > + {opt.label} + + ))} + + + {defaults.map_provider === 'mapbox-gl' && ( +
+
+ + ) => setMapboxToken(e.target.value)} + onBlur={() => save({ mapbox_access_token: mapboxToken })} + placeholder="pk.eyJ…" + spellCheck={false} + autoComplete="off" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

{t('admin.defaultSettings.mapboxTokenHint')}

+
+ +
+ + { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }} + placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')} + options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))} + size="sm" + style={{ marginBottom: 8 }} + /> + ) => setMapboxStyle(e.target.value)} + onBlur={() => save({ mapbox_style: mapboxStyle })} + placeholder="mapbox://styles/mapbox/standard" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+ + {t('admin.defaultSettings.mapbox3d')} }> + {([ + { value: true, label: t('settings.on') || 'On' }, + { value: false, label: t('settings.off') || 'Off' }, + ] as const).map(opt => ( + save({ mapbox_3d_enabled: opt.value })}> + {opt.label} + + ))} + + + {t('admin.defaultSettings.mapboxQuality')} }> + {([ + { value: true, label: t('settings.on') || 'On' }, + { value: false, label: t('settings.off') || 'Off' }, + ] as const).map(opt => ( + save({ mapbox_quality_mode: opt.value })}> + {opt.label} + + ))} + +
+ )} +
) } diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index eeb0b10e..607df1f1 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react' import DOM from 'react-dom' import { renderToStaticMarkup } from 'react-dom/server' -import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet' +import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' import 'leaflet.markercluster/dist/MarkerCluster.css' @@ -10,6 +10,7 @@ import { mapsApi } from '../../api/client' import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons' import ReservationOverlay from './ReservationOverlay' import type { Reservation } from '../../types' +import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' function categoryIconSvg(iconName: string | null | undefined, size: number): string { const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] @@ -118,6 +119,44 @@ function createPlaceIcon(place, orderNumbers, isSelected) { return fallbackIcon } +// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle +// markers of planned places; the colour matches its pill category. +const poiIconCache = new Map() +function createPoiIcon(category: string) { + const cached = poiIconCache.get(category) + if (cached) return cached + const cat = POI_CATEGORY_BY_KEY[category] + const color = cat?.color || '#6b7280' + const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : '' + const icon = L.divIcon({ + className: '', + html: `
${svg}
`, + iconSize: [26, 26], + iconAnchor: [13, 13], + tooltipAnchor: [0, -14], + }) + poiIconCache.set(category, icon) + return icon +} + +// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch +// OSM places for the visible area. +function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) { + const map = useMap() + useEffect(() => { + if (!onViewportChange) return + const emit = () => { + const b = map.getBounds() + onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }) + } + map.whenReady(emit) // ensure the first bbox is captured once the map is laid out + map.on('moveend', emit) + map.on('zoomend', emit) + return () => { map.off('moveend', emit); map.off('zoomend', emit) } + }, [map, onViewportChange]) + return null +} + interface SelectionControllerProps { places: Place[] selectedPlaceId: number | null @@ -367,7 +406,21 @@ export const MapView = memo(function MapView({ showReservationStats = false, visibleConnectionIds = [] as number[], onReservationClick, + pois = [] as Poi[], + onPoiClick, + onViewportChange, }: any) { + const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( + onPoiClick?.(poi) }} + > + {poi.name} + + )), [pois, onPoiClick]) const visibleReservations = useMemo(() => { if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [] const set = new Set(visibleConnectionIds) @@ -543,6 +596,7 @@ export const MapView = memo(function MapView({ + + + {poiMarkers} {isMobile && void + pois?: Poi[] + onPoiClick?: (poi: Poi) => void + onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void } function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { @@ -128,6 +132,17 @@ function createMarkerElement(place: Place & { category_color?: string; category_ return wrap } +// Small coloured pin for an OSM "explore" POI (matches the pill category colour). +function createPoiMarkerElement(category: string): HTMLDivElement { + const cat = POI_CATEGORY_BY_KEY[category] + const color = cat?.color || '#6b7280' + const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : '' + const el = document.createElement('div') + el.style.cssText = 'width:26px;height:26px;cursor:pointer;' + el.innerHTML = `
${svg}
` + return el +} + export function MapViewGL({ places = [], dayPlaces = [], @@ -149,6 +164,9 @@ export function MapViewGL({ visibleConnectionIds = [], showReservationStats = false, onReservationClick, + pois = [], + onPoiClick, + onViewportChange, }: Props) { const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') @@ -167,6 +185,11 @@ export function MapViewGL({ // options without forcing a full overlay rebuild on every prop change. const onReservationClickRef = useRef(onReservationClick) onReservationClickRef.current = onReservationClick + const poiMarkersRef = useRef([]) + const onPoiClickRef = useRef(onPoiClick) + onPoiClickRef.current = onPoiClick + const onViewportChangeRef = useRef(onViewportChange) + onViewportChangeRef.current = onViewportChange 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 @@ -260,6 +283,14 @@ export function MapViewGL({ if (t.closest('.mapboxgl-marker')) return // markers handle their own click onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) }) + // Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore + // pill can fetch OSM places for the visible area. + const emitViewport = () => { + const b = map.getBounds() + onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }) + } + map.on('moveend', emitViewport) + map.once('idle', emitViewport) // In the mapbox-gl map the right mouse button is reserved for the // built-in rotate/pitch gesture, so we bind the "add place" action // to the middle mouse button (button === 1) instead. @@ -435,6 +466,22 @@ export function MapViewGL({ }) }, [places, selectedPlaceId, dayOrderMap, photoUrls]) + // Reconcile OSM "explore" POI markers (imperative, kept separate from the + // planned-place markers so they don't cluster or get confused with them). + useEffect(() => { + const map = mapRef.current + if (!map || !mapReady) return + 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('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) + } + }, [pois, mapReady]) + // Update route geojson useEffect(() => { const map = mapRef.current diff --git a/client/src/components/Map/PoiCategoryPill.tsx b/client/src/components/Map/PoiCategoryPill.tsx new file mode 100644 index 00000000..f68fbd81 --- /dev/null +++ b/client/src/components/Map/PoiCategoryPill.tsx @@ -0,0 +1,87 @@ +import { RotateCw } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { Tooltip } from '../shared/Tooltip' +import { POI_CATEGORIES } from './poiCategories' + +interface Props { + active: Set + onToggle: (key: string) => void + loadingKeys?: Set + /** true when the map moved since the last search → offer "search this area" */ + moved?: boolean + onSearchArea?: () => void +} + +// 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) { + const { t } = useTranslation() + + const frosted: React.CSSProperties = { + background: 'var(--sidebar-bg)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))', + } + + return ( +
+
+ {POI_CATEGORIES.map(cat => { + const on = active.has(cat.key) + const loading = loadingKeys?.has(cat.key) + return ( + + + + ) + })} +
+ + {moved && active.size > 0 && ( + + )} +
+ ) +} diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts new file mode 100644 index 00000000..3dfdd35d --- /dev/null +++ b/client/src/components/Map/poiCategories.ts @@ -0,0 +1,43 @@ +import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react' + +// The POI categories shown in the map "explore" pill. The `key` is the contract +// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping +// lives there; label/icon/colour live here. `color` doubles as the active-pill +// fill AND the marker colour, so the pill and the map agree visually. +export interface PoiCategory { + key: string + labelKey: string + Icon: LucideIcon + color: string +} + +export const POI_CATEGORIES: PoiCategory[] = [ + { key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' }, + { key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' }, + { key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' }, + { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' }, + { key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' }, + { key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' }, + { key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' }, + { key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' }, +] + +export const POI_CATEGORY_BY_KEY: Record = Object.fromEntries( + POI_CATEGORIES.map(c => [c.key, c]), +) + +// One POI result from /api/maps/pois (mirror of the server's OverpassPoi). +export interface Poi { + osm_id: string + name: string + lat: number + lng: number + category: string + poi_type: string + address: string | null + website: string | null + phone: string | null + opening_hours: string | null + cuisine: string | null + source: 'openstreetmap' +} diff --git a/client/src/components/Map/usePoiExplore.ts b/client/src/components/Map/usePoiExplore.ts new file mode 100644 index 00000000..d3c21a71 --- /dev/null +++ b/client/src/components/Map/usePoiExplore.ts @@ -0,0 +1,76 @@ +import { useState, useRef, useCallback, useMemo } from 'react' +import { mapsApi } from '../../api/client' +import type { Poi } from './poiCategories' + +export interface Bbox { south: number; west: number; north: number; east: number } + +/** + * 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 + * the results stale (`moved`) so the pill can offer "search this area". This keeps + * Overpass load (and visual churn) down. + */ +export function usePoiExplore() { + const [active, setActive] = useState>(() => new Set()) + const [byCat, setByCat] = useState>({}) + const [loadingKeys, setLoadingKeys] = useState>(() => new Set()) + const [moved, setMoved] = useState(false) + + 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 + + const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(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) => { + setLoading(key, true) + try { + const res = await mapsApi.pois(key, bbox) + // 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 { + setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev)) + } finally { + setLoading(key, false) + } + }, [setLoading]) + + const onViewportChange = useCallback((bbox: Bbox) => { + bboxRef.current = bbox + if (activeRef.current.size > 0) setMoved(true) + }, []) + + // Single-select: clicking a category switches to it (dropping the previous one + // and its markers immediately) and fetches it for the current viewport; clicking + // the already-active category turns it off. + const toggle = useCallback((key: string) => { + const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1 + setMoved(false) + if (isOnlyActive) { + setActive(new Set()) + setByCat({}) + return + } + setActive(new Set([key])) + setByCat({}) + if (bboxRef.current) fetchCat(key, bboxRef.current) + }, [fetchCat]) + + const searchArea = useCallback(() => { + const bbox = bboxRef.current + if (!bbox) return + setMoved(false) + activeRef.current.forEach(key => fetchCat(key, bbox)) + }, [fetchCat]) + + const pois = useMemo(() => Object.values(byCat).flat(), [byCat]) + + return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange } +} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 8f04e638..ec6d09ea 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -376,14 +376,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { if (legsAbortRef.current) legsAbortRef.current.abort() if (!selectedDayId || !routeShown) { setRouteLegs({}); return } const merged = mergedItemsMap[selectedDayId] || [] + const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => { + const e = (r.endpoints || []).find((x: any) => x.role === role) + return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null + } const runs: { id: number; lat: number; lng: number }[][] = [] let cur: { id: number; lat: number; lng: number }[] = [] for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng }) } else if (it.type === 'transport') { - if (cur.length >= 2) runs.push(cur) - cur = [] + const r = it.data + const from = epLoc(r, 'from'), to = epLoc(r, 'to') + if (from || to) { + // Located transport: route to its departure point, break the run (the + // flight/train itself isn't driven), and let its arrival start the next. + if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng }) + if (cur.length >= 2) runs.push(cur) + cur = [] + if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng }) + } else if (cur.length > 0) { + // No location: ignore for routing, but attribute the through-leg to the + // booking so its distance/duration shows under it (purely cosmetic). + cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id } + } } } if (cur.length >= 2) runs.push(cur) @@ -731,11 +747,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const prevIds = da.map(a => a.id) - // Separate locked (stay at their index) and unlocked assignments + // Separate fixed (stay at their index) and movable assignments. A place is + // fixed if it's locked OR has a set time — timed places are anchored by their + // time, so the optimizer must not reshuffle them. const locked = new Map() // index -> assignment const unlocked = [] da.forEach((a, i) => { - if (lockedIds.has(a.id)) locked.set(i, a) + if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a) else unlocked.push(a) }) @@ -1917,6 +1935,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP ) })()} + {routeLegs[res.id] && } ) } diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 48defabb..14f2ba86 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -27,7 +27,7 @@ interface PlaceFormModalProps { onClose: () => void onSave: (data: PlaceSubmitData, files?: File[]) => Promise | void place: Place | null - prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null + prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null tripId: number categories: Category[] onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise | undefined @@ -86,6 +86,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { lng: String(prefillCoords.lng), name: prefillCoords.name || '', address: prefillCoords.address || '', + website: prefillCoords.website || '', + phone: prefillCoords.phone || '', + osm_id: prefillCoords.osm_id, }) } else { setForm(DEFAULT_FORM) diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx index 130e9a1d..d5cb40ed 100644 --- a/client/src/components/Settings/DisplaySettingsTab.tsx +++ b/client/src/components/Settings/DisplaySettingsTab.tsx @@ -262,6 +262,37 @@ export default function DisplaySettingsTab(): React.ReactElement {

{t('settings.bookingLabelsHint')}

+ {/* Explore places on the map (POI category pill) */} +
+ +
+ {[ + { value: true, label: t('settings.on') || 'On' }, + { value: false, label: t('settings.off') || 'Off' }, + ].map(opt => ( + + ))} +
+

{t('settings.mapPoiPillHint')}

+
+ {/* Blur Booking Codes */}
diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 391cbb6c..98c8118d 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -53,29 +53,44 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu return pos != null }) - // Build a unified list of places + transports sorted by effective position, - // then derive segments by resetting whenever a transport appears — mirrors getMergedItems order. - type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' } - const entries: (Entry & { pos: number })[] = [ + // The departure/arrival coordinate of a transport, if its endpoints carry one. + const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => { + const e = (r.endpoints || []).find((x: any) => x.role === role) + return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null + } + + // Build a unified list of places + transports sorted by effective position. + type Entry = + | { kind: 'place'; lat: number; lng: number; pos: number } + | { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number } + const entries: Entry[] = [ ...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({ kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index, })), ...dayTransports.map(r => ({ kind: 'transport' as const, + from: epLoc(r, 'from'), + to: epLoc(r, 'to'), pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number, })), ].sort((a, b) => a.pos - b.pos) - // Group consecutive located places into runs, resetting whenever a transport - // appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order. + // Group located places into driving runs. + // - A transport WITH a location anchors the route to its departure point (you + // travel there), then breaks the run (you don't drive the flight/train); its + // arrival point starts the next run. + // - A transport WITHOUT a location is ignored entirely — the places around it + // connect directly, as if the booking weren't there. const runs: { lat: number; lng: number }[][] = [] let currentRun: { lat: number; lng: number }[] = [] for (const entry of entries) { if (entry.kind === 'place') { currentRun.push({ lat: entry.lat, lng: entry.lng }) - } else { + } else if (entry.from || entry.to) { + if (entry.from) currentRun.push(entry.from) if (currentRun.length >= 2) runs.push(currentRun) currentRun = [] + if (entry.to) currentRun.push(entry.to) } } if (currentRun.length >= 2) runs.push(currentRun) @@ -120,7 +135,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu .filter(r => TRANSPORT_TYPES.includes(r.type)) .map(r => { const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position - return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}` + // Include endpoints so adding/moving a departure/arrival location re-routes. + const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';') + return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}` }) .sort() .join('|') diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 294fcae5..4a5e9f6a 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -42,6 +42,8 @@ import { usePlannerHistory } from '../hooks/usePlannerHistory' import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types' import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react' import { useTripPlanner } from './tripPlanner/useTripPlanner' +import { usePoiExplore } from '../components/Map/usePoiExplore' +import PoiCategoryPill from '../components/Map/PoiCategoryPill' function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) { const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => { @@ -195,7 +197,7 @@ export default function TripPlannerPage(): React.ReactElement | null { isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter, expandedDayIds, setExpandedDayIds, mapPlaces, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, - handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, + handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle, handleSaveReservation, handleSaveTransport, handleDeleteReservation, @@ -203,6 +205,9 @@ export default function TripPlannerPage(): React.ReactElement | null { mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone, } = useTripPlanner() + const poi = usePoiExplore() + const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false + if (isLoading || !splashDone) { return (
x.id === rid) if (r) setMapTransportDetail(r) }} + pois={poi.pois} + onPoiClick={openAddPlaceFromPoi} + onViewportChange={poi.onViewportChange} /> + {poiPillEnabled && ( +
+ +
+ )}