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 && ( +
+ +
+ )}