Major: - GPS trail recording: reusable, dependency-free engine in src/geotracker/ (GeoTracker + geo-utils) with pluggable storage/sync adapters; LUPMIS wiring in src/geotracker-lupmis.js. Expandable My Location control (Locate Me + Record Trail), live navbar GPS readout, on-map trail/position rendering, gps_trails/gps_trail_points SQLocal tables, and store-and-forward sync via pushGpsTrail() -> save_gps_trail.php (server side documented, not yet built). - SSO authentication: public/index.php entry point validates the LUSPA SSO cookie and injects window.LUPMIS_SESSION; remotedb district_id is now a session-resolved getter. Adds public/.htaccess (DirectoryIndex). - Account menu offcanvas (navbar burger) with sign-in/out states. UI / fixes: - LayerSwitcher modernisation; base-map "None" option in picker + settings. - Mobile drawing toolbar wraps to two rows below 576px and shows only in Draw mode; second row right-aligned and clears the Select option bar. - Safari bottom-dock clipping fixed (app-container 100dvh -> 100svh). - Rename public/icons -> app-icons to dodge Apache's default /icons/ alias. - Service Worker bumped to v8 (network-first HTML, per-provider tile clear). Docs: reusable-mapping and OSM-3D-buildings concept notes; ignore Office lock files (~$*). Rebuilt dist/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
/**
|
|
* 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<trail> // trails with synced=0 and completed
|
|
* getTrailPoints(trailId) -> Array<point>
|
|
* 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<string, Set<Function>>} */
|
|
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<object>}
|
|
*/
|
|
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;
|