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
+227 -191
View File
@@ -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<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[]> {
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
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<void> {
// 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<void> {
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<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
.map((wp) => {
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}">
<time>${timeISO}</time>
${courseTag}
${speedTag}
</trkpt>`
})
.join('\n')
@@ -250,21 +304,3 @@ ${trkpts}
</trk>
</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)
}