feat: adds better gpx track views

This commit is contained in:
Moritz Enderle
2026-03-31 00:10:33 +02:00
parent ad329eddb9
commit 3aaa6e916b
18 changed files with 146 additions and 17 deletions

View File

@@ -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",

View File

@@ -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 (
<Polyline
key={`gpx-${place.id}`}
positions={coords}
color={place.category_color || '#3b82f6'}
weight={3.5}
opacity={0.75}
/>
)
} catch { return null }
})}
</MapContainer>
)
}

View File

@@ -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 (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendingUp size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{t('inspector.trackStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<MapPin size={12} color="#3b82f6" />
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
</div>
{hasEle && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#22c55e" />
{Math.round(maxEle)} m
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>
<Mountain size={12} color="#ef4444" />
{Math.round(minEle)} m
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
↑{Math.round(totalUp)} m &nbsp;↓{Math.round(totalDown)} m
</div>
</>
)}
</div>
{pathD && (
<svg width="100%" viewBox={`0 0 ${chartW} ${chartH}`} preserveAspectRatio="none" style={{ display: 'block', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<defs>
<linearGradient id={`ele-grad-${place.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.25" />
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={`${pathD} L${chartW},${chartH} L0,${chartH} Z`} fill={`url(#ele-grad-${place.id})`} />
<path d={pathD} fill="none" stroke="#3b82f6" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
)}
</div>
)
} catch { return null }
})()}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>

View File

@@ -815,6 +815,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'حجز',
'inspector.editRes': 'تعديل الحجز',
'inspector.participants': 'المشاركون',
'inspector.trackStats': 'بيانات المسار',
// Reservations
'reservations.title': 'الحجوزات',

View File

@@ -794,6 +794,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Reserva',
'inspector.editRes': 'Editar reserva',
'inspector.participants': 'Participantes',
'inspector.trackStats': 'Dados da trilha',
// Reservations
'reservations.title': 'Reservas',

View File

@@ -816,6 +816,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Rezervace',
'inspector.editRes': 'Upravit rezervaci',
'inspector.participants': 'Účastníci',
'inspector.trackStats': 'Data trasy',
// Rezervace (Reservations)
'reservations.title': 'Rezervace',

View File

@@ -812,6 +812,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Reservierung',
'inspector.editRes': 'Reservierung bearbeiten',
'inspector.participants': 'Teilnehmer',
'inspector.trackStats': 'Streckendaten',
// Reservations
'reservations.title': 'Buchungen',

View File

@@ -809,6 +809,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Reservation',
'inspector.editRes': 'Edit Reservation',
'inspector.participants': 'Participants',
'inspector.trackStats': 'Track Stats',
// Reservations
'reservations.title': 'Bookings',

View File

@@ -789,6 +789,7 @@ const es: Record<string, string> = {
'inspector.addRes': 'Reserva',
'inspector.editRes': 'Editar reserva',
'inspector.participants': 'Participantes',
'inspector.trackStats': 'Datos de la ruta',
// Reservations
'reservations.title': 'Reservas',

View File

@@ -811,6 +811,7 @@ const fr: Record<string, string> = {
'inspector.addRes': 'Réservation',
'inspector.editRes': 'Modifier la réservation',
'inspector.participants': 'Participants',
'inspector.trackStats': 'Données du parcours',
// Reservations
'reservations.title': 'Réservations',

View File

@@ -810,6 +810,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -811,6 +811,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'inspector.addRes': 'Prenotazione',
'inspector.editRes': 'Modifica prenotazione',
'inspector.participants': 'Partecipanti',
'inspector.trackStats': 'Dati del percorso',
// Reservations
'reservations.title': 'Prenotazioni',

View File

@@ -811,6 +811,7 @@ const nl: Record<string, string> = {
'inspector.addRes': 'Reservering',
'inspector.editRes': 'Reservering bewerken',
'inspector.participants': 'Deelnemers',
'inspector.trackStats': 'Routegegevens',
// Reservations
'reservations.title': 'Boekingen',

View File

@@ -811,6 +811,7 @@ const ru: Record<string, string> = {
'inspector.addRes': 'Бронирование',
'inspector.editRes': 'Редактировать бронирование',
'inspector.participants': 'Участники',
'inspector.trackStats': 'Данные маршрута',
// Reservations
'reservations.title': 'Бронирования',

View File

@@ -811,6 +811,7 @@ const zh: Record<string, string> = {
'inspector.addRes': '预订',
'inspector.editRes': '编辑预订',
'inspector.participants': '参与者',
'inspector.trackStats': '轨迹数据',
// Reservations
'reservations.title': '预订',

View File

@@ -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

View File

@@ -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) {

View File

@@ -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(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\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 <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\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 <trkpt>
// 3) If still nothing, extract full track geometry from <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trackDesc = (() => { const m = xml.match(/<trk[^>]*>[\s\S]*?<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null })();
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\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(/<ele[^>]*>([\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);
}