feat: replace active GPS logging with multiformat GPS track upload and OpenSeaMap Leaflet rendering

This commit is contained in:
2026-05-28 15:48:07 +02:00
parent 39637532ee
commit 1388f603c6
7 changed files with 587 additions and 333 deletions
+25
View File
@@ -15,6 +15,7 @@
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -22,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -2715,6 +2717,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2722,6 +2731,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.12.4", "version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
@@ -5299,6 +5318,12 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+2
View File
@@ -17,6 +17,7 @@
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -24,6 +25,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
+90
View File
@@ -1847,5 +1847,95 @@ body:has(.theme-cupertino) {
} }
} }
/* GPS Track Upload & Map Styling */
.track-upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed rgba(212, 175, 55, 0.3);
border-radius: 12px;
padding: 30px;
background: rgba(255, 255, 255, 0.02);
color: #94a3b8;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
box-sizing: border-box;
}
.track-upload-zone:hover,
.track-upload-zone.dragover {
border-color: #fbbf24;
background: rgba(251, 191, 36, 0.05);
color: #fbbf24;
}
.track-upload-icon {
color: #fbbf24;
margin-bottom: 12px;
}
.track-upload-text {
font-size: 14px;
font-weight: 500;
}
.track-upload-subtext {
font-size: 12px;
color: #64748b;
margin-top: 4px;
}
#openseamap-container {
width: 100%;
height: 400px;
border-radius: 12px;
border: 1px solid rgba(212, 175, 55, 0.2);
position: relative;
z-index: 1;
box-sizing: border-box;
}
.track-info-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.track-info-left {
display: flex;
align-items: center;
gap: 8px;
}
.track-info-name {
font-size: 14px;
font-weight: 500;
color: #f8fafc;
}
.track-info-stats {
font-size: 12px;
color: #94a3b8;
}
.track-error-msg {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 12px;
}
+228 -125
View File
@@ -1,21 +1,23 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Play, Square, Navigation } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Navigation } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { import {
startGpsTracking,
stopGpsTracking,
isGpsTrackingActive,
getDecryptedGpsTrack, getDecryptedGpsTrack,
downloadGpxFile, saveUploadedGpsTrack,
getDistanceMeters, deleteGpsTrack,
type GpsWaypoint downloadTrackFile,
parseTrackFile,
type GpsWaypoint,
type SavedGpsTrack
} from '../services/gpsTracker.js' } from '../services/gpsTracker.js'
interface LogEntryEditorProps { interface LogEntryEditorProps {
@@ -99,9 +101,13 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
const [weatherLoading, setWeatherLoading] = useState(false) const [weatherLoading, setWeatherLoading] = useState(false)
// GPS Tracking States // GPS Tracking States
const [waypoints, setWaypoints] = useState<GpsWaypoint[]>([]) const [savedTrack, setSavedTrack] = useState<SavedGpsTrack | null>(null)
const [trackingActive, setTrackingActive] = useState(false) const [dragOver, setDragOver] = useState(false)
const [tick, setTick] = useState(0) const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const mapInstanceRef = useRef<L.Map | null>(null)
// Auto-calculate Freshwater Consumption // Auto-calculate Freshwater Consumption
useEffect(() => { useEffect(() => {
@@ -187,11 +193,11 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
loadEntry() loadEntry()
}, [entryId]) }, [entryId])
// GPS Tracking logic // GPS Track Loader
const loadGpsTrack = async () => { const loadGpsTrack = async () => {
try { try {
const track = await getDecryptedGpsTrack(entryId) const track = await getDecryptedGpsTrack(entryId)
setWaypoints(track) setSavedTrack(track)
} catch (e) { } catch (e) {
console.warn('Failed to load GPS track:', e) console.warn('Failed to load GPS track:', e)
} }
@@ -199,72 +205,164 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
useEffect(() => { useEffect(() => {
loadGpsTrack() loadGpsTrack()
setTrackingActive(isGpsTrackingActive(entryId))
const interval = setInterval(() => {
setTrackingActive(isGpsTrackingActive(entryId))
if (isGpsTrackingActive(entryId)) {
loadGpsTrack()
}
}, 5000)
return () => clearInterval(interval)
}, [entryId]) }, [entryId])
// Leaflet Map Initialization and Rendering
useEffect(() => { useEffect(() => {
if (!trackingActive) return if (!savedTrack || !savedTrack.waypoints || savedTrack.waypoints.length === 0 || !mapContainerRef.current) {
const timer = setInterval(() => { if (mapInstanceRef.current) {
setTick((t) => t + 1) mapInstanceRef.current.remove()
}, 1000) mapInstanceRef.current = null
return () => clearInterval(timer) }
}, [trackingActive]) return
}
const handleStartTracking = async () => { const startWp = savedTrack.waypoints[0]
try { const map = L.map(mapContainerRef.current).setView([startWp.lat, startWp.lng], 13)
await startGpsTracking(logbookId, entryId, (newWp) => { mapInstanceRef.current = map
setWaypoints((prev) => [...prev, newWp])
}) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
setTrackingActive(true) maxZoom: 19,
} catch (err: any) { attribution: '&copy; <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
showAlert(err.message || 'Failed to start GPS tracking') }).addTo(map)
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data &copy; <a href="http://openseamap.org">OpenSeaMap</a> contributors'
}).addTo(map)
const latLngs = savedTrack.waypoints.map((wp) => [wp.lat, wp.lng] as [number, number])
const polyline = L.polyline(latLngs, {
color: '#fbbf24',
weight: 4,
opacity: 0.85
}).addTo(map)
map.fitBounds(polyline.getBounds(), { padding: [20, 20] })
if (savedTrack.waypoints.length > 0) {
L.circleMarker(latLngs[0], {
radius: 8,
fillColor: '#10b981',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
}).addTo(map).bindPopup('Start Position')
if (savedTrack.waypoints.length > 1) {
L.circleMarker(latLngs[latLngs.length - 1], {
radius: 8,
fillColor: '#ef4444',
fillOpacity: 0.9,
color: '#ffffff',
weight: 2
}).addTo(map).bindPopup('End Position')
}
}
setTimeout(() => {
map.invalidateSize()
}, 100)
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove()
mapInstanceRef.current = null
}
}
}, [savedTrack])
// GPX/KML/GeoJSON Upload Handlers
const handleFileUpload = async (file: File) => {
setUploadError(null)
const reader = new FileReader()
reader.onload = async (e) => {
try {
const text = e.target?.result as string
if (!text) {
throw new Error('File is empty')
}
const { waypoints: parsedWps, type: fileType } = parseTrackFile(text, file.name)
if (parsedWps.length === 0) {
throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.')
}
await saveUploadedGpsTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
await loadGpsTrack()
} catch (err: any) {
console.error('File parsing failed:', err)
setUploadError(err.message || 'Failed to parse track file.')
}
}
reader.onerror = () => {
setUploadError('Failed to read file.')
}
reader.readAsText(file)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileUpload(e.target.files[0])
} }
} }
const handleStopTracking = () => { const handleDragOver = (e: React.DragEvent) => {
stopGpsTracking() e.preventDefault()
setTrackingActive(false) setDragOver(true)
loadGpsTrack()
} }
const calculateTotalDistanceSailed = () => { const handleDragLeave = () => {
if (waypoints.length < 2) return 0 setDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFileUpload(e.dataTransfer.files[0])
}
}
const handleDeleteTrack = async () => {
if (!window.confirm(t('logs.gps_track_delete_confirm'))) {
return
}
try {
await deleteGpsTrack(logbookId, entryId)
setSavedTrack(null)
setUploadError(null)
} catch (err: any) {
showAlert(err.message || 'Failed to delete track')
}
}
const calculateTrackDistance = (wps: GpsWaypoint[]) => {
if (wps.length < 2) return 0
let totalMeters = 0 let totalMeters = 0
for (let i = 1; i < waypoints.length; i++) { for (let i = 1; i < wps.length; i++) {
totalMeters += getDistanceMeters( const lat1 = wps[i - 1].lat
waypoints[i - 1].lat, const lon1 = wps[i - 1].lng
waypoints[i - 1].lng, const lat2 = wps[i].lat
waypoints[i].lat, const lon2 = wps[i].lng
waypoints[i].lng
) const R = 6371e3
const phi1 = (lat1 * Math.PI) / 180
const phi2 = (lat2 * Math.PI) / 180
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
totalMeters += R * c
} }
return Number((totalMeters / 1852).toFixed(2)) return Number((totalMeters / 1852).toFixed(2))
} }
const calculateDurationStr = () => {
if (tick < 0 || waypoints.length < 2) return '00:00:00'
const first = waypoints[0].timestamp
const last = trackingActive ? Date.now() : waypoints[waypoints.length - 1].timestamp
const diffMs = last - first
if (diffMs <= 0) return '00:00:00'
const secs = Math.floor(diffMs / 1000) % 60
const mins = Math.floor(diffMs / (1000 * 60)) % 60
const hours = Math.floor(diffMs / (1000 * 60 * 60))
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(hours)}:${pad(mins)}:${pad(secs)}`
}
const handleGetGps = () => { const handleGetGps = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
showAlert('Geolocation is not supported by your browser') showAlert('Geolocation is not supported by your browser')
@@ -1020,73 +1118,78 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
</div> </div>
</div> </div>
{/* GPS Tracking Dashboard */} {/* GPS Track Upload & Map Visualization */}
<div className="form-card"> <div className="form-card">
<div className="form-header"> <div className="form-header">
<Navigation size={20} className={`form-icon ${trackingActive ? 'spin' : ''}`} style={{ color: trackingActive ? '#10b981' : '#f59e0b', animationDuration: '3s' }} /> <Navigation size={20} className="form-icon" />
<h3>{t('logs.gps_tracking_title')}</h3> <h3>{t('logs.gps_tracking_title')}</h3>
<span className={`sync-badge ${trackingActive ? 'synced' : 'local'}`} style={{ marginLeft: 'auto', background: trackingActive ? 'rgba(16, 185, 129, 0.15)' : 'rgba(148, 163, 184, 0.15)', color: trackingActive ? '#10b981' : '#94a3b8' }}>
{trackingActive ? t('logs.gps_tracking_status_active') : t('logs.gps_tracking_status_inactive')}
</span>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px', margin: '16px 0' }}> {uploadError && <div className="track-error-msg">{uploadError}</div>}
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_duration')}</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', fontFamily: 'monospace', color: '#f8fafc' }}>
{calculateDurationStr()}
</div>
</div>
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}> {!savedTrack ? (
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_distance')}</div> /* Upload Zone when no track is loaded */
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#f8fafc' }}> <div
{calculateTotalDistanceSailed()} sm className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
</div> onDragOver={handleDragOver}
</div> onDragLeave={handleDragLeave}
onDrop={handleDrop}
<div className="glass" style={{ padding: '12px', borderRadius: '8px', textAlign: 'center' }}> onClick={() => fileInputRef.current?.click()}
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{t('logs.gps_tracking_stat_waypoints')}</div>
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#f8fafc' }}>
{waypoints.length}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{!trackingActive ? (
<button
type="button"
className="btn primary"
onClick={handleStartTracking}
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
<Play size={16} />
{t('logs.gps_tracking_btn_start')}
</button>
) : (
<button
type="button"
className="btn primary"
onClick={handleStopTracking}
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center', background: '#ef4444' }}
>
<Square size={16} />
{t('logs.gps_tracking_btn_stop')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={() => downloadGpxFile(waypoints, date)}
disabled={waypoints.length === 0}
style={{ width: 'auto', padding: '10px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
<Download size={16} /> <input
{t('logs.gps_tracking_btn_gpx')} type="file"
</button> ref={fileInputRef}
</div> style={{ display: 'none' }}
accept=".gpx,.kml,.json,.geojson"
onChange={handleFileChange}
disabled={saving}
/>
<Download size={36} className="track-upload-icon" />
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
</div>
) : (
/* Map and Details when track is loaded */
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div className="track-info-header">
<div className="track-info-left">
<Navigation size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
</div>
<div className="track-info-stats">
<span style={{ marginRight: '12px' }}>
{t('logs.gps_tracking_stat_distance')}: <strong>{calculateTrackDistance(savedTrack.waypoints)} sm</strong>
</span>
<span>
{t('logs.gps_tracking_stat_waypoints')}: <strong>{savedTrack.waypoints.length}</strong>
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
>
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
</button>
<button
type="button"
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
</button>
</div>
</div>
{/* Leaflet Map Div */}
<div id="openseamap-container" ref={mapContainerRef} />
</div>
)}
</div> </div>
<PhotoCapture entryId={entryId} logbookId={logbookId} /> <PhotoCapture entryId={entryId} logbookId={logbookId} />
+7 -8
View File
@@ -108,15 +108,14 @@
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?", "photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
"confirm_yes": "Ja", "confirm_yes": "Ja",
"confirm_no": "Nein", "confirm_no": "Nein",
"gps_tracking_title": "GPS-Routenaufzeichnung (E2E-verschlüsselt)", "gps_tracking_title": "GPS-Route (OpenSeaMap)",
"gps_tracking_status_active": "Aufzeichnung läuft", "gps_tracking_btn_gpx": "Track-Datei herunterladen",
"gps_tracking_status_inactive": "Aufzeichnung inaktiv", "gps_tracking_stat_distance": "Track-Distanz",
"gps_tracking_btn_start": "Aufzeichnung starten",
"gps_tracking_btn_stop": "Aufzeichnung stoppen",
"gps_tracking_btn_gpx": "GPX herunterladen",
"gps_tracking_stat_duration": "Dauer",
"gps_tracking_stat_distance": "Distanz",
"gps_tracking_stat_waypoints": "Wegpunkte", "gps_tracking_stat_waypoints": "Wegpunkte",
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
"gps_track_upload_btn": "GPS-Track hochladen",
"gps_track_delete": "Track-Datei löschen",
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
"exporting": "Exportiere...", "exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen." "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen."
}, },
+8 -9
View File
@@ -108,15 +108,14 @@
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?", "photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
"confirm_yes": "Yes", "confirm_yes": "Yes",
"confirm_no": "No", "confirm_no": "No",
"gps_tracking_title": "GPS Route Tracking (E2E Encrypted)", "gps_tracking_title": "GPS Route (OpenSeaMap)",
"gps_tracking_status_active": "Tracking Active", "gps_tracking_btn_gpx": "Download Track File",
"gps_tracking_status_inactive": "Tracking Inactive", "gps_tracking_stat_distance": "Track Distance",
"gps_tracking_btn_start": "Start Tracking", "gps_tracking_stat_waypoints": "Points",
"gps_tracking_btn_stop": "Stop Tracking", "gps_track_upload_help": "Drag & drop a GPX, KML, or GeoJSON file here, or click to select",
"gps_tracking_btn_gpx": "Download GPX", "gps_track_upload_btn": "Upload GPS Track File",
"gps_tracking_stat_duration": "Duration", "gps_track_delete": "Delete Track File",
"gps_tracking_stat_distance": "Distance", "gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?",
"gps_tracking_stat_waypoints": "Waypoints",
"exporting": "Exporting...", "exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead." "share_unsupported": "Web sharing is not supported on this device. File downloaded instead."
}, },
+227 -191
View File
@@ -11,191 +11,62 @@ export interface GpsWaypoint {
heading?: number heading?: number
} }
let watchId: number | null = null export interface SavedGpsTrack {
let wakeLock: any = null waypoints: GpsWaypoint[]
let activeEntryId: string | null = null gpxContent: string // Holds the raw text file content (GPX, KML or GeoJSON)
let lastWaypoint: GpsWaypoint | null = null filename: string
let onWaypointAddedCallback: ((waypoint: GpsWaypoint) => void) | null = null fileType: string // 'gpx' | 'kml' | 'geojson'
// Haversine formula to compute distance in meters
export function getDistanceMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371e3 // Earth's radius in meters
const phi1 = (lat1 * Math.PI) / 180
const phi2 = (lat2 * Math.PI) / 180
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
} }
// Request Screen Wake Lock // Get the decrypted track data for a journal entry (with legacy array format compatibility)
async function requestWakeLock() { export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
try {
if ('wakeLock' in navigator) {
wakeLock = await (navigator as any).wakeLock.request('screen')
console.log('GPS Tracker: Screen Wake Lock acquired')
}
} catch (err) {
console.warn('GPS Tracker: Wake Lock request failed:', err)
}
}
// Release Screen Wake Lock
function releaseWakeLock() {
if (wakeLock) {
wakeLock.release().then(() => {
wakeLock = null;
console.log('GPS Tracker: Screen Wake Lock released')
})
}
}
// Handle visibility changes to re-acquire wake lock if tab is minimized/restored
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', async () => {
if (watchId !== null && document.visibilityState === 'visible') {
await requestWakeLock()
}
})
}
// Start GPS Tracking Run
export async function startGpsTracking(
logbookId: string,
entryId: string,
onWaypointAdded?: (waypoint: GpsWaypoint) => void
): Promise<void> {
if (watchId !== null) {
throw new Error('Tracking is already active')
}
if (!navigator.geolocation) {
throw new Error('Geolocation is not supported by your device')
}
activeEntryId = entryId
onWaypointAddedCallback = onWaypointAdded || null
lastWaypoint = null
// Acquire Screen Wake Lock to prevent standby/sleep
await requestWakeLock()
// Load last waypoint from existing track to resume or filter correctly
try {
const existingTrack = await getDecryptedGpsTrack(entryId)
if (existingTrack && existingTrack.length > 0) {
lastWaypoint = existingTrack[existingTrack.length - 1]
}
} catch (e) {
console.warn('Could not read existing waypoints for filtering:', e)
}
watchId = navigator.geolocation.watchPosition(
async (position) => {
const { latitude, longitude, speed, heading } = position.coords
const now = Date.now()
// Convert speed from m/s to knots (1 m/s = 1.94384 knots)
const speedKnots = speed !== null && speed !== undefined && speed >= 0 ? speed * 1.94384 : undefined
const headingDeg = heading !== null && heading !== undefined && heading >= 0 ? heading : undefined
const newWaypoint: GpsWaypoint = {
timestamp: now,
lat: Number(latitude.toFixed(6)),
lng: Number(longitude.toFixed(6)),
speedKnots: speedKnots !== undefined ? Number(speedKnots.toFixed(1)) : undefined,
heading: headingDeg !== undefined ? Number(headingDeg.toFixed(0)) : undefined
}
// Filter: Only add if distance to last waypoint > 15 meters OR if 30 seconds elapsed
if (lastWaypoint) {
const distance = getDistanceMeters(lastWaypoint.lat, lastWaypoint.lng, newWaypoint.lat, newWaypoint.lng)
const timeElapsed = now - lastWaypoint.timestamp
// Throttle check
if (distance < 15 && timeElapsed < 30000) {
// Skip insignificant waypoint
return
}
}
// Save waypoint
try {
await saveWaypoint(logbookId, entryId, newWaypoint)
lastWaypoint = newWaypoint
if (onWaypointAddedCallback) {
onWaypointAddedCallback(newWaypoint)
}
} catch (err) {
console.error('GPS Tracker: Failed to save waypoint:', err)
}
},
(error) => {
console.error('GPS Geolocation tracking error:', error)
},
{
enableHighAccuracy: true,
maximumAge: 0
}
)
}
// Stop GPS Tracking Run
export function stopGpsTracking(): void {
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId)
watchId = null
}
releaseWakeLock()
activeEntryId = null
onWaypointAddedCallback = null
lastWaypoint = null
console.log('GPS Tracker: Stopped tracking')
}
// Is Tracking currently running for this entry?
export function isGpsTrackingActive(entryId?: string): boolean {
if (entryId) {
return watchId !== null && activeEntryId === entryId
}
return watchId !== null
}
// Get the decrypted waypoints array for a journal entry
export async function getDecryptedGpsTrack(entryId: string): Promise<GpsWaypoint[]> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) { if (!masterKey) {
throw new Error('Master key not found. Please log in.') throw new Error('Master key not found. Please log in.')
} }
const record = await db.gpsTracks.get(entryId) const record = await db.gpsTracks.get(entryId)
if (!record) return [] if (!record) return null
try { try {
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
return Array.isArray(decrypted) ? decrypted : [] if (Array.isArray(decrypted)) {
// Legacy format (just coordinate array)
return {
waypoints: decrypted,
gpxContent: generateLegacyGpxString(decrypted, 'legacy'),
filename: 'track_legacy.gpx',
fileType: 'gpx'
}
}
return decrypted
} catch (err) { } catch (err) {
console.error('Failed to decrypt GPS track:', err) console.error('Failed to decrypt GPS track:', err)
return [] return null
} }
} }
// Helper: append waypoint, encrypt, and save/queue sync // Encrypt and save uploaded GPS track to local Dexie and remote sync
async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWaypoint): Promise<void> { export async function saveUploadedGpsTrack(
logbookId: string,
entryId: string,
gpxContent: string,
waypoints: GpsWaypoint[],
filename: string,
fileType: string
): Promise<void> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found. Please log in.') if (!masterKey) throw new Error('Master key not found. Please log in.')
// Fetch current waypoints const trackData: SavedGpsTrack = {
const waypoints = await getDecryptedGpsTrack(entryId) waypoints,
waypoints.push(waypoint) gpxContent,
filename,
fileType
}
// Encrypt array // Encrypt JSON
const encrypted = await encryptJson(waypoints, masterKey) const encrypted = await encryptJson(trackData, masterKey)
const now = new Date().toISOString() const now = new Date().toISOString()
// Save to Dexie // Save to Dexie
@@ -208,9 +79,9 @@ async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWay
updatedAt: now updatedAt: now
}) })
// Add to Sync queue (payloadId is entryId here) // Add to Sync queue (payloadId is entryId)
await db.syncQueue.put({ await db.syncQueue.put({
action: 'create', // upsert mapping is used on server action: 'create',
type: 'gpsTrack', type: 'gpsTrack',
payloadId: entryId, payloadId: entryId,
logbookId, logbookId,
@@ -222,17 +93,200 @@ async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWay
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} }
// Generate GPX file contents from Waypoints // Delete GPS track from local DB and sync queue
export function generateGpxString(waypoints: GpsWaypoint[], dateStr: string): string { export async function deleteGpsTrack(logbookId: string, entryId: string): Promise<void> {
const now = new Date().toISOString()
// Delete from Dexie
await db.gpsTracks.delete(entryId)
// Add to Sync queue
await db.syncQueue.put({
action: 'delete',
type: 'gpsTrack',
payloadId: entryId,
logbookId,
data: '',
updatedAt: now
})
// Trigger sync
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
// Download the track file exactly as uploaded
export function downloadTrackFile(track: SavedGpsTrack): void {
const blob = new Blob([track.gpxContent], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = track.filename || 'track.gpx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// Main parser entry point
export function parseTrackFile(text: string, filename: string): { waypoints: GpsWaypoint[]; type: string } {
const lowerName = filename.toLowerCase()
if (lowerName.endsWith('.kml') || text.includes('<kml')) {
return { waypoints: parseKmlFile(text), type: 'kml' }
} else if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
} else {
return { waypoints: parseGpxFile(text), type: 'gpx' }
}
}
// 1. GPX Parser
export function parseGpxFile(gpxText: string): GpsWaypoint[] {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(gpxText, 'text/xml')
const trackPoints = xmlDoc.getElementsByTagName('trkpt')
const waypoints: GpsWaypoint[] = []
for (let i = 0; i < trackPoints.length; i++) {
const el = trackPoints[i]
const lat = parseFloat(el.getAttribute('lat') || '')
const lon = parseFloat(el.getAttribute('lon') || '')
if (isNaN(lat) || isNaN(lon)) continue
const timeEl = el.getElementsByTagName('time')[0]
const timestamp = timeEl && timeEl.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
const speedEl = el.getElementsByTagName('speed')[0]
const speedKnots = speedEl && speedEl.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
const courseEl = el.getElementsByTagName('course')[0] || el.getElementsByTagName('heading')[0]
const heading = courseEl && courseEl.textContent ? parseFloat(courseEl.textContent) : undefined
waypoints.push({
timestamp,
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6)),
speedKnots: speedKnots !== undefined && !isNaN(speedKnots) ? Number(speedKnots.toFixed(1)) : undefined,
heading: heading !== undefined && !isNaN(heading) ? Number(heading.toFixed(0)) : undefined
})
}
return waypoints
}
// 2. KML Parser
export function parseKmlFile(kmlText: string): GpsWaypoint[] {
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(kmlText, 'text/xml')
const waypoints: GpsWaypoint[] = []
// Check for standard KML <coordinates> tags
const coordsTags = xmlDoc.getElementsByTagName('coordinates')
for (let i = 0; i < coordsTags.length; i++) {
const text = coordsTags[i].textContent || ''
const coordStrings = text.trim().split(/\s+/)
for (const str of coordStrings) {
const parts = str.split(',')
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
const lat = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lon)) {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
}
}
// Check for gx:coord extensions (commonly used in Google Earth tracks)
const gxCoords = xmlDoc.getElementsByTagName('gx:coord')
if (gxCoords.length > 0) {
for (let i = 0; i < gxCoords.length; i++) {
const text = gxCoords[i].textContent || ''
const parts = text.trim().split(/\s+/)
if (parts.length >= 2) {
const lon = parseFloat(parts[0])
const lat = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lon)) {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
}
}
return waypoints
}
// 3. GeoJSON Parser
export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
const waypoints: GpsWaypoint[] = []
try {
const data = JSON.parse(geoJsonText)
const processGeometry = (geom: any) => {
if (!geom) return
if (geom.type === 'LineString' && Array.isArray(geom.coordinates)) {
for (const coord of geom.coordinates) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
} else if (geom.type === 'MultiLineString' && Array.isArray(geom.coordinates)) {
for (const line of geom.coordinates) {
if (Array.isArray(line)) {
for (const coord of line) {
const lon = coord[0]
const lat = coord[1]
if (typeof lat === 'number' && typeof lon === 'number') {
waypoints.push({
timestamp: Date.now(),
lat: Number(lat.toFixed(6)),
lng: Number(lon.toFixed(6))
})
}
}
}
}
}
};
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
for (const feature of data.features) {
if (feature && feature.geometry) {
processGeometry(feature.geometry)
}
}
} else if (data.type === 'Feature' && data.geometry) {
processGeometry(data.geometry)
} else if (data.type === 'LineString' || data.type === 'MultiLineString') {
processGeometry(data)
}
} catch (err) {
console.error('Failed to parse GeoJSON track:', err)
}
return waypoints
}
// Generate legacy fallback GPX string
function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): string {
const trkpts = waypoints const trkpts = waypoints
.map((wp) => { .map((wp) => {
const timeISO = new Date(wp.timestamp).toISOString() const timeISO = new Date(wp.timestamp).toISOString()
const courseTag = wp.heading !== undefined ? `<course>${wp.heading}</course>` : ''
const speedTag = wp.speedKnots !== undefined ? `<speed>${(wp.speedKnots / 1.94384).toFixed(2)}</speed>` : '' // speed back in m/s for GPX spec
return ` <trkpt lat="${wp.lat}" lon="${wp.lng}"> return ` <trkpt lat="${wp.lat}" lon="${wp.lng}">
<time>${timeISO}</time> <time>${timeISO}</time>
${courseTag}
${speedTag}
</trkpt>` </trkpt>`
}) })
.join('\n') .join('\n')
@@ -250,21 +304,3 @@ ${trkpts}
</trk> </trk>
</gpx>` </gpx>`
} }
// Download GPX file client-side
export function downloadGpxFile(waypoints: GpsWaypoint[], dateStr: string): void {
if (waypoints.length === 0) {
return
}
const gpxContent = generateGpxString(waypoints, dateStr)
const blob = new Blob([gpxContent], { type: 'application/gpx+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `track_${dateStr.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.gpx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}