Merge pull request #189 from M-Enderle/feat/gpx-full-route-import

feat(add-gpx-tracks): adds better gpx track views
This commit is contained in:
Maurice
2026-03-31 20:17:22 +02:00
committed by GitHub
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