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);
}