mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(map): draw transit journeys along their real rail and bus alignments
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.
This commit is contained in:
@@ -445,9 +445,9 @@ export const MapView = memo(function MapView({
|
||||
</Marker>
|
||||
)), [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 => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) => (
|
||||
<Fragment key={`transit-${item.res.id}-${segIdx}`}>
|
||||
{!seg.walk && (
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={{ color: '#ffffff', weight: 6, opacity: 0.85, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
)}
|
||||
<Polyline
|
||||
positions={seg.coords}
|
||||
pathOptions={seg.walk
|
||||
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
|
||||
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>
|
||||
</Fragment>
|
||||
))
|
||||
: item.arcs.map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -276,16 +277,25 @@ export class ReservationMapboxOverlay {
|
||||
const map = this.map
|
||||
if (map.getSource(RESERVATION_SOURCE_ID)) return
|
||||
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// White casing under real transit paths so the colored lines read cleanly.
|
||||
map.addLayer({
|
||||
id: RESERVATION_LINE_LAYER_ID + '-transit-casing',
|
||||
type: 'line',
|
||||
source: RESERVATION_SOURCE_ID,
|
||||
filter: ['all', ['==', ['get', 'transitPath'], true], ['!=', ['get', 'walk'], true]] as any,
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.85 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: RESERVATION_LINE_LAYER_ID,
|
||||
type: 'line',
|
||||
source: RESERVATION_SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': TRANSPORT_COLOR,
|
||||
'line-width': 2.5,
|
||||
// Confirmed = solid + 0.75; pending = dashed + 0.55.
|
||||
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
|
||||
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
|
||||
'line-color': ['coalesce', ['get', 'color'], TRANSPORT_COLOR] as any,
|
||||
'line-width': ['case', ['==', ['get', 'transitPath'], true], ['case', ['==', ['get', 'walk'], true], 3, 3.5], 2.5] as any,
|
||||
// Confirmed = solid + 0.75; pending = dashed + 0.55; walks always dotted.
|
||||
'line-opacity': ['case', ['==', ['get', 'transitPath'], true], 0.95, ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55]] as any,
|
||||
'line-dasharray': ['case', ['==', ['get', 'walk'], true], ['literal', [0.1, 2.5]], ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]]] as any,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
@@ -328,18 +338,41 @@ export class ReservationMapboxOverlay {
|
||||
}
|
||||
|
||||
// ── line features ───────────────────────────────────────────────
|
||||
const features = visibleItems.flatMap(item => 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 })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>(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])
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: size === 'sm' ? '1px 0' : '2px 0' }}>
|
||||
<span style={ruleStyle} />
|
||||
|
||||
@@ -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 [{
|
||||
|
||||
Reference in New Issue
Block a user