From 5b9723cf8d844c144ec7463f09e14b077ffe3632 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 2 Jul 2026 23:15:52 +0200 Subject: [PATCH] feat(map): draw transit journeys along their real rail and bus alignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MOTIS leg geometry (encoded polylines) now travels through the proxy and is stored per leg, so both map renderers draw the journey along the actual tracks instead of a straight line: colored cores in each line's GTFS color over a white casing, walks as dotted grey connectors. Transit journeys are always visible on the map — they are part of the plan itself, not an opt-in overlay — and the day route already anchors to their stations, so the journey slots into the route computation end to end. Entries saved before this keep the straight-line fallback. Also: stronger dashes on the walk dividers, notes open rendered (preview tab) when present, and MOTIS's START/END placeholders are replaced with the places the user actually picked. --- client/src/components/Map/MapView.tsx | 6 +- client/src/components/Map/MapViewGL.tsx | 6 +- .../src/components/Map/ReservationOverlay.tsx | 25 ++++++- .../src/components/Map/reservationsMapbox.ts | 67 ++++++++++++++----- client/src/components/Map/transitGeometry.ts | 63 +++++++++++++++++ .../Planner/TransitJourneyModal.tsx | 4 +- .../components/Planner/TransitSearchPanel.tsx | 16 ++++- .../src/components/Planner/transitDisplay.tsx | 2 +- server/src/services/transitService.ts | 6 ++ 9 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 client/src/components/Map/transitGeometry.ts diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index e5b0432ac..a62099912 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -445,9 +445,9 @@ export const MapView = memo(function MapView({ )), [pois, onPoiClick]) const visibleReservations = useMemo(() => { - if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [] - const set = new Set(visibleConnectionIds) - return reservations.filter((r: Reservation) => set.has(r.id)) + const set = new Set(visibleConnectionIds || []) + // Transit journeys are part of the plan itself — always on the map (#1065). + return reservations.filter((r: Reservation) => r.type === 'transit' || set.has(r.id)) }, [reservations, visibleConnectionIds]) // Dynamic padding: account for sidebars + bottom inspector + day detail panel const paddingOpts = useMemo((): L.FitBoundsOptions => { diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index a5712f40a..0f8749075 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -800,9 +800,9 @@ export function MapViewGL({ // DayPlanSidebar — nothing is rendered until the user enables a // booking's route, matching the Leaflet MapView's behaviour. const visibleReservations = useMemo(() => { - if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [] - const set = new Set(visibleConnectionIds) - return reservations.filter(r => set.has(r.id)) + const set = new Set(visibleConnectionIds || []) + // Transit journeys are part of the plan itself — always on the map (#1065). + return reservations.filter(r => r.type === 'transit' || set.has(r.id)) }, [reservations, visibleConnectionIds]) useEffect(() => { diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index f86300325..1df0bbec5 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -1,9 +1,10 @@ -import { createElement, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, createElement, useEffect, useMemo, useRef, useState } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet' import L from 'leaflet' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react' import { escapeHtml } from '@trek/shared' +import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry' import { useSettingsStore } from '../../store/settingsStore' import type { Reservation, ReservationEndpoint } from '../../types' @@ -162,6 +163,7 @@ interface TransportItem { waypoints: ReservationEndpoint[] type: TransportType arcs: [number, number][][] + transitSegs: TransitMapSegment[] primaryArc: [number, number][] fallback: [number, number] mainLabel: string | null @@ -393,7 +395,7 @@ export default function ReservationOverlay({ reservations, showConnections, show const subParts = [duration, distance].filter(Boolean) as string[] const subLabel = subParts.length > 0 ? subParts.join(' · ') : null - out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel }) + out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], primaryArc, fallback, mainLabel, subLabel }) } return out }, [reservations]) @@ -422,7 +424,24 @@ export default function ReservationOverlay({ reservations, showConnections, show return ( <> - {visibleItems.map(item => item.arcs.map((seg, segIdx) => ( + {visibleItems.map(item => item.transitSegs.length > 0 + ? item.transitSegs.map((seg, segIdx) => ( + + {!seg.walk && ( + + )} + + + )) + : item.arcs.map((seg, segIdx) => ( item.arcs.map(seg => ({ - type: 'Feature' as const, - properties: { - resId: item.res.id, - type: item.type, - status: item.res.status ?? 'pending', - }, - geometry: { - type: 'LineString' as const, - coordinates: seg.map(([lat, lng]) => [lng, lat]), - }, - }))) + const features = visibleItems.flatMap(item => { + const transitSegs = item.type === 'transit' ? getTransitMapSegments(item.res) : [] + if (transitSegs.length > 0) { + return transitSegs.map(seg => ({ + type: 'Feature' as const, + properties: { + resId: item.res.id, + type: item.type, + status: item.res.status ?? 'pending', + transitPath: true, + walk: seg.walk, + color: seg.walk ? '#64748b' : (seg.color || '#7c3aed'), + }, + geometry: { + type: 'LineString' as const, + coordinates: seg.coords.map(([lat, lng]) => [lng, lat]), + }, + })) + } + return item.arcs.map(seg => ({ + type: 'Feature' as const, + properties: { + resId: item.res.id, + type: item.type, + status: item.res.status ?? 'pending', + transitPath: false, + walk: false, + color: null as string | null, + }, + geometry: { + type: 'LineString' as const, + coordinates: seg.map(([lat, lng]) => [lng, lat]), + }, + })) + }) const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined src?.setData({ type: 'FeatureCollection', features }) diff --git a/client/src/components/Map/transitGeometry.ts b/client/src/components/Map/transitGeometry.ts new file mode 100644 index 000000000..b12d76d42 --- /dev/null +++ b/client/src/components/Map/transitGeometry.ts @@ -0,0 +1,63 @@ +import type { Reservation } from '../../types' + +/** + * Real-path geometry for transit journeys on the map (#1065). MOTIS delivers + * each leg's shape as a Google-encoded polyline; we store it on + * metadata.transit.legs[].geometry and decode it here so the map can draw the + * actual rail/bus alignment instead of a straight line. + */ + +export interface TransitMapSegment { + coords: [number, number][] + color: string | null + walk: boolean +} + +/** Google polyline decoding with a configurable precision (MOTIS uses 6). */ +export function decodePolyline(encoded: string, precision = 6): [number, number][] { + const factor = Math.pow(10, precision) + const coords: [number, number][] = [] + let index = 0 + let lat = 0 + let lng = 0 + while (index < encoded.length) { + for (const which of [0, 1] as const) { + let result = 0 + let shift = 0 + let byte = 0x20 + while (byte >= 0x20) { + if (index >= encoded.length) return coords + byte = encoded.charCodeAt(index++) - 63 + result |= (byte & 0x1f) << shift + shift += 5 + } + const delta = result & 1 ? ~(result >> 1) : result >> 1 + if (which === 0) lat += delta + else lng += delta + } + coords.push([lat / factor, lng / factor]) + } + return coords +} + +/** + * The decoded per-leg segments of a transit reservation, or [] when it has no + * stored geometry (pre-geometry entries fall back to the straight line). + */ +export function getTransitMapSegments(res: Reservation): TransitMapSegment[] { + if (res.type !== 'transit') return [] + let meta: any = res.metadata + if (typeof meta === 'string') { + try { meta = JSON.parse(meta) } catch { return [] } + } + const legs = meta?.transit?.legs + if (!Array.isArray(legs)) return [] + const out: TransitMapSegment[] = [] + for (const leg of legs) { + if (!leg?.geometry || typeof leg.geometry !== 'string') continue + const coords = decodePolyline(leg.geometry, typeof leg.geometry_precision === 'number' ? leg.geometry_precision : 6) + if (coords.length < 2) continue + out.push({ coords, color: leg.line_color || null, walk: leg.mode === 'WALK' }) + } + return out +} diff --git a/client/src/components/Planner/TransitJourneyModal.tsx b/client/src/components/Planner/TransitJourneyModal.tsx index e7399cbc5..7013dede7 100644 --- a/client/src/components/Planner/TransitJourneyModal.tsx +++ b/client/src/components/Planner/TransitJourneyModal.tsx @@ -51,7 +51,8 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe const [title, setTitle] = useState(res.title || '') const [editingTitle, setEditingTitle] = useState(false) const [notes, setNotes] = useState(res.notes || '') - const [notesTab, setNotesTab] = useState<'write' | 'preview'>('write') + // Existing notes open rendered; the write tab is for editing. + const [notesTab, setNotesTab] = useState<'write' | 'preview'>(() => (res.notes ? 'preview' : 'write')) const [saving, setSaving] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const titleInputRef = useRef(null) @@ -105,6 +106,7 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe setTitle(res.title || '') setNotes(res.notes || '') setEditingTitle(false) + setNotesTab(res.notes ? 'preview' : 'write') }, [res.id]) useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index fe6acad7b..ae219e987 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -26,6 +26,7 @@ interface TransitLeg { mode: string; from: TransitLegStop; to: TransitLegStop; duration: number; distance: number | null headsign: string | null; line: string | null; lineColor: string | null; lineTextColor: string | null agency: string | null; intermediateStops: number + geometry?: string | null; geometryPrecision?: number } export interface TransitItinerary { startTime: string; endTime: string; duration: number; transfers: number; walkSeconds: number; legs: TransitLeg[] @@ -370,7 +371,18 @@ export default function TransitSearchPanel({ day, days, places, accommodations = const allModes = activeModes.size === MODE_GROUPS.length const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',') const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes }) - setItineraries(d.itineraries || []) + // MOTIS names the request coordinates START/END — swap in the places the + // user actually picked so walks read "Walk to Zoologischer Garten". + const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n) + const cleaned = (d.itineraries || []).map((it: TransitItinerary) => ({ + ...it, + legs: it.legs.map(l => ({ + ...l, + from: { ...l.from, name: cleanStop(l.from.name) }, + to: { ...l.to, name: cleanStop(l.to.name) }, + })), + })) + setItineraries(cleaned) } catch { toast.error(t('transit.searchError')) setItineraries([]) @@ -444,6 +456,8 @@ export default function TransitSearchPanel({ day, days, places, accommodations = stops: l.intermediateStops, from: { name: l.from.name, time: l.from.time ? timeHHmmInTz(l.from.time, tzAt(l.from.lat, l.from.lng)) : null, track: l.from.track }, to: { name: l.to.name, time: l.to.time ? timeHHmmInTz(l.to.time, tzAt(l.to.lat, l.to.lng)) : null, track: l.to.track }, + geometry: l.geometry || null, + geometry_precision: l.geometryPrecision ?? 6, })), }, }, diff --git a/client/src/components/Planner/transitDisplay.tsx b/client/src/components/Planner/transitDisplay.tsx index 9a9cdb48e..2fb8a4093 100644 --- a/client/src/components/Planner/transitDisplay.tsx +++ b/client/src/components/Planner/transitDisplay.tsx @@ -32,7 +32,7 @@ export function TransitWalkDivider({ leg, t, size = 'md' }: { size?: 'sm' | 'md' }) { const mins = leg.duration ? Math.round(leg.duration / 60) : null - const ruleStyle: React.CSSProperties = { flex: 1, borderTop: '1px dashed var(--border-primary)', opacity: 0.6, minWidth: 12 } + const ruleStyle: React.CSSProperties = { flex: 1, borderTop: '1.5px dashed var(--text-faint)', opacity: 0.75, minWidth: 12 } return (
diff --git a/server/src/services/transitService.ts b/server/src/services/transitService.ts index 8bf13fa2b..1dfe7b527 100644 --- a/server/src/services/transitService.ts +++ b/server/src/services/transitService.ts @@ -129,6 +129,9 @@ export interface TransitLeg { lineTextColor: string | null; agency: string | null; intermediateStops: number; + /** Encoded polyline of the leg's real path (Google encoding) + its precision. */ + geometry: string | null; + geometryPrecision: number; } export interface TransitItinerary { @@ -216,6 +219,7 @@ export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerar routeShortName?: string; displayName?: string; routeColor?: string; routeTextColor?: string; agencyName?: string; from?: MotisPlaceRaw; to?: MotisPlaceRaw; intermediateStops?: unknown[]; + legGeometry?: { points?: string; precision?: number }; }>; }>; }; @@ -234,6 +238,8 @@ export async function plan(q: PlanQuery): Promise<{ itineraries: TransitItinerar lineTextColor: safeColor(leg.routeTextColor), agency: leg.agencyName || null, intermediateStops: Array.isArray(leg.intermediateStops) ? leg.intermediateStops.length : 0, + geometry: leg.legGeometry?.points || null, + geometryPrecision: leg.legGeometry?.precision ?? 6, })); const walkSeconds = legs.filter((l) => l.mode === 'WALK').reduce((a, l) => a + l.duration, 0); return [{