/** * GeoTracker.js — a framework-agnostic GPS live-position + trail-recording * engine. It has **no** dependency on OpenLayers, Bootstrap, SQLocal, or any * LUPMIS code, so it can be dropped into any web project. Persistence and * server sync are provided by the host through small adapter objects. * * ───────────────────────────────────────────────────────────────────────── * STORAGE ADAPTER (required for recording) — all methods may be async: * createTrail(meta) -> trailId // meta: {uuid,name,startedAt,...} * addPoint(trailId, point) -> void // point: normalized fix (see below) * finishTrail(trailId, summary)-> void // summary: {endedAt,pointCount,distanceM} * getUnsyncedTrails() -> Array // trails with synced=0 and completed * getTrailPoints(trailId) -> Array * markTrailSynced(trailId, remoteId) -> void * * SYNC ADAPTER (optional) — store-and-forward: * pushTrail(trail, points) -> { remoteId } | throws * isOnline?() -> boolean // optional connectivity probe * * NORMALIZED FIX shape emitted on 'position' and stored via addPoint: * { lon, lat, accuracy, altitude, altitudeAccuracy, heading, speed, * satellites:null, timestamp } * (satellites is always null on the web Geolocation API — kept for parity * with native builds that can populate it.) * ───────────────────────────────────────────────────────────────────────── * * @module geotracker/GeoTracker */ import { haversineMeters } from './geo-utils.js'; /** @typedef {'idle'|'watching'|'recording'} GeoTrackerState */ const DEFAULTS = { /** Minimum metres between two recorded trail points. */ minDistanceM: 5, /** Ignore fixes arriving faster than this (throttle, ms). */ minIntervalMs: 1000, /** Record a point at least this often even when stationary (heartbeat, ms). */ heartbeatMs: 20000, /** Drop fixes worse than this horizontal accuracy (metres). 0 = accept all. */ maxAccuracyM: 50, /** navigator.geolocation options. */ enableHighAccuracy: true, timeoutMs: 15000, maximumAgeMs: 0, }; export class GeoTracker { /** * @param {object} [options] * @param {object} [options.storage] storage adapter (see module docs) * @param {object} [options.sync] sync adapter (see module docs) * @param {Geolocation} [options.geolocation] inject navigator.geolocation (for tests) * @param {number} [options.minDistanceM] * @param {number} [options.minIntervalMs] * @param {number} [options.heartbeatMs] * @param {number} [options.maxAccuracyM] * @param {boolean} [options.enableHighAccuracy] */ constructor(options = {}) { this.opts = { ...DEFAULTS, ...options }; this.storage = options.storage || null; this.sync = options.sync || null; this._geo = options.geolocation || (typeof navigator !== 'undefined' ? navigator.geolocation : null); /** @type {GeoTrackerState} */ this._state = 'idle'; this._watchId = null; this._live = false; // live readout requested this._recording = false; // recording in progress this._activeTrailId = null; this._activeTrailUuid = null; this._lastRecorded = null; // last point actually written {lon,lat,timestamp} this._lastRecordedAt = 0; this._distanceM = 0; this._pointCount = 0; this._lastFix = null; // most recent normalized fix (any quality) /** @type {Record>} */ this._listeners = Object.create(null); } // ── Events ──────────────────────────────────────────────────────────── /** * Subscribe to an event. Returns an unsubscribe function. * Events: 'position' | 'point' | 'statechange' | 'trailstart' | * 'trailstop' | 'error' | 'syncstatus' * @param {string} event * @param {Function} cb * @returns {() => void} */ on(event, cb) { (this._listeners[event] || (this._listeners[event] = new Set())).add(cb); return () => this._listeners[event]?.delete(cb); } _emit(event, payload) { const set = this._listeners[event]; if (!set) return; for (const cb of set) { try { cb(payload); } catch (err) { console.error(`[GeoTracker] listener for "${event}" threw`, err); } } } // ── Public state ────────────────────────────────────────────────────── /** @returns {GeoTrackerState} */ get state() { return this._state; } get isRecording() { return this._recording; } get lastFix() { return this._lastFix; } get isSupported() { return !!this._geo; } _setState(s) { if (this._state === s) return; this._state = s; this._emit('statechange', s); } // ── Live readout (watch without recording) ────────────────────────────── /** * Begin a position watch purely for the live readout (no trail is recorded). * Safe to call repeatedly. */ startLive() { if (!this._geo) { this._emit('error', new Error('Geolocation not supported')); return; } this._live = true; this._ensureWatch(); } /** Stop the live readout. Has no effect while a recording is in progress. */ stopLive() { this._live = false; if (!this._recording) this._teardownWatch(); } /** * One-shot position request (e.g. for a "Locate me" button). Resolves with a * normalized fix. Does not start/stop the watch. * @returns {Promise} */ getCurrentPosition() { return new Promise((resolve, reject) => { if (!this._geo) { reject(new Error('Geolocation not supported')); return; } this._geo.getCurrentPosition( (pos) => { const fix = GeoTracker.normalize(pos); this._lastFix = fix; this._emit('position', fix); resolve(fix); }, (err) => { this._emit('error', err); reject(err); }, { enableHighAccuracy: this.opts.enableHighAccuracy, timeout: this.opts.timeoutMs, maximumAge: this.opts.maximumAgeMs, } ); }); } // ── Recording ─────────────────────────────────────────────────────────── /** * Start recording a new trail. Creates the trail in storage, then records * filtered points as the device moves. * @param {object} [meta] e.g. { name, districtId } * @returns {Promise<{trailId:*, uuid:string}>} */ async startRecording(meta = {}) { if (!this._geo) throw new Error('Geolocation not supported'); if (!this.storage) throw new Error('GeoTracker: no storage adapter configured'); if (this._recording) return { trailId: this._activeTrailId, uuid: this._activeTrailUuid }; const uuid = GeoTracker.uuid(); const startedAt = new Date().toISOString(); const trailMeta = { uuid, name: meta.name || null, startedAt, ...meta }; const trailId = await this.storage.createTrail(trailMeta); this._activeTrailId = trailId; this._activeTrailUuid = uuid; this._lastRecorded = null; this._lastRecordedAt = 0; this._distanceM = 0; this._pointCount = 0; this._recording = true; this._ensureWatch(); this._setState('recording'); this._emit('trailstart', { trailId, uuid, startedAt }); return { trailId, uuid }; } /** * Stop the active recording, finalise the trail summary, and (if a sync * adapter is present) attempt to push it immediately. * @returns {Promise<{trailId:*, pointCount:number, distanceM:number, synced:boolean}>} */ async stopRecording() { if (!this._recording) return null; const trailId = this._activeTrailId; const endedAt = new Date().toISOString(); const summary = { endedAt, pointCount: this._pointCount, distanceM: this._distanceM }; this._recording = false; if (!this._live) this._teardownWatch(); this._setState(this._live ? 'watching' : 'idle'); try { await this.storage.finishTrail(trailId, summary); } catch (err) { this._emit('error', err); } this._emit('trailstop', { trailId, ...summary }); let synced = false; if (this.sync) { try { synced = await this._syncTrail(trailId); } catch (err) { this._emit('error', err); } } this._activeTrailId = null; this._activeTrailUuid = null; return { trailId, pointCount: summary.pointCount, distanceM: summary.distanceM, synced }; } // ── Sync (store-and-forward) ──────────────────────────────────────────── /** * Push all completed-but-unsynced trails to the server via the sync adapter. * Call on app start and whenever connectivity returns. * @returns {Promise<{pushed:number, failed:number}>} */ async syncPending() { if (!this.sync || !this.storage) return { pushed: 0, failed: 0 }; if (this.sync.isOnline && !this.sync.isOnline()) return { pushed: 0, failed: 0 }; let pushed = 0, failed = 0; const trails = await this.storage.getUnsyncedTrails(); for (const trail of trails) { try { const ok = await this._syncTrail(trail.id ?? trail.trailId, trail); ok ? pushed++ : failed++; } catch (err) { failed++; this._emit('error', err); } } this._emit('syncstatus', { pushed, failed }); return { pushed, failed }; } /** @private push a single trail by id. */ async _syncTrail(trailId, trailRow) { const points = await this.storage.getTrailPoints(trailId); const trail = trailRow || { id: trailId }; const result = await this.sync.pushTrail(trail, points); const remoteId = result && (result.remoteId ?? result.id ?? null); await this.storage.markTrailSynced(trailId, remoteId); return true; } // ── Internal watch handling ────────────────────────────────────────────── /** @private start the geolocation watch if not already running. */ _ensureWatch() { if (this._watchId != null || !this._geo) { if (this._state === 'idle' && this._live) this._setState('watching'); return; } this._watchId = this._geo.watchPosition( (pos) => this._onFix(pos), (err) => this._emit('error', err), { enableHighAccuracy: this.opts.enableHighAccuracy, timeout: this.opts.timeoutMs, maximumAge: this.opts.maximumAgeMs, } ); if (!this._recording) this._setState('watching'); } /** @private stop the geolocation watch. */ _teardownWatch() { if (this._watchId != null && this._geo) { this._geo.clearWatch(this._watchId); } this._watchId = null; } /** @private handle a raw Geolocation fix. */ async _onFix(pos) { const fix = GeoTracker.normalize(pos); this._lastFix = fix; this._emit('position', fix); // always emit for the live readout if (!this._recording) return; const { minIntervalMs, minDistanceM, heartbeatMs, maxAccuracyM } = this.opts; const now = fix.timestamp; // Throttle very frequent fixes. if (this._lastRecordedAt && (now - this._lastRecordedAt) < minIntervalMs) return; // Drop low-quality fixes (unless this is the very first point). if (maxAccuracyM > 0 && fix.accuracy != null && fix.accuracy > maxAccuracyM && this._lastRecorded) return; let keep = false; let stepM = 0; if (!this._lastRecorded) { keep = true; // always record the first point } else { stepM = haversineMeters(this._lastRecorded.lon, this._lastRecorded.lat, fix.lon, fix.lat); const elapsed = now - this._lastRecordedAt; if (stepM >= minDistanceM || elapsed >= heartbeatMs) keep = true; } if (!keep) return; if (this._lastRecorded) this._distanceM += stepM; this._pointCount += 1; this._lastRecorded = { lon: fix.lon, lat: fix.lat, timestamp: now }; this._lastRecordedAt = now; try { await this.storage.addPoint(this._activeTrailId, { ...fix, seq: this._pointCount }); this._emit('point', { trailId: this._activeTrailId, seq: this._pointCount, point: fix, distanceM: this._distanceM, pointCount: this._pointCount, }); } catch (err) { this._emit('error', err); } } // ── Static helpers ──────────────────────────────────────────────────────── /** Normalize a browser GeolocationPosition into the module's fix shape. */ static normalize(pos) { const c = pos.coords || {}; const num = (v) => (v != null && !Number.isNaN(v) ? v : null); return { lon: c.longitude, lat: c.latitude, accuracy: num(c.accuracy), altitude: num(c.altitude), altitudeAccuracy: num(c.altitudeAccuracy), heading: num(c.heading), speed: num(c.speed), satellites: null, // not exposed by the web Geolocation API timestamp: pos.timestamp || Date.now(), }; } /** RFC4122-ish UUID, using crypto when available. */ static uuid() { if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { const r = (Math.random() * 16) | 0; const v = ch === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } } export default GeoTracker;