mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16: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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user