pwaLUPMIS2/src/geotracker/GeoTracker.js
ekke cfaceb3487 GPS trail recording, SSO auth, account menu, and mobile/UI refinements
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>
2026-05-28 16:08:37 +02:00

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;