import { formatAppCoordinate, formatCanonicalCoordinate, formatGpsAccuracyMeters, parseAppDecimal } from './numberFormat.js' const MPS_TO_KNOTS = 1.9438444924406 /** Extra ms beyond the native timeout so hung browsers still reject. */ const TIMEOUT_GRACE_MS = 750 /** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */ export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown' export interface GeoCoordinates { lat: string lng: string /** SOG from GPS when available (kn), otherwise null. */ speedKn: number | null /** Estimated horizontal accuracy in metres, when reported by the browser. */ accuracyM: number | null /** Derived signal quality indicator for UI hints. */ signalQuality: GpsSignalQuality } /** Classifies GPS fix quality from reported accuracy (lower metres = better). */ export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality { if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown' if (accuracyM <= 15) return 'excellent' if (accuracyM <= 40) return 'good' if (accuracyM <= 100) return 'fair' return 'poor' } export function gpsQualityI18nKey(quality: GpsSignalQuality): string { return `logs.gps_quality_${quality}` } export type GeolocationPermissionState = PermissionState | 'unsupported' export type GeolocationErrorReason = | 'unavailable' | 'timeout' | 'permission_denied' | 'position_unavailable' | 'unknown' /** Maps browser / wrapper errors to a stable reason for i18n. */ export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason { if (error instanceof Error) { if (error.message === 'geolocation_unavailable') return 'unavailable' if (error.message === 'geolocation_timeout') return 'timeout' } const code = (error as GeolocationPositionError | undefined)?.code if (code === 1) return 'permission_denied' if (code === 2) return 'position_unavailable' if (code === 3) return 'timeout' return 'unknown' } /** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */ export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string { switch (reason) { case 'unavailable': return 'logs.gps_unavailable' case 'timeout': return 'logs.gps_timeout' case 'permission_denied': return 'logs.gps_permission_denied' case 'position_unavailable': return 'logs.gps_position_unavailable' default: return 'logs.gps_failed' } } export interface GetPositionOptions { timeoutMs?: number /** Manual fixes may use high accuracy; background auto-position should not. */ enableHighAccuracy?: boolean maximumAge?: number } export { formatGpsAccuracyMeters } export function parseGpsCoordinate(value: string): number | null { return parseAppDecimal(value.trim()) } /** Validates lat/lng and returns normalized strings for storage, or null. */ export function normalizeGpsCoordinates( lat: string, lng: string ): { lat: string; lng: string } | null { const latN = parseGpsCoordinate(lat) const lngN = parseGpsCoordinate(lng) if (latN == null || lngN == null) return null if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) } } /** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */ export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen' export function hasSeenGeolocationLiveIntro(): boolean { try { return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1' } catch { return false } } export function markGeolocationLiveIntroSeen(): void { try { localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1') } catch { // Private mode / quota — non-fatal } } export async function queryGeolocationPermission(): Promise { if (!navigator.geolocation) return 'unsupported' if (!navigator.permissions?.query) return 'prompt' try { const status = await navigator.permissions.query({ name: 'geolocation' }) return status.state } catch { return 'prompt' } } function normalizeGetPositionOptions( options: number | GetPositionOptions | undefined ): Required { const opts = typeof options === 'number' ? { timeoutMs: options } : (options ?? {}) const enableHighAccuracy = opts.enableHighAccuracy ?? true return { timeoutMs: opts.timeoutMs ?? 15000, enableHighAccuracy, maximumAge: opts.maximumAge ?? (enableHighAccuracy ? 0 : 120_000) } } function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinates { const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed) ? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1)) : null const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy) ? pos.coords.accuracy : null return { lat: formatAppCoordinate(pos.coords.latitude), lng: formatAppCoordinate(pos.coords.longitude), speedKn, accuracyM, signalQuality: classifyGpsAccuracyMeters(accuracyM) } } /** * Resolves with coordinates or rejects. Uses both the native timeout and an outer * watchdog so desktop browsers without GPS cannot hang indefinitely. */ export function getCurrentPosition( options?: number | GetPositionOptions ): Promise { const { timeoutMs, enableHighAccuracy, maximumAge } = normalizeGetPositionOptions(options) return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('geolocation_unavailable')) return } let settled = false const finish = (fn: () => void) => { if (settled) return settled = true window.clearTimeout(watchdog) fn() } const watchdog = window.setTimeout(() => { finish(() => reject(new Error('geolocation_timeout'))) }, timeoutMs + TIMEOUT_GRACE_MS) navigator.geolocation.getCurrentPosition( (pos) => { finish(() => resolve(positionFromGeolocationPosition(pos))) }, (err) => { finish(() => reject(err)) }, { enableHighAccuracy, timeout: timeoutMs, maximumAge } ) }) }