feat: adds better gpx track views
This commit is contained in:
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ↓{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' }}>
|
||||
|
||||
@@ -815,6 +815,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'inspector.addRes': 'حجز',
|
||||
'inspector.editRes': 'تعديل الحجز',
|
||||
'inspector.participants': 'المشاركون',
|
||||
'inspector.trackStats': 'بيانات المسار',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'الحجوزات',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -811,6 +811,7 @@ const ru: Record<string, string> = {
|
||||
'inspector.addRes': 'Бронирование',
|
||||
'inspector.editRes': 'Редактировать бронирование',
|
||||
'inspector.participants': 'Участники',
|
||||
'inspector.trackStats': 'Данные маршрута',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Бронирования',
|
||||
|
||||
@@ -811,6 +811,7 @@ const zh: Record<string, string> = {
|
||||
'inspector.addRes': '预订',
|
||||
'inspector.editRes': '编辑预订',
|
||||
'inspector.participants': '参与者',
|
||||
'inspector.trackStats': '轨迹数据',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': '预订',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user