From 3aaa6e916be24eb5286d39c619b7df81bc484a5e Mon Sep 17 00:00:00 2001 From: Moritz Enderle Date: Tue, 31 Mar 2026 00:10:33 +0200 Subject: [PATCH] feat: adds better gpx track views --- client/package-lock.json | 4 +- client/src/components/Map/MapView.tsx | 18 ++++ .../src/components/Planner/PlaceInspector.tsx | 96 ++++++++++++++++++- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/types.ts | 1 + server/src/db/migrations.ts | 4 + server/src/routes/places.ts | 28 +++--- 18 files changed, 146 insertions(+), 17 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 5dc9bc0..4fd8204 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.7.0", + "version": "2.7.1", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 26e744a..85a1e30 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -508,6 +508,24 @@ export function MapView({ ))} )} + + {/* GPX imported route geometries */} + {places.map((place) => { + if (!place.route_geometry) return null + try { + const coords = JSON.parse(place.route_geometry) as [number, number][] + if (!coords || coords.length < 2) return null + return ( + + ) + } catch { return null } + })} ) } diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 766ce0a..e8ae383 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' -import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { mapsApi } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' @@ -461,6 +461,98 @@ export default function PlaceInspector({ )} + {/* GPX Track stats */} + {place.route_geometry && (() => { + try { + const pts: number[][] = JSON.parse(place.route_geometry) + if (!pts || pts.length < 2) return null + const hasEle = pts[0].length >= 3 + + // Haversine distance + const toRad = (d: number) => d * Math.PI / 180 + let totalDist = 0 + for (let i = 1; i < pts.length; i++) { + const [lat1, lng1] = pts[i - 1], [lat2, lng2] = pts[i] + const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1) + const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2 + totalDist += 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + } + const distKm = totalDist / 1000 + + // Elevation stats + let minEle = Infinity, maxEle = -Infinity, totalUp = 0, totalDown = 0 + if (hasEle) { + for (let i = 0; i < pts.length; i++) { + const e = pts[i][2] + if (e < minEle) minEle = e + if (e > maxEle) maxEle = e + if (i > 0) { + const diff = e - pts[i - 1][2] + if (diff > 0) totalUp += diff; else totalDown += Math.abs(diff) + } + } + } + + // Elevation profile SVG + const chartW = 280, chartH = 60 + const elevations = hasEle ? pts.map(p => p[2]) : [] + let pathD = '' + if (elevations.length > 1) { + const step = Math.max(1, Math.floor(elevations.length / chartW)) + const sampled = elevations.filter((_, i) => i % step === 0) + const eMin = Math.min(...sampled), eMax = Math.max(...sampled) + const range = eMax - eMin || 1 + pathD = sampled.map((e, i) => { + const x = (i / (sampled.length - 1)) * chartW + const y = chartH - ((e - eMin) / range) * (chartH - 4) - 2 + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}` + }).join(' ') + } + + return ( +
+
+ + {t('inspector.trackStats')} +
+
+
+ + {distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`} +
+ {hasEle && ( + <> +
+ + {Math.round(maxEle)} m +
+
+ + {Math.round(minEle)} m +
+
+ ↑{Math.round(totalUp)} m  ↓{Math.round(totalDown)} m +
+ + )} +
+ {pathD && ( + + + + + + + + + + + )} +
+ ) + } catch { return null } + })()} + {/* Files section */} {(placeFiles.length > 0 || onFileUpload) && (
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 7449336..82da2d8 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -815,6 +815,7 @@ const ar: Record = { 'inspector.addRes': 'حجز', 'inspector.editRes': 'تعديل الحجز', 'inspector.participants': 'المشاركون', + 'inspector.trackStats': 'بيانات المسار', // Reservations 'reservations.title': 'الحجوزات', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index f00adb9..a871a3f 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -794,6 +794,7 @@ const br: Record = { 'inspector.addRes': 'Reserva', 'inspector.editRes': 'Editar reserva', 'inspector.participants': 'Participantes', + 'inspector.trackStats': 'Dados da trilha', // Reservations 'reservations.title': 'Reservas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 856098f..3e15ffa 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -816,6 +816,7 @@ const cs: Record = { 'inspector.addRes': 'Rezervace', 'inspector.editRes': 'Upravit rezervaci', 'inspector.participants': 'Účastníci', + 'inspector.trackStats': 'Data trasy', // Rezervace (Reservations) 'reservations.title': 'Rezervace', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 341a36d..110415e 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -812,6 +812,7 @@ const de: Record = { 'inspector.addRes': 'Reservierung', 'inspector.editRes': 'Reservierung bearbeiten', 'inspector.participants': 'Teilnehmer', + 'inspector.trackStats': 'Streckendaten', // Reservations 'reservations.title': 'Buchungen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ea8ebe3..e12fedd 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -809,6 +809,7 @@ const en: Record = { 'inspector.addRes': 'Reservation', 'inspector.editRes': 'Edit Reservation', 'inspector.participants': 'Participants', + 'inspector.trackStats': 'Track Stats', // Reservations 'reservations.title': 'Bookings', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index f1b0e1e..e7b27d7 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -789,6 +789,7 @@ const es: Record = { 'inspector.addRes': 'Reserva', 'inspector.editRes': 'Editar reserva', 'inspector.participants': 'Participantes', + 'inspector.trackStats': 'Datos de la ruta', // Reservations 'reservations.title': 'Reservas', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 6ff7793..ad1ab95 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -811,6 +811,7 @@ const fr: Record = { 'inspector.addRes': 'Réservation', 'inspector.editRes': 'Modifier la réservation', 'inspector.participants': 'Participants', + 'inspector.trackStats': 'Données du parcours', // Reservations 'reservations.title': 'Réservations', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index ca68de1..dc2901e 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -810,6 +810,7 @@ const hu: Record = { 'inspector.addRes': 'Foglalás', 'inspector.editRes': 'Foglalás szerkesztése', 'inspector.participants': 'Résztvevők', + 'inspector.trackStats': 'Útvonal adatok', // Foglalások 'reservations.title': 'Foglalások', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index a7ae949..5645c67 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -811,6 +811,7 @@ const it: Record = { 'inspector.addRes': 'Prenotazione', 'inspector.editRes': 'Modifica prenotazione', 'inspector.participants': 'Partecipanti', + 'inspector.trackStats': 'Dati del percorso', // Reservations 'reservations.title': 'Prenotazioni', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 43a657c..ad0c099 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -811,6 +811,7 @@ const nl: Record = { 'inspector.addRes': 'Reservering', 'inspector.editRes': 'Reservering bewerken', 'inspector.participants': 'Deelnemers', + 'inspector.trackStats': 'Routegegevens', // Reservations 'reservations.title': 'Boekingen', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 15854e9..beddd63 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -811,6 +811,7 @@ const ru: Record = { 'inspector.addRes': 'Бронирование', 'inspector.editRes': 'Редактировать бронирование', 'inspector.participants': 'Участники', + 'inspector.trackStats': 'Данные маршрута', // Reservations 'reservations.title': 'Бронирования', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 65c0540..8fcc1f9 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -811,6 +811,7 @@ const zh: Record = { 'inspector.addRes': '预订', 'inspector.editRes': '编辑预订', 'inspector.participants': '参与者', + 'inspector.trackStats': '轨迹数据', // Reservations 'reservations.title': '预订', diff --git a/client/src/types.ts b/client/src/types.ts index b216b63..4012914 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -49,6 +49,7 @@ export interface Place { image_url: string | null google_place_id: string | null osm_id: string | null + route_geometry: string | null place_time: string | null end_time: string | null created_at: string diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a9adf1e..32fdac4 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -427,6 +427,10 @@ function runMigrations(db: Database.Database): void { db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); } catch {} }, + () => { + // GPX full route geometry stored as JSON array of [lat,lng] pairs + try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index f304e6a..1463f6f 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -115,7 +115,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); }); -// Import places from GPX file (must be before /:id) +// Import places from GPX file with full track geometry (must be before /:id) router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => { const { tripId } = req.params; const file = (req as any).file; @@ -136,7 +136,7 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi const extractName = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null }; const extractDesc = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null }; - const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = []; + const waypoints: { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }[] = []; // 1) Parse elements (named waypoints / POIs) const wptRegex = /]+)>([\s\S]*?)<\/wpt>/gi; @@ -159,23 +159,25 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi } } - // 3) If still nothing, extract track name + start/end points from + // 3) If still nothing, extract full track geometry from if (waypoints.length === 0) { const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i); const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track'; + const trackDesc = (() => { const m = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null })(); const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi; - const trackPoints: { lat: number; lng: number }[] = []; + const trackPoints: { lat: number; lng: number; ele: number | null }[] = []; while ((match = trkptRegex.exec(xml)) !== null) { const coords = parseCoords(match[1]); - if (coords) trackPoints.push(coords); + if (!coords) continue; + const eleMatch = match[2]?.match(/]*>([\s\S]*?)<\/ele>/i); + const ele = eleMatch ? parseFloat(eleMatch[1]) : null; + trackPoints.push({ ...coords, ele: (ele !== null && !isNaN(ele)) ? ele : null }); } if (trackPoints.length > 0) { const start = trackPoints[0]; - waypoints.push({ ...start, name: `${trackName} — Start`, description: null }); - if (trackPoints.length > 1) { - const end = trackPoints[trackPoints.length - 1]; - waypoints.push({ ...end, name: `${trackName} — End`, description: null }); - } + const hasAllEle = trackPoints.every(p => p.ele !== null); + const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); + waypoints.push({ ...start, name: trackName, description: trackDesc, routeGeometry: JSON.stringify(routeGeometry) }); } } @@ -184,13 +186,13 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi } const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, transport_mode) - VALUES (?, ?, ?, ?, ?, 'walking') + INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) + VALUES (?, ?, ?, ?, ?, 'walking', ?) `); const created: any[] = []; const insertAll = db.transaction(() => { for (const wp of waypoints) { - const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng); + const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); }