diff --git a/client/package-lock.json b/client/package-lock.json index 5230d5c..cc2f065 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -22,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2715,6 +2717,13 @@ "dev": true, "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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2722,6 +2731,16 @@ "dev": true, "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": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -5299,6 +5318,12 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/client/package.json b/client/package.json index fa572cb..c1c80d7 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", "jspdf": "^4.2.1", + "leaflet": "^1.9.4", "lucide-react": "^1.16.0", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -24,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/client/src/App.css b/client/src/App.css index eff6ca5..18e9e86 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; +} + diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index fca4c5c..058f6e1 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -1,21 +1,23 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.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 { useDialog } from './ModalDialog.tsx' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' import { - startGpsTracking, - stopGpsTracking, - isGpsTrackingActive, getDecryptedGpsTrack, - downloadGpxFile, - getDistanceMeters, - type GpsWaypoint + saveUploadedGpsTrack, + deleteGpsTrack, + downloadTrackFile, + parseTrackFile, + type GpsWaypoint, + type SavedGpsTrack } from '../services/gpsTracker.js' interface LogEntryEditorProps { @@ -99,9 +101,13 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE const [weatherLoading, setWeatherLoading] = useState(false) // GPS Tracking States - const [waypoints, setWaypoints] = useState([]) - const [trackingActive, setTrackingActive] = useState(false) - const [tick, setTick] = useState(0) + const [savedTrack, setSavedTrack] = useState(null) + const [dragOver, setDragOver] = useState(false) + const [uploadError, setUploadError] = useState(null) + const fileInputRef = useRef(null) + + const mapContainerRef = useRef(null) + const mapInstanceRef = useRef(null) // Auto-calculate Freshwater Consumption useEffect(() => { @@ -187,11 +193,11 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE loadEntry() }, [entryId]) - // GPS Tracking logic + // GPS Track Loader const loadGpsTrack = async () => { try { const track = await getDecryptedGpsTrack(entryId) - setWaypoints(track) + setSavedTrack(track) } catch (e) { console.warn('Failed to load GPS track:', e) } @@ -199,72 +205,164 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE useEffect(() => { loadGpsTrack() - setTrackingActive(isGpsTrackingActive(entryId)) - - const interval = setInterval(() => { - setTrackingActive(isGpsTrackingActive(entryId)) - if (isGpsTrackingActive(entryId)) { - loadGpsTrack() - } - }, 5000) - - return () => clearInterval(interval) }, [entryId]) + // Leaflet Map Initialization and Rendering useEffect(() => { - if (!trackingActive) return - const timer = setInterval(() => { - setTick((t) => t + 1) - }, 1000) - return () => clearInterval(timer) - }, [trackingActive]) + if (!savedTrack || !savedTrack.waypoints || savedTrack.waypoints.length === 0 || !mapContainerRef.current) { + if (mapInstanceRef.current) { + mapInstanceRef.current.remove() + mapInstanceRef.current = null + } + return + } - const handleStartTracking = async () => { - try { - await startGpsTracking(logbookId, entryId, (newWp) => { - setWaypoints((prev) => [...prev, newWp]) - }) - setTrackingActive(true) - } catch (err: any) { - showAlert(err.message || 'Failed to start GPS tracking') + const startWp = savedTrack.waypoints[0] + const map = L.map(mapContainerRef.current).setView([startWp.lat, startWp.lng], 13) + mapInstanceRef.current = map + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(map) + + L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', { + maxZoom: 18, + attribution: 'Map data © OpenSeaMap 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) => { + if (e.target.files && e.target.files.length > 0) { + handleFileUpload(e.target.files[0]) } } - const handleStopTracking = () => { - stopGpsTracking() - setTrackingActive(false) - loadGpsTrack() + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(true) } - const calculateTotalDistanceSailed = () => { - if (waypoints.length < 2) return 0 + const handleDragLeave = () => { + 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 - for (let i = 1; i < waypoints.length; i++) { - totalMeters += getDistanceMeters( - waypoints[i - 1].lat, - waypoints[i - 1].lng, - waypoints[i].lat, - waypoints[i].lng - ) + for (let i = 1; i < wps.length; i++) { + const lat1 = wps[i - 1].lat + const lon1 = wps[i - 1].lng + const lat2 = wps[i].lat + const lon2 = wps[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)) } - 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 = () => { if (!navigator.geolocation) { showAlert('Geolocation is not supported by your browser') @@ -1020,73 +1118,78 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE - {/* GPS Tracking Dashboard */} + {/* GPS Track Upload & Map Visualization */}
- +

{t('logs.gps_tracking_title')}

- - {trackingActive ? t('logs.gps_tracking_status_active') : t('logs.gps_tracking_status_inactive')} -
-
-
-
{t('logs.gps_tracking_stat_duration')}
-
- {calculateDurationStr()} -
-
+ {uploadError &&
{uploadError}
} -
-
{t('logs.gps_tracking_stat_distance')}
-
- {calculateTotalDistanceSailed()} sm -
-
- -
-
{t('logs.gps_tracking_stat_waypoints')}
-
- {waypoints.length} -
-
-
- -
- {!trackingActive ? ( - - ) : ( - - )} - - -
+ + +
{t('logs.gps_track_upload_btn')}
+
{t('logs.gps_track_upload_help')}
+
+ ) : ( + /* Map and Details when track is loaded */ +
+
+
+ + {savedTrack.filename || 'track'} +
+
+ + {t('logs.gps_tracking_stat_distance')}: {calculateTrackDistance(savedTrack.waypoints)} sm + + + {t('logs.gps_tracking_stat_waypoints')}: {savedTrack.waypoints.length} + +
+
+ + +
+
+ + {/* Leaflet Map Div */} +
+
+ )}
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 4b2b902..89ccbac 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -108,15 +108,14 @@ "photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?", "confirm_yes": "Ja", "confirm_no": "Nein", - "gps_tracking_title": "GPS-Routenaufzeichnung (E2E-verschlüsselt)", - "gps_tracking_status_active": "Aufzeichnung läuft", - "gps_tracking_status_inactive": "Aufzeichnung inaktiv", - "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_title": "GPS-Route (OpenSeaMap)", + "gps_tracking_btn_gpx": "Track-Datei herunterladen", + "gps_tracking_stat_distance": "Track-Distanz", "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...", "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen." }, diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index e8a59a1..d25ecaa 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -108,15 +108,14 @@ "photo_delete_confirm": "Are you sure you want to permanently delete this photo?", "confirm_yes": "Yes", "confirm_no": "No", - "gps_tracking_title": "GPS Route Tracking (E2E Encrypted)", - "gps_tracking_status_active": "Tracking Active", - "gps_tracking_status_inactive": "Tracking Inactive", - "gps_tracking_btn_start": "Start Tracking", - "gps_tracking_btn_stop": "Stop Tracking", - "gps_tracking_btn_gpx": "Download GPX", - "gps_tracking_stat_duration": "Duration", - "gps_tracking_stat_distance": "Distance", - "gps_tracking_stat_waypoints": "Waypoints", + "gps_tracking_title": "GPS Route (OpenSeaMap)", + "gps_tracking_btn_gpx": "Download Track File", + "gps_tracking_stat_distance": "Track Distance", + "gps_tracking_stat_waypoints": "Points", + "gps_track_upload_help": "Drag & drop a GPX, KML, or GeoJSON file here, or click to select", + "gps_track_upload_btn": "Upload GPS Track File", + "gps_track_delete": "Delete Track File", + "gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?", "exporting": "Exporting...", "share_unsupported": "Web sharing is not supported on this device. File downloaded instead." }, diff --git a/client/src/services/gpsTracker.ts b/client/src/services/gpsTracker.ts index 25b2eec..a22c8b2 100644 --- a/client/src/services/gpsTracker.ts +++ b/client/src/services/gpsTracker.ts @@ -11,191 +11,62 @@ export interface GpsWaypoint { heading?: number } -let watchId: number | null = null -let wakeLock: any = null -let activeEntryId: string | null = null -let lastWaypoint: GpsWaypoint | null = null -let onWaypointAddedCallback: ((waypoint: GpsWaypoint) => void) | null = null - -// 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 +export interface SavedGpsTrack { + waypoints: GpsWaypoint[] + gpxContent: string // Holds the raw text file content (GPX, KML or GeoJSON) + filename: string + fileType: string // 'gpx' | 'kml' | 'geojson' } -// Request Screen Wake Lock -async function requestWakeLock() { - 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 { - 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 { +// Get the decrypted track data for a journal entry (with legacy array format compatibility) +export async function getDecryptedGpsTrack(entryId: string): Promise { const masterKey = getActiveMasterKey() if (!masterKey) { throw new Error('Master key not found. Please log in.') } const record = await db.gpsTracks.get(entryId) - if (!record) return [] + if (!record) return null try { 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) { console.error('Failed to decrypt GPS track:', err) - return [] + return null } } -// Helper: append waypoint, encrypt, and save/queue sync -async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWaypoint): Promise { +// Encrypt and save uploaded GPS track to local Dexie and remote sync +export async function saveUploadedGpsTrack( + logbookId: string, + entryId: string, + gpxContent: string, + waypoints: GpsWaypoint[], + filename: string, + fileType: string +): Promise { const masterKey = getActiveMasterKey() if (!masterKey) throw new Error('Master key not found. Please log in.') - // Fetch current waypoints - const waypoints = await getDecryptedGpsTrack(entryId) - waypoints.push(waypoint) + const trackData: SavedGpsTrack = { + waypoints, + gpxContent, + filename, + fileType + } - // Encrypt array - const encrypted = await encryptJson(waypoints, masterKey) + // Encrypt JSON + const encrypted = await encryptJson(trackData, masterKey) const now = new Date().toISOString() // Save to Dexie @@ -208,9 +79,9 @@ async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWay updatedAt: now }) - // Add to Sync queue (payloadId is entryId here) + // Add to Sync queue (payloadId is entryId) await db.syncQueue.put({ - action: 'create', // upsert mapping is used on server + action: 'create', type: 'gpsTrack', payloadId: entryId, logbookId, @@ -222,17 +93,200 @@ async function saveWaypoint(logbookId: string, entryId: string, waypoint: GpsWay syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) } -// Generate GPX file contents from Waypoints -export function generateGpxString(waypoints: GpsWaypoint[], dateStr: string): string { +// Delete GPS track from local DB and sync queue +export async function deleteGpsTrack(logbookId: string, entryId: string): Promise { + 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(' 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 .map((wp) => { const timeISO = new Date(wp.timestamp).toISOString() - const courseTag = wp.heading !== undefined ? `${wp.heading}` : '' - const speedTag = wp.speedKnots !== undefined ? `${(wp.speedKnots / 1.94384).toFixed(2)}` : '' // speed back in m/s for GPX spec return ` - ${courseTag} - ${speedTag} ` }) .join('\n') @@ -250,21 +304,3 @@ ${trkpts} ` } - -// 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) -}