mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { avatarUrl } from '../common/avatarUrl';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import type { TrekWsUserEventName } from '@trek/shared';
|
||||
import type { JourneyTrack, TrekWsUserEventName } from '@trek/shared';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
|
||||
|
||||
@@ -771,6 +771,63 @@ export class JourneyDomainService {
|
||||
|
||||
// ── Entries ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The GPX tracks belonging to a journey (#1260). A journey has no trip of its own,
|
||||
* so the link runs through its entries: every entry records the trip it came from,
|
||||
* and a trip's routed geometries live on its places. Uploading a GPX in the planner
|
||||
* therefore already puts everything in place; nothing drew it in the journal.
|
||||
*
|
||||
* Only places that actually carry geometry are returned, so a journey whose trips
|
||||
* have no tracks answers with an empty list rather than a wall of null points.
|
||||
*/
|
||||
journeyTracks(journeyId: number, userId: number): JourneyTrack[] | null {
|
||||
if (!this.canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT DISTINCT p.id AS place_id, p.trip_id, p.name, p.route_color, p.route_geometry
|
||||
FROM journey_entries je
|
||||
JOIN places p ON p.trip_id = je.source_trip_id
|
||||
WHERE je.journey_id = ?
|
||||
AND je.source_trip_id IS NOT NULL
|
||||
AND p.route_geometry IS NOT NULL
|
||||
ORDER BY p.trip_id, p.id
|
||||
`).all(journeyId) as {
|
||||
place_id: number; trip_id: number; name: string | null;
|
||||
route_color: string | null; route_geometry: string;
|
||||
}[];
|
||||
|
||||
const tracks: JourneyTrack[] = [];
|
||||
for (const row of rows) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.route_geometry);
|
||||
} catch {
|
||||
continue; // A geometry that is not JSON is not worth failing the whole map over.
|
||||
}
|
||||
if (!Array.isArray(parsed)) continue;
|
||||
|
||||
const points: [number, number][] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!Array.isArray(entry) || entry.length < 2) continue;
|
||||
const lat = Number(entry[0]);
|
||||
const lng = Number(entry[1]);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
points.push([lat, lng]);
|
||||
}
|
||||
// A single point is a pin, not a line, and the map already has the entry markers.
|
||||
if (points.length < 2) continue;
|
||||
|
||||
tracks.push({
|
||||
place_id: row.place_id,
|
||||
trip_id: row.trip_id,
|
||||
name: row.name ?? '',
|
||||
color: row.route_color,
|
||||
points,
|
||||
});
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
listEntries(journeyId: number, userId: number) {
|
||||
if (!this.canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
|
||||
@@ -421,6 +421,20 @@ export class JourneyController {
|
||||
return { entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPX tracks drawn on this journey's map (#1260). Read-only and derived: the
|
||||
* geometries belong to the trips the entries came from, so uploading a GPX in the
|
||||
* planner is all it takes for it to show up here.
|
||||
*/
|
||||
@Get(':id/tracks')
|
||||
listTracks(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
const tracks = this.journey.journeyTracks(Number(id), user.id);
|
||||
if (!tracks) {
|
||||
throw new HttpException({ error: 'Journey not found' }, 404);
|
||||
}
|
||||
return { tracks };
|
||||
}
|
||||
|
||||
@Post(':id/entries')
|
||||
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) {
|
||||
if (!body.entry_date) {
|
||||
|
||||
@@ -51,6 +51,7 @@ export class JourneyService {
|
||||
|
||||
// Entries
|
||||
listEntries(id: number, userId: number) { return this.journey.listEntries(id, userId); }
|
||||
journeyTracks(id: number, userId: number) { return this.journey.journeyTracks(id, userId); }
|
||||
// Entry create/update bodies are free-form in the legacy route (req.body: any);
|
||||
// the cast keeps that boundary here so callers needn't pre-shape the payload.
|
||||
createEntry(id: number, userId: number, data: Record<string, unknown>, sid?: string) { return this.journey.createEntry(id, userId, data as Parameters<typeof this.journey.createEntry>[2], sid); }
|
||||
|
||||
@@ -2009,3 +2009,73 @@ describe('entry enrichment', () => {
|
||||
expect((entry as any).source_trip_name).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── GPX tracks on the journey map (#1260) ─────────────────────────────────────
|
||||
describe('journeyTracks', () => {
|
||||
/** A GPX import stores the geometry on the place, as JSON [lat, lng] pairs. */
|
||||
const withGeometry = (placeId: number, geometry: unknown, color: string | null = null) =>
|
||||
testDb
|
||||
.prepare('UPDATE places SET route_geometry = ?, route_color = ? WHERE id = ?')
|
||||
.run(typeof geometry === 'string' ? geometry : JSON.stringify(geometry), color, placeId);
|
||||
|
||||
it('JOURNEY-SVC-TRACKS-001: returns the tracks of the trips the entries came from', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, place } = tripWithPlace(user.id);
|
||||
withGeometry(place.id, [[35.1, 135.7], [35.2, 135.8]], '#ff0000');
|
||||
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
||||
|
||||
const tracks = svc.journeyTracks(journey.id, user.id)!;
|
||||
expect(tracks).toHaveLength(1);
|
||||
expect(tracks[0]).toMatchObject({ place_id: place.id, trip_id: trip.id, color: '#ff0000' });
|
||||
expect(tracks[0].points).toEqual([[35.1, 135.7], [35.2, 135.8]]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-TRACKS-002: keeps the elevation out and the pair in', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, place } = tripWithPlace(user.id);
|
||||
// The importer keeps elevation as a third value where the file had it.
|
||||
withGeometry(place.id, [[47.1, 11.2, 1830], [47.2, 11.3, 1902]]);
|
||||
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
||||
|
||||
expect(svc.journeyTracks(journey.id, user.id)![0].points).toEqual([[47.1, 11.2], [47.2, 11.3]]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-TRACKS-003: a place without geometry contributes nothing', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip } = tripWithPlace(user.id);
|
||||
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
||||
|
||||
expect(svc.journeyTracks(journey.id, user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-TRACKS-004: unusable geometry is skipped, not fatal', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, place } = tripWithPlace(user.id);
|
||||
const second = createPlace(testDb, trip.id, { name: 'Good one' });
|
||||
withGeometry(place.id, 'not json at all');
|
||||
withGeometry(second.id, [[1, 2], [3, 4]]);
|
||||
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
||||
|
||||
const tracks = svc.journeyTracks(journey.id, user.id)!;
|
||||
expect(tracks.map(t => t.place_id)).toEqual([second.id]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-TRACKS-005: a single point is a pin, not a line', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, place } = tripWithPlace(user.id);
|
||||
withGeometry(place.id, [[35.1, 135.7]]);
|
||||
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
||||
|
||||
expect(svc.journeyTracks(journey.id, user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-TRACKS-006: a stranger gets null, not another user route', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const { trip, place } = tripWithPlace(user.id);
|
||||
withGeometry(place.id, [[1, 2], [3, 4]]);
|
||||
const journey = svc.createJourney(user.id, { title: 'J', trip_ids: [trip.id] });
|
||||
|
||||
expect(svc.journeyTracks(journey.id, stranger.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,3 +130,24 @@ export const journeyShareLinkRequestSchema = z.looseObject({
|
||||
share_map: z.unknown().optional(),
|
||||
});
|
||||
export type JourneyShareLinkRequest = z.infer<typeof journeyShareLinkRequestSchema>;
|
||||
|
||||
/**
|
||||
* GPX tracks a journey can draw (#1260). A journey has no trip of its own, but its
|
||||
* entries record the trip and place they came from, so the tracks are the routed
|
||||
* geometries of the places in those trips. Points are [lat, lng] pairs, matching the
|
||||
* order the GPX importer stored them in; elevation, where the import kept it, is
|
||||
* dropped here because nothing on the map reads it.
|
||||
*/
|
||||
export const journeyTrackSchema = z.object({
|
||||
place_id: z.number(),
|
||||
trip_id: z.number(),
|
||||
name: z.string(),
|
||||
color: z.string().nullable(),
|
||||
points: z.array(z.tuple([z.number(), z.number()])),
|
||||
});
|
||||
export type JourneyTrack = z.infer<typeof journeyTrackSchema>;
|
||||
|
||||
export const journeyTracksResponseSchema = z.object({
|
||||
tracks: z.array(journeyTrackSchema),
|
||||
});
|
||||
export type JourneyTracksResponse = z.infer<typeof journeyTracksResponseSchema>;
|
||||
|
||||
@@ -55,6 +55,10 @@ On mobile, entries are displayed in a horizontal scrolling timeline of card thum
|
||||
|
||||
The journey detail page includes a map on the right (desktop) or an integrated map-timeline (mobile) showing all entry locations alongside the places from linked trips.
|
||||
|
||||
**GPX tracks are drawn too.** Any route you imported into one of the journey's linked trips, from a `.gpx`, `.kml` or `.kmz` file, appears on the journey map as well, in the same colour it has in the trip planner and with a white casing so it stays readable on satellite tiles. Nothing to switch on: the tracks belong to the trips your entries came from, so importing the file while planning is all it takes. Hovering a track shows its name.
|
||||
|
||||
The thin dashed line connecting entries in date order is something else and stays as it is: that one is drawn by TREK, while a track is the route you actually recorded.
|
||||
|
||||

|
||||
|
||||
## Plugin entry rows
|
||||
|
||||
Reference in New Issue
Block a user