feat(journey): draw the trips' GPX tracks on the journey map (#1911)

* feat(journey): serve the GPX tracks behind a journey

A journey has no trip of its own, but every entry records the trip it came from,
and a trip's routed geometries live on its places. So the tracks are derivable
without any new storage: GET /journeys/:id/tracks walks that link and returns the
geometries, dropping elevation nothing on the map reads and skipping anything
that is not a usable line.

* feat(journey): draw those tracks on the journey map

Both renderers, Leaflet and GL, plus the mobile map-timeline that shares them.
Solid and in the track's own colour with a white casing, so it reads as a
recorded route rather than as the dashed line that merely connects entries in
time order, and stays legible on satellite tiles.

* feat(journey): load the tracks alongside the journey

Fetched separately from the journey itself: most journeys have no tracks, and a
failed lookup should cost the map its lines rather than the whole page.

* docs(journey): document the GPX tracks on the map

* fix(journey): label tracks with the house map tooltip

bindTooltip's default box ignores the appearance tokens, so it sat there in
Leaflet grey next to markers already using .map-tooltip. Same class now, and an
unnamed track gets no tooltip at all instead of an empty one.
This commit is contained in:
Maurice
2026-08-12 15:14:50 +02:00
committed by GitHub
parent 767dc662b7
commit 870bd6131f
15 changed files with 334 additions and 11 deletions
+3 -1
View File
@@ -11,7 +11,7 @@ import {
mapsPlaceEnrichmentResultSchema,
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest,
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
@@ -887,6 +887,8 @@ export const journeyApi = {
// Entries
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
// GPX tracks of the trips this journey's entries came from (#1260).
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
@@ -35,14 +35,14 @@ vi.mock('leaflet', () => {
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => ({ addTo: vi.fn() })),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => ({ addTo: vi.fn() })),
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
};
@@ -489,4 +489,65 @@ describe('JourneyMap', () => {
unmount();
expect(vi.mocked(map.remove).mock.calls.length).toBe(before + 1);
});
// ── GPX tracks (#1260) ──────────────────────────────────────────────────────
const track = (over: Partial<{ place_id: number; trip_id: number; name: string; color: string | null; points: [number, number][] }> = {}) => ({
place_id: 1, trip_id: 1, name: 'Morning hike', color: '#ff0000',
points: [[47.1, 11.2], [47.2, 11.3]] as [number, number][], ...over,
});
it('FE-COMP-JOURNEYMAP-040: draws a track as a casing plus a coloured line', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track()]} />);
const calls = vi.mocked(L.polyline).mock.calls;
const casing = calls.find(c => (c[1] as any)?.color === '#ffffff');
const line = calls.find(c => (c[1] as any)?.color === '#ff0000');
expect(casing).toBeDefined();
expect(line).toBeDefined();
// Leaflet wants [lat, lng], which is the order the geometry is stored in.
expect(line![0]).toEqual([[47.1, 11.2], [47.2, 11.3]]);
// Solid, unlike the dashed line that merely connects entries in time order.
expect((line![1] as any).dashArray).toBeUndefined();
});
it('FE-COMP-JOURNEYMAP-043: labels a track with the map tooltip the markers use', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ name: 'Morning hike' })]} />);
const line = vi.mocked(L.polyline).mock.results
.map(r => r.value as any)
.find(v => v.bindTooltip.mock.calls.length > 0);
expect(line).toBeDefined();
const [label, opts] = line.bindTooltip.mock.calls[0];
expect(label).toBe('Morning hike');
// The house tooltip, not Leaflet's default box: it reads the appearance tokens.
expect(opts.className).toBe('map-tooltip');
});
it('FE-COMP-JOURNEYMAP-044: an unnamed track gets no empty tooltip', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ name: '' })]} />);
const tooltipped = vi.mocked(L.polyline).mock.results
.map(r => r.value as any)
.filter(v => v.bindTooltip.mock.calls.length > 0);
expect(tooltipped).toHaveLength(0);
});
it('FE-COMP-JOURNEYMAP-041: a track without its own colour falls back rather than vanishing', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ color: null })]} />);
const colors = vi.mocked(L.polyline).mock.calls.map(c => (c[1] as any)?.color);
expect(colors).toContain('#4f46e5');
});
it('FE-COMP-JOURNEYMAP-042: a track with fewer than two points draws nothing', () => {
vi.mocked(L.polyline).mockClear();
render(<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ points: [[47.1, 11.2]] })]} />);
const colors = vi.mocked(L.polyline).mock.calls.map(c => (c[1] as any)?.color);
expect(colors).not.toContain('#ff0000');
expect(colors).not.toContain('#ffffff');
});
});
+26 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'react'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
import type { JourneyTrack } from '@trek/shared'
export interface MapMarkerItem {
id: string
@@ -35,6 +36,8 @@ interface Props {
checkins: any[]
entries: MapEntry[]
trail?: { lat: number; lng: number }[]
/** Routed GPX geometries from the journey's trips (#1260). */
tracks?: JourneyTrack[]
height?: number
dark?: boolean
activeMarkerId?: string | null
@@ -84,11 +87,15 @@ function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): st
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMap(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, ref }: Props,
) {
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
@@ -194,6 +201,23 @@ function JourneyMap(
coords.forEach(c => allCoords.push(c))
}
// GPX tracks — drawn solid and in their own colour, so they read as a recorded
// route rather than as the dashed line that merely connects entries in time order.
// A white casing keeps them legible on satellite tiles, same as the planner map.
for (const track of stableTracks) {
if (track.points.length < 2) continue
const coords = track.points.map(([lat, lng]) => [lat, lng] as L.LatLngTuple)
const color = track.color || TRACK_FALLBACK_COLOR
L.polyline(coords, { color: '#ffffff', weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' }).addTo(map)
const line = L.polyline(coords, { color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' })
// Same tooltip the markers on this map use, rather than Leaflet's default box:
// it follows the appearance tokens, so it lands right in dark mode and with
// transparency switched off.
if (track.name) line.bindTooltip(track.name, { sticky: true, direction: 'top', className: 'map-tooltip' })
line.addTo(map)
coords.forEach(c => allCoords.push(c))
}
// route polyline — only in non-fullscreen (sidebar map) mode
if (!fullScreen && items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
@@ -255,7 +279,7 @@ function JourneyMap(
mapRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
}, [entries, stableTrail, stableTracks, dark, mapTileUrl, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
@@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
import type { JourneyMapGLHandle } from './JourneyMapGL'
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
import type { JourneyTrack } from '@trek/shared'
// Unified handle — both providers expose the same three methods.
export type JourneyMapAutoHandle = JourneyMapHandle
@@ -26,6 +27,7 @@ interface Props {
checkins: unknown[]
entries: MapEntry[]
trail?: { lat: number; lng: number }[]
tracks?: JourneyTrack[]
height?: number
dark?: boolean
activeMarkerId?: string | null
+46 -2
View File
@@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
import type { JourneyTrack } from '@trek/shared'
export interface JourneyMapGLHandle {
highlightMarker: (id: string | null) => void
@@ -27,6 +28,8 @@ interface Props {
checkins: unknown[]
entries: MapEntry[]
trail?: { lat: number; lng: number }[]
/** Routed GPX geometries from the journey's trips (#1260). */
tracks?: JourneyTrack[]
height?: number
dark?: boolean
activeMarkerId?: string | null
@@ -209,11 +212,15 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const EMPTY_TRACKS: JourneyTrack[] = []
/** Fallback when a track carries no colour of its own, matching the planner's default. */
const TRACK_FALLBACK_COLOR = '#4f46e5'
function JourneyMapGL(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props,
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props,
) {
const stableTrail = trail || EMPTY_TRAIL
const stableTracks = tracks || EMPTY_TRACKS
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -416,6 +423,43 @@ function JourneyMapGL(
}
}
// GPX tracks — one source for all of them, coloured per feature so a journey
// with several recorded routes keeps them apart. Casing underneath, same as the
// planner map, so a track stays readable on satellite tiles.
if (stableTracks.length > 0) {
const featureCollection: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: stableTracks
.filter(track => track.points.length > 1)
.map(track => ({
type: 'Feature' as const,
properties: { color: track.color || TRACK_FALLBACK_COLOR, name: track.name },
geometry: { type: 'LineString' as const, coordinates: track.points.map(([lat, lng]) => [lng, lat]) },
})),
}
if (featureCollection.features.length > 0) {
if (map.getSource('journey-tracks')) {
(map.getSource('journey-tracks') as mapboxgl.GeoJSONSource).setData(featureCollection)
} else {
map.addSource('journey-tracks', { type: 'geojson', data: featureCollection })
map.addLayer({
id: 'journey-tracks-casing',
type: 'line',
source: 'journey-tracks',
paint: { 'line-color': '#ffffff', 'line-width': 6, 'line-opacity': 0.75 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
map.addLayer({
id: 'journey-tracks-line',
type: 'line',
source: 'journey-tracks',
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.95 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
}
}
// markers
items.forEach((item) => {
const el = markerHtml(item.dayColor, item.dayLabel, false)
@@ -454,7 +498,7 @@ function JourneyMapGL(
try { map.remove() } catch { /* noop */ }
mapRef.current = null
}
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
}, [entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
// Switching the UI language has to repin the basemap labels without tearing
// the map down. The load handler covers the initial run.
@@ -5,6 +5,7 @@ import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
import type { JourneyTrack } from '@trek/shared'
interface MapEntry {
id: string
@@ -19,6 +20,7 @@ interface Props {
entries: JourneyEntry[] | any[]
mapEntries: MapEntry[]
trail?: { lat: number; lng: number }[]
tracks?: JourneyTrack[]
dark?: boolean
readOnly?: boolean
onEntryClick: (entry: any) => void
@@ -31,6 +33,7 @@ export default function MobileMapTimeline({
entries,
mapEntries,
trail,
tracks,
dark,
readOnly,
onEntryClick,
@@ -160,6 +163,7 @@ export default function MobileMapTimeline({
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
@@ -190,6 +194,7 @@ export default function MobileMapTimeline({
entries={mapEntries}
checkins={[]}
trail={trail}
tracks={tracks}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
@@ -38,7 +38,7 @@ export default function MJourneyDetail() {
showInvite, setShowInvite,
showSettings, setShowSettings,
hideSkeletons,
sidebarMapItems,
sidebarMapItems, tracks,
loadJourney, updateEntry, deleteEntry, uploadPhotos,
} = useJourneyDetail()
@@ -209,6 +209,7 @@ export default function MJourneyDetail() {
ref={mapRef}
checkins={[]}
entries={sidebarMapItems}
tracks={tracks}
height={9999}
dark={dark}
activeMarkerId={entries[activeIndex] ? String(entries[activeIndex].id) : null}
+3 -1
View File
@@ -44,7 +44,7 @@ function JourneyDetailPageDesktop() {
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
} = useJourneyDetail()
@@ -86,6 +86,7 @@ function JourneyDetailPageDesktop() {
<MobileMapTimeline
entries={timelineEntries}
mapEntries={sidebarMapItems}
tracks={tracks}
dark={document.documentElement.classList.contains('dark')}
readOnly={!canEditEntries}
onEntryClick={(entry) => setViewingEntry(entry)}
@@ -441,6 +442,7 @@ function JourneyDetailPageDesktop() {
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
tracks={tracks}
height={9999}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
@@ -3,6 +3,8 @@ import { useParams, useNavigate, useSearchParams } from 'react-router'
import { useJourneyStore } from '../../store/journeyStore'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { journeyApi } from '../../api/client'
import type { JourneyTrack } from '@trek/shared'
import { DAY_COLORS } from '../../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/Journey/JourneyMapAuto'
import { useToast } from '../../components/shared/Toast'
@@ -61,6 +63,19 @@ export function useJourneyDetail() {
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
// GPX tracks of the trips behind this journey (#1260). Loaded separately from the
// journey itself: a journey without tracks is the common case, and a failed lookup
// should cost the map its lines, not the whole page.
const [tracks, setTracks] = useState<JourneyTrack[]>([])
useEffect(() => {
if (!id) return
let cancelled = false
journeyApi.listTracks(Number(id))
.then(res => { if (!cancelled) setTracks(res.tracks ?? []) })
.catch(() => { if (!cancelled) setTracks([]) })
return () => { cancelled = true }
}, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
@@ -287,7 +302,7 @@ export function useJourneyDetail() {
unlinkTrip, setUnlinkTrip, showSettings, setShowSettings,
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, activeLocationId, handleMarkerClick, handleLocationClick,
mapEntries, sidebarMapItems, tripDates, isMobile,
mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
}
}