Bike profiles: - bikes table: name, weight, wheel, chainring/cog teeth, surface type - CRUD in settings screen, active bike indicator - Default bike: 12kg, 26", 42/18, mixed surface - bike_id linked to gps_sessions Physics-based kcal (replaces MET*0.97): - Per-segment power calculation: gravity + rolling (Crr) + aero (CdA) - Power > 0 = pedaling: mechanical energy / 25% efficiency = metabolic cost - Power <= 0 = coasting: basal metabolic rate only - Walk/run: proper MET table from Compendium of Physical Activities - Raw altitude for gradient (smoothed was killing real slopes, 460 vs 296 kcal) - Smoothed altitude kept for displayed elevGain/Loss only Session recalculation: - "Recalculate" button on session detail page - Uses current weight + bike settings, re-reads all points - Bike selector per session (change bike -> auto recalculate) Other fixes: - User weight fallback chain: weight_log -> profile -> 80kg - Theoretical cadence from gear ratio + wheel circumference - Crash recovery preserves bike data
921 lines
34 KiB
JavaScript
921 lines
34 KiB
JavaScript
// GPS tracking module — recording, calculations, GPX import
|
|
// Exposes window.GPS
|
|
|
|
const GPS = {};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurable thresholds (can be changed in settings)
|
|
GPS.config = {
|
|
minSpeedMs: 0.42, // 1.5 km/h — below this = stationary
|
|
minAccuracyM: 20, // skip fixes less accurate than this
|
|
minDistanceM: 1, // skip points closer than this
|
|
};
|
|
|
|
// Load saved config
|
|
try {
|
|
const saved = localStorage.getItem("baldav_gps_config");
|
|
if (saved) Object.assign(GPS.config, JSON.parse(saved));
|
|
} catch {}
|
|
|
|
GPS.saveConfig = function() {
|
|
localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config));
|
|
};
|
|
|
|
// Haversine distance (meters)
|
|
// ---------------------------------------------------------------------------
|
|
GPS.distance = function(lat1, lon1, lat2, lon2) {
|
|
const R = 6371000;
|
|
const toRad = d => d * Math.PI / 180;
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLon = toRad(lon2 - lon1);
|
|
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compute session stats from array of {lat, lon, altitude, ts} points
|
|
// weightKg = rider weight, opts = { bikeWeightKg, activityType, surfaceCrr,
|
|
// chainringTeeth, cogTeeth, wheelDiameterInch } (all optional)
|
|
// ---------------------------------------------------------------------------
|
|
GPS.computeStats = function(points, weightKg, opts) {
|
|
if (points.length < 2) return { distance: 0, elevGain: 0, elevLoss: 0, avgSpeed: 0, maxSpeed: 0, kcal: 0, duration: 0, avgCadence: null };
|
|
|
|
// Sort by seq (if present) or timestamp to fix out-of-order points from native service
|
|
const sorted = points.slice().sort((a, b) => {
|
|
if (a.seq != null && b.seq != null) return a.seq - b.seq;
|
|
return new Date(a.ts) - new Date(b.ts);
|
|
});
|
|
|
|
let distance = 0, elevGain = 0, elevLoss = 0;
|
|
const speeds = []; // m/s per segment
|
|
|
|
// 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise)
|
|
const smoothedAlt = sorted.map((p, i) => {
|
|
const alt = p.altitude ?? p.altitude_gps_m;
|
|
if (alt == null) return null;
|
|
let sum = 0, cnt = 0;
|
|
for (let j = Math.max(0, i - 2); j <= Math.min(sorted.length - 1, i + 2); j++) {
|
|
const a = sorted[j].altitude ?? sorted[j].altitude_gps_m;
|
|
if (a != null) { sum += a; cnt++; }
|
|
}
|
|
return cnt > 0 ? sum / cnt : alt;
|
|
});
|
|
|
|
// Physics constants
|
|
const g = 9.81;
|
|
const CdA = 0.6; // typical upright position drag area (m^2)
|
|
const rho = 1.225; // air density at sea level (kg/m^3)
|
|
const efficiency = 0.25; // human mechanical efficiency (~25%)
|
|
const Crr = opts?.surfaceCrr ?? 0.006; // rolling resistance (mixed surface default)
|
|
const mTotal = (weightKg ?? 80) + (opts?.bikeWeightKg ?? 0);
|
|
const isBike = (opts?.activityType ?? "bike") === "bike";
|
|
|
|
let totalKcal = 0;
|
|
// Basal metabolic rate per minute (MET=1 sitting, formula: MET * 3.5 * kg / 200)
|
|
const basalPerMin = (1.0 * 3.5 * (weightKg ?? 80)) / 200;
|
|
|
|
// Cadence tracking
|
|
let totalPedalRevs = 0;
|
|
let pedalingSeconds = 0;
|
|
const hasGearData = opts?.chainringTeeth && opts?.cogTeeth && opts?.wheelDiameterInch;
|
|
const wheelCirc = hasGearData ? Math.PI * opts.wheelDiameterInch * 0.0254 : 0; // meters
|
|
const gearRatio = hasGearData ? opts.chainringTeeth / opts.cogTeeth : 0;
|
|
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const p = sorted[i], prev = sorted[i - 1];
|
|
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
|
|
distance += d;
|
|
|
|
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
|
|
const v = dt > 0 ? d / dt : 0; // m/s
|
|
speeds.push(v);
|
|
|
|
// Elevation from smoothed altitude, with 1m dead zone
|
|
if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) {
|
|
const diff = smoothedAlt[i] - smoothedAlt[i - 1];
|
|
if (diff > 1) elevGain += diff;
|
|
else if (diff < -1) elevLoss += Math.abs(diff);
|
|
}
|
|
|
|
// Kcal calculation per segment
|
|
if (dt <= 0 || dt > 300) continue; // skip pauses > 5min
|
|
|
|
if (v < 0.3) {
|
|
// Standing still: only basal metabolic rate
|
|
totalKcal += basalPerMin * (dt / 60);
|
|
continue;
|
|
}
|
|
|
|
if (isBike) {
|
|
// Gradient from RAW altitude (smoothed altitude kills real slopes and underestimates kcal)
|
|
const rawAlt = sorted[i].altitude ?? sorted[i].altitude_gps_m ?? 0;
|
|
const rawAltPrev = sorted[i-1].altitude ?? sorted[i-1].altitude_gps_m ?? 0;
|
|
const altDiff = rawAlt - rawAltPrev;
|
|
const gradient = d > 2 ? altDiff / d : 0; // rise/run
|
|
const sinG = gradient; // for small angles sin ~= gradient
|
|
const cosG = Math.sqrt(1 - sinG * sinG);
|
|
|
|
// Power required (watts): gravity + rolling resistance + aerodynamic drag
|
|
const pGravity = mTotal * g * sinG;
|
|
const pRolling = Crr * mTotal * g * cosG;
|
|
const pAero = 0.5 * CdA * rho * v * v;
|
|
const power = v * (pGravity + pRolling + pAero);
|
|
|
|
if (power > 0) {
|
|
// Pedaling: convert mechanical power to metabolic energy
|
|
const mechEnergy = power * dt; // joules
|
|
const metabEnergy = mechEnergy / efficiency; // joules (total metabolic cost)
|
|
totalKcal += metabEnergy / 4184; // convert to kcal
|
|
|
|
// Cadence: wheel revolutions / gear ratio = pedal revolutions
|
|
if (hasGearData && wheelCirc > 0) {
|
|
const wheelRevs = d / wheelCirc;
|
|
totalPedalRevs += wheelRevs / gearRatio;
|
|
pedalingSeconds += dt;
|
|
}
|
|
} else {
|
|
// Coasting downhill: only basal metabolic rate
|
|
totalKcal += basalPerMin * (dt / 60);
|
|
}
|
|
} else {
|
|
// Walk/run: MET-based from Compendium of Physical Activities
|
|
const speedKmh = v * 3.6;
|
|
let met;
|
|
if (speedKmh < 4) met = 2.5; // slow walk
|
|
else if (speedKmh < 5.5) met = 3.5; // normal walk
|
|
else if (speedKmh < 7) met = 5.0; // brisk walk
|
|
else if (speedKmh < 9) met = 8.0; // jogging
|
|
else if (speedKmh < 12) met = 10.0; // running
|
|
else if (speedKmh < 16) met = 12.5; // fast running
|
|
else met = 15.0; // sprint
|
|
|
|
totalKcal += (dt / 60) * (met * 3.5 * (weightKg ?? 80)) / 200;
|
|
}
|
|
}
|
|
|
|
// 9-point smoothed speeds for maxSpeed (kills spikes from GPS noise)
|
|
let maxSpeed = 0;
|
|
for (let i = 0; i < speeds.length; i++) {
|
|
let sum = 0, cnt = 0;
|
|
for (let j = Math.max(0, i - 4); j <= Math.min(speeds.length - 1, i + 4); j++) {
|
|
sum += speeds[j]; cnt++;
|
|
}
|
|
const smooth = sum / cnt;
|
|
if (smooth > maxSpeed) maxSpeed = smooth;
|
|
}
|
|
|
|
const first = new Date(sorted[0].ts);
|
|
const last = new Date(sorted[sorted.length - 1].ts);
|
|
const duration = (last - first) / 1000;
|
|
const avgSpeed = duration > 0 ? (distance / duration) : 0;
|
|
const avgSpeedKmh = avgSpeed * 3.6;
|
|
const maxSpeedKmh = maxSpeed * 3.6;
|
|
|
|
// Average cadence (RPM) — only if gear data was provided and there was pedaling
|
|
const avgCadence = (hasGearData && pedalingSeconds > 0)
|
|
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
|
|
: null;
|
|
|
|
return {
|
|
distance: Math.round(distance),
|
|
elevGain: Math.round(elevGain),
|
|
elevLoss: Math.round(elevLoss),
|
|
avgSpeed: Math.round(avgSpeedKmh * 10) / 10,
|
|
maxSpeed: Math.round(maxSpeedKmh * 10) / 10,
|
|
kcal: Math.round(totalKcal),
|
|
duration: Math.round(duration),
|
|
avgCadence,
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Speed smoothing — 3-point moving average
|
|
// ---------------------------------------------------------------------------
|
|
GPS.smoothSpeeds = function(points) {
|
|
const speeds = [];
|
|
for (let i = 0; i < points.length; i++) {
|
|
if (i === 0 || i === points.length - 1) {
|
|
speeds.push(points[i].speed_ms ?? 0);
|
|
} else {
|
|
const a = points[i-1].speed_ms ?? 0;
|
|
const b = points[i].speed_ms ?? 0;
|
|
const c = points[i+1].speed_ms ?? 0;
|
|
speeds.push((a + b + c) / 3);
|
|
}
|
|
}
|
|
return speeds;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live recording state
|
|
// ---------------------------------------------------------------------------
|
|
GPS.recording = {
|
|
active: false,
|
|
sessionId: null,
|
|
watchId: null,
|
|
points: [],
|
|
startTime: null,
|
|
activityType: "bike",
|
|
};
|
|
|
|
// Recording states: waiting_gps → ready → recording (auto on first movement)
|
|
// Pause: speed < 1 m/s for 5+ seconds → stop counting distance/time
|
|
// Start native GPS service on Android (foreground service survives background)
|
|
GPS._startNativeService = async function() {
|
|
try {
|
|
// Send config along with start command: start:minSpeed:maxAcc:minDist
|
|
const cmd = `start:${GPS.config.minSpeedMs}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}`;
|
|
await window.__TAURI__.core.invoke("gps_service_command", { cmd });
|
|
console.log("[GPS] Native service started with config:", cmd);
|
|
GPS.recording.nativeService = true;
|
|
} catch (e) {
|
|
console.log("[GPS] No native service (desktop?), using WebView GPS");
|
|
GPS.recording.nativeService = false;
|
|
}
|
|
};
|
|
|
|
GPS._stopNativeService = async function() {
|
|
try {
|
|
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "stop" });
|
|
} catch {}
|
|
};
|
|
|
|
// Read points from native service file
|
|
GPS._readNativePoints = async function() {
|
|
try {
|
|
const raw = await window.__TAURI__.core.invoke("gps_service_read_points");
|
|
if (!raw) return [];
|
|
const points = [];
|
|
for (const line of raw.trim().split("\n")) {
|
|
if (!line) continue;
|
|
try {
|
|
const d = JSON.parse(line);
|
|
if (d.lat == null || d.lon == null) continue; // skip marker/invalid lines
|
|
points.push({
|
|
lat: d.lat, lon: d.lon, altitude: d.alt,
|
|
speed_ms: d.speed, accuracy: d.acc,
|
|
ts: new Date(d.ts).toISOString(),
|
|
});
|
|
} catch { /* skip partial/corrupt lines */ }
|
|
}
|
|
// Sort by timestamp to fix any ordering issues
|
|
points.sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
|
return points;
|
|
} catch { return []; }
|
|
};
|
|
|
|
// Core point processing — used by both WebView GPS and native service polling
|
|
GPS._processIncomingPoint = async function(p) {
|
|
if (!GPS.recording.active) return;
|
|
|
|
// State: waiting_gps → ready (got first accurate fix)
|
|
if (GPS.recording.state === "waiting_gps") {
|
|
GPS.recording.state = "ready";
|
|
if (GPS.recording.onStateChange) GPS.recording.onStateChange("ready", p);
|
|
}
|
|
|
|
// Manual pause — show position but don't process
|
|
if (GPS.recording.manualPaused) {
|
|
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
|
return;
|
|
}
|
|
|
|
// Determine if moving
|
|
const speed = p.speed_ms != null ? p.speed_ms : 0;
|
|
let isMoving = speed > GPS.config.minSpeedMs;
|
|
if (!isMoving && GPS.recording.lastMovingPoint) {
|
|
const d = GPS.distance(GPS.recording.lastMovingPoint.lat, GPS.recording.lastMovingPoint.lon, p.lat, p.lon);
|
|
const dt = (new Date(p.ts) - new Date(GPS.recording.lastMovingPoint.ts)) / 1000;
|
|
if (d > GPS.config.minDistanceM * 3 && dt < 5 && dt > 0) isMoving = true;
|
|
}
|
|
|
|
// State: ready → recording (first movement detected)
|
|
if (GPS.recording.state === "ready" && isMoving) {
|
|
GPS.recording.state = "recording";
|
|
GPS.recording.startTime = new Date();
|
|
const now = GPS.recording.startTime;
|
|
GPS.recording.sessionId = await DB.createGpsSession(
|
|
now.toISOString().slice(0, 10),
|
|
now.toTimeString().slice(0, 5),
|
|
GPS.recording.activityType
|
|
);
|
|
GPS._persistState();
|
|
if (GPS.recording.onStateChange) GPS.recording.onStateChange("recording", p);
|
|
}
|
|
|
|
// If not yet recording, just update position display
|
|
if (GPS.recording.state !== "recording") {
|
|
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
|
return;
|
|
}
|
|
|
|
// Auto-pause detection
|
|
if (!isMoving) {
|
|
if (!GPS.recording.pauseStart) {
|
|
GPS.recording.pauseStart = new Date(p.ts);
|
|
}
|
|
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
|
return;
|
|
}
|
|
|
|
// Resumed from auto-pause
|
|
if (GPS.recording.pauseStart) {
|
|
const pauseMs = new Date(p.ts) - GPS.recording.pauseStart;
|
|
GPS.recording.totalPauseMs += pauseMs;
|
|
GPS.recording.pauseStart = null;
|
|
if (GPS.recording.onStateChange) GPS.recording.onStateChange("resumed", p);
|
|
}
|
|
|
|
// Save moving point with seq
|
|
GPS.recording.lastMovingPoint = p;
|
|
GPS.recording.points.push(p);
|
|
GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1;
|
|
if (GPS.recording.sessionId) {
|
|
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq);
|
|
}
|
|
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
|
};
|
|
|
|
// Persist recording state to localStorage for crash recovery
|
|
GPS._persistState = function() {
|
|
try {
|
|
localStorage.setItem("baldav_gps_active", JSON.stringify({
|
|
sessionId: GPS.recording.sessionId,
|
|
activityType: GPS.recording.activityType,
|
|
weightKg: GPS.recording.weightKg,
|
|
startTime: GPS.recording.startTime?.toISOString(),
|
|
totalPauseMs: GPS.recording.totalPauseMs,
|
|
manualPaused: GPS.recording.manualPaused,
|
|
pauses: GPS.recording.pauses || [],
|
|
pointCount: GPS.recording.points.length,
|
|
bikeId: GPS.recording.bikeId ?? null,
|
|
bikeWeightKg: GPS.recording.bikeWeightKg ?? null,
|
|
surfaceCrr: GPS.recording.surfaceCrr ?? null,
|
|
chainringTeeth: GPS.recording.chainringTeeth ?? null,
|
|
cogTeeth: GPS.recording.cogTeeth ?? null,
|
|
wheelDiameterInch: GPS.recording.wheelDiameterInch ?? null,
|
|
}));
|
|
} catch {}
|
|
};
|
|
|
|
GPS._clearPersistedState = function() {
|
|
localStorage.removeItem("baldav_gps_active");
|
|
};
|
|
|
|
// Manual pause/resume
|
|
GPS.pauseRecording = function() {
|
|
if (!GPS.recording.active || GPS.recording.manualPaused) return;
|
|
GPS.recording.manualPaused = true;
|
|
const last = GPS.recording.lastMovingPoint || GPS.recording.points[GPS.recording.points.length - 1];
|
|
GPS.recording._currentPause = {
|
|
startTs: Date.now(),
|
|
startLat: last?.lat, startLon: last?.lon,
|
|
};
|
|
GPS._persistState();
|
|
if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_paused");
|
|
};
|
|
|
|
GPS.resumeRecording = function() {
|
|
if (!GPS.recording.active || !GPS.recording.manualPaused) return;
|
|
GPS.recording.manualPaused = false;
|
|
const last = GPS.recording.lastMovingPoint || GPS.recording.points[GPS.recording.points.length - 1];
|
|
if (GPS.recording._currentPause) {
|
|
GPS.recording._currentPause.endTs = Date.now();
|
|
GPS.recording._currentPause.endLat = last?.lat;
|
|
GPS.recording._currentPause.endLon = last?.lon;
|
|
const dur = GPS.recording._currentPause.endTs - GPS.recording._currentPause.startTs;
|
|
GPS.recording.totalPauseMs += dur;
|
|
if (!GPS.recording.pauses) GPS.recording.pauses = [];
|
|
GPS.recording.pauses.push(GPS.recording._currentPause);
|
|
GPS.recording._currentPause = null;
|
|
}
|
|
GPS._persistState();
|
|
if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_resumed");
|
|
};
|
|
|
|
// Recover orphaned recording session on app startup
|
|
GPS.recoverSession = async function() {
|
|
const saved = localStorage.getItem("baldav_gps_active");
|
|
if (!saved) return;
|
|
try {
|
|
const s = JSON.parse(saved);
|
|
if (!s.sessionId) { GPS._clearPersistedState(); return; }
|
|
// Import remaining native points not yet in DB (idempotent by timestamp)
|
|
const existingPts = await DB.getGpsPoints(s.sessionId);
|
|
const maxDbTs = existingPts.length ? Math.max(...existingPts.map(p => new Date(p.ts).getTime())) : 0;
|
|
const nativePoints = await GPS._readNativePoints();
|
|
let imported = 0;
|
|
let seq = existingPts.length;
|
|
for (const p of nativePoints) {
|
|
if (new Date(p.ts).getTime() <= maxDbTs) continue;
|
|
seq++;
|
|
await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq);
|
|
imported++;
|
|
}
|
|
// Finalize the session
|
|
const pts = await DB.getGpsPoints(s.sessionId);
|
|
if (pts.length > 0) {
|
|
const recoverOpts = {
|
|
activityType: s.activityType,
|
|
bikeWeightKg: s.bikeWeightKg ?? 0,
|
|
surfaceCrr: s.surfaceCrr,
|
|
chainringTeeth: s.chainringTeeth,
|
|
cogTeeth: s.cogTeeth,
|
|
wheelDiameterInch: s.wheelDiameterInch,
|
|
};
|
|
const stats = GPS.computeStats(pts, s.weightKg || 80, recoverOpts);
|
|
await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5),
|
|
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
|
// Store bike_id if present
|
|
if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId);
|
|
console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points");
|
|
}
|
|
GPS._clearPersistedState();
|
|
await GPS._stopNativeService();
|
|
} catch (e) {
|
|
console.error("[GPS] Recovery error:", e);
|
|
GPS._clearPersistedState();
|
|
}
|
|
};
|
|
|
|
GPS.startRecording = async function(activityType, weightKg) {
|
|
if (GPS.recording.active) return;
|
|
if (!navigator.geolocation) throw new Error("Geolocation not available");
|
|
|
|
GPS.recording.active = true;
|
|
GPS.recording.activityType = activityType || "bike";
|
|
GPS.recording.points = [];
|
|
GPS.recording.startTime = null;
|
|
GPS.recording.weightKg = weightKg ?? 80;
|
|
GPS.recording.state = "waiting_gps";
|
|
GPS.recording.sessionId = null;
|
|
GPS.recording.pauseStart = null;
|
|
GPS.recording.totalPauseMs = 0;
|
|
GPS.recording.lastMovingPoint = null;
|
|
GPS.recording.manualPaused = false;
|
|
GPS.recording.pauses = [];
|
|
GPS.recording._currentPause = null;
|
|
GPS.recording.nativeService = false;
|
|
GPS.recording._pollTimer = null;
|
|
GPS.recording._lastProcessedTs = 0;
|
|
GPS.recording._pointSeq = 0;
|
|
|
|
// Start native foreground service (Android)
|
|
await GPS._startNativeService();
|
|
|
|
// Poll native service output file
|
|
if (GPS.recording.nativeService) {
|
|
let polling = false;
|
|
GPS.recording._pollTimer = setInterval(async () => {
|
|
if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; }
|
|
if (polling) return; // skip if previous poll still running
|
|
polling = true;
|
|
try {
|
|
const nativePoints = await GPS._readNativePoints();
|
|
let newCount = 0;
|
|
// Suppress per-point UI updates during batch — onBatchUpdate handles it
|
|
const origOnPoint = GPS.recording.onPoint;
|
|
for (const p of nativePoints) {
|
|
const pTs = new Date(p.ts).getTime();
|
|
if (pTs <= GPS.recording._lastProcessedTs) continue;
|
|
GPS.recording.onPoint = null; // suppress addLatLng per point
|
|
await GPS._processIncomingPoint(p);
|
|
GPS.recording._lastProcessedTs = pTs;
|
|
newCount++;
|
|
}
|
|
GPS.recording.onPoint = origOnPoint;
|
|
// Batch UI update — rebuild polyline from sorted points
|
|
if (newCount > 0 && GPS.recording.onBatchUpdate) {
|
|
GPS.recording.onBatchUpdate();
|
|
}
|
|
} catch (e) {
|
|
console.error("[GPS] poll error:", e);
|
|
}
|
|
polling = false;
|
|
}, 2000);
|
|
}
|
|
|
|
GPS.recording.watchId = navigator.geolocation.watchPosition(
|
|
async (pos) => {
|
|
if (pos.coords.accuracy > GPS.config.minAccuracyM) return;
|
|
await GPS._processIncomingPoint({
|
|
lat: pos.coords.latitude,
|
|
lon: pos.coords.longitude,
|
|
altitude: pos.coords.altitude,
|
|
speed_ms: pos.coords.speed,
|
|
accuracy: pos.coords.accuracy,
|
|
ts: new Date(pos.timestamp).toISOString(),
|
|
});
|
|
},
|
|
(err) => {
|
|
console.error("GPS error:", err.message);
|
|
if (GPS.recording.onError) GPS.recording.onError(err);
|
|
},
|
|
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
|
|
);
|
|
};
|
|
|
|
GPS.stopRecording = async function() {
|
|
if (!GPS.recording.active) return null;
|
|
GPS.recording.active = false;
|
|
|
|
if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer);
|
|
|
|
if (GPS.recording.watchId != null) {
|
|
navigator.geolocation.clearWatch(GPS.recording.watchId);
|
|
GPS.recording.watchId = null;
|
|
}
|
|
|
|
// Stop native service
|
|
if (GPS.recording.nativeService) {
|
|
// Import any remaining points from native service
|
|
const nativePoints = await GPS._readNativePoints();
|
|
if (nativePoints.length > GPS.recording._lastNativeCount && GPS.recording.sessionId) {
|
|
for (let i = GPS.recording._lastNativeCount; i < nativePoints.length; i++) {
|
|
const p = nativePoints[i];
|
|
GPS.recording.points.push(p);
|
|
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms);
|
|
}
|
|
}
|
|
await GPS._stopNativeService();
|
|
}
|
|
|
|
if (!GPS.recording.sessionId) {
|
|
return null;
|
|
}
|
|
|
|
// Build opts for physics-based kcal if bike data is available
|
|
const recOpts = {
|
|
activityType: GPS.recording.activityType,
|
|
bikeWeightKg: GPS.recording.bikeWeightKg ?? 0,
|
|
surfaceCrr: GPS.recording.surfaceCrr,
|
|
chainringTeeth: GPS.recording.chainringTeeth,
|
|
cogTeeth: GPS.recording.cogTeeth,
|
|
wheelDiameterInch: GPS.recording.wheelDiameterInch,
|
|
};
|
|
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts);
|
|
const endTime = new Date().toTimeString().slice(0, 5);
|
|
|
|
await DB.finishGpsSession(
|
|
GPS.recording.sessionId, endTime,
|
|
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
|
stats.elevGain, stats.elevLoss
|
|
);
|
|
|
|
// Store bike_id in session if present
|
|
if (GPS.recording.bikeId) {
|
|
await DB.updateSessionBike(GPS.recording.sessionId, GPS.recording.bikeId);
|
|
}
|
|
|
|
const sid = GPS.recording.sessionId;
|
|
const pauses = GPS.recording.pauses || [];
|
|
GPS.recording.sessionId = null;
|
|
GPS.recording.points = [];
|
|
GPS.recording.pauses = [];
|
|
GPS.recording.manualPaused = false;
|
|
GPS.recording.bikeId = null;
|
|
GPS.recording.bikeWeightKg = null;
|
|
GPS.recording.surfaceCrr = null;
|
|
GPS.recording.chainringTeeth = null;
|
|
GPS.recording.cogTeeth = null;
|
|
GPS.recording.wheelDiameterInch = null;
|
|
GPS._clearPersistedState();
|
|
return { sessionId: sid, stats, pauses };
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GPX import
|
|
// ---------------------------------------------------------------------------
|
|
GPS.parseGPX = function(xmlString) {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xmlString, "application/xml");
|
|
const trkpts = doc.querySelectorAll("trkpt");
|
|
const points = [];
|
|
|
|
for (const pt of trkpts) {
|
|
const lat = parseFloat(pt.getAttribute("lat"));
|
|
const lon = parseFloat(pt.getAttribute("lon"));
|
|
const eleEl = pt.querySelector("ele");
|
|
const timeEl = pt.querySelector("time");
|
|
const spdEl = pt.querySelector("speed");
|
|
|
|
points.push({
|
|
lat, lon,
|
|
altitude: eleEl ? parseFloat(eleEl.textContent) : null,
|
|
ts: timeEl ? timeEl.textContent : null,
|
|
speed: spdEl ? parseFloat(spdEl.textContent) : null,
|
|
});
|
|
}
|
|
|
|
// Activity type from GPX <type> tag
|
|
const typeEl = doc.querySelector("type");
|
|
const actType = typeEl ? typeEl.textContent.toLowerCase() : "other";
|
|
|
|
return { points, activityType: actType };
|
|
};
|
|
|
|
GPS.importGPX = async function(xmlString, weightKg) {
|
|
const { points, activityType } = GPS.parseGPX(xmlString);
|
|
if (points.length < 2) throw new Error("GPX has fewer than 2 points");
|
|
|
|
const stats = GPS.computeStats(points, weightKg ?? 80);
|
|
const first = points[0].ts ? new Date(points[0].ts) : new Date();
|
|
const last = points[points.length - 1].ts ? new Date(points[points.length - 1].ts) : new Date();
|
|
|
|
const session = {
|
|
date: first.toISOString().slice(0, 10),
|
|
start_time: first.toTimeString().slice(0, 5),
|
|
end_time: last.toTimeString().slice(0, 5),
|
|
activity_type: actType(activityType),
|
|
total_distance_m: stats.distance,
|
|
total_kcal: stats.kcal,
|
|
avg_speed_kmh: stats.avgSpeed,
|
|
max_speed_kmh: stats.maxSpeed,
|
|
elevation_gain_m: stats.elevGain,
|
|
elevation_loss_m: stats.elevLoss,
|
|
source: "gpx_import",
|
|
};
|
|
|
|
const dbPoints = points.map((p, idx) => ({
|
|
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
|
|
}));
|
|
|
|
return DB.importGpxSession(session, dbPoints);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FitoTrack .ftb import (zip containing data.xml)
|
|
// ---------------------------------------------------------------------------
|
|
GPS.importFTB = async function(arrayBuffer, weightKg) {
|
|
const zip = await JSZip.loadAsync(arrayBuffer);
|
|
const xmlFile = zip.file("data.xml");
|
|
if (!xmlFile) throw new Error("data.xml not found in .ftb");
|
|
const xmlStr = await xmlFile.async("string");
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xmlStr, "application/xml");
|
|
|
|
// Parse workouts
|
|
const workoutEls = doc.querySelectorAll("workouts > workouts");
|
|
const sampleEls = doc.querySelectorAll("samples > samples");
|
|
|
|
// Group samples by workoutId
|
|
const samplesByWorkout = {};
|
|
for (const s of sampleEls) {
|
|
const wid = s.querySelector("workoutId")?.textContent;
|
|
if (!wid) continue;
|
|
if (!samplesByWorkout[wid]) samplesByWorkout[wid] = [];
|
|
samplesByWorkout[wid].push({
|
|
lat: parseFloat(s.querySelector("lat")?.textContent),
|
|
lon: parseFloat(s.querySelector("lon")?.textContent),
|
|
altitude: parseFloat(s.querySelector("elevationMSL")?.textContent) || parseFloat(s.querySelector("elevation")?.textContent) || null,
|
|
speed: parseFloat(s.querySelector("speed")?.textContent) || null,
|
|
ts: new Date(parseInt(s.querySelector("absoluteTime")?.textContent)).toISOString(),
|
|
});
|
|
}
|
|
|
|
let imported = 0;
|
|
for (const w of workoutEls) {
|
|
const el = tag => w.querySelector(tag)?.textContent;
|
|
const wid = el("id");
|
|
const samples = samplesByWorkout[wid] || [];
|
|
if (samples.length < 2) continue;
|
|
|
|
const startMs = parseInt(el("start"));
|
|
const endMs = parseInt(el("end"));
|
|
const startDate = new Date(startMs);
|
|
const endDate = new Date(endMs);
|
|
|
|
const session = {
|
|
date: startDate.toISOString().slice(0, 10),
|
|
start_time: startDate.toTimeString().slice(0, 5),
|
|
end_time: endDate.toTimeString().slice(0, 5),
|
|
activity_type: ftbType(el("workoutType")),
|
|
total_distance_m: parseFloat(el("length")) || null,
|
|
total_kcal: parseFloat(el("calorie")) || null,
|
|
avg_speed_kmh: parseFloat(el("avgSpeed")) ? Math.round(parseFloat(el("avgSpeed")) * 3.6 * 10) / 10 : null,
|
|
max_speed_kmh: parseFloat(el("topSpeed")) ? Math.round(parseFloat(el("topSpeed")) * 3.6 * 10) / 10 : null,
|
|
elevation_gain_m: parseFloat(el("ascent")) || null,
|
|
elevation_loss_m: parseFloat(el("descent")) || null,
|
|
source: "fitotrack_import",
|
|
};
|
|
|
|
const dbPoints = samples.map((p, idx) => ({
|
|
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
|
|
}));
|
|
|
|
await DB.importGpxSession(session, dbPoints);
|
|
imported++;
|
|
}
|
|
|
|
return imported;
|
|
};
|
|
|
|
function ftbType(raw) {
|
|
const r = (raw || "").toLowerCase();
|
|
if (r.includes("cycl") || r.includes("bik")) return "bike";
|
|
if (r.includes("run")) return "run";
|
|
if (r.includes("walk") || r.includes("hik")) return "walk";
|
|
return "other";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interpolate points where GPS gap > threshold (e.g. tunnel, signal loss)
|
|
// Adds intermediate points with linear lat/lon/altitude interpolation
|
|
// ---------------------------------------------------------------------------
|
|
GPS.interpolate = function(points, maxGapSec = 30) {
|
|
if (points.length < 2) return points;
|
|
const result = [points[0]];
|
|
for (let i = 1; i < points.length; i++) {
|
|
const prev = points[i - 1];
|
|
const cur = points[i];
|
|
const prevT = new Date(prev.ts).getTime();
|
|
const curT = new Date(cur.ts).getTime();
|
|
const gapSec = (curT - prevT) / 1000;
|
|
|
|
if (gapSec > maxGapSec) {
|
|
// Insert interpolated points every ~3 seconds
|
|
const steps = Math.min(Math.ceil(gapSec / 3), 100);
|
|
for (let s = 1; s < steps; s++) {
|
|
const frac = s / steps;
|
|
const t = prevT + (curT - prevT) * frac;
|
|
result.push({
|
|
lat: prev.lat + (cur.lat - prev.lat) * frac,
|
|
lon: prev.lon + (cur.lon - prev.lon) * frac,
|
|
altitude: (prev.altitude != null && cur.altitude != null)
|
|
? prev.altitude + (cur.altitude - prev.altitude) * frac : null,
|
|
altitude_gps_m: (prev.altitude_gps_m != null && cur.altitude_gps_m != null)
|
|
? prev.altitude_gps_m + (cur.altitude_gps_m - prev.altitude_gps_m) * frac : null,
|
|
speed_ms: (prev.speed_ms != null && cur.speed_ms != null)
|
|
? prev.speed_ms + (cur.speed_ms - prev.speed_ms) * frac : null,
|
|
ts: new Date(t).toISOString(),
|
|
interpolated: true,
|
|
});
|
|
}
|
|
}
|
|
result.push(cur);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Filter GPS noise — remove points with unrealistic speed (>150 km/h)
|
|
// and break track into segments at large time gaps
|
|
// ---------------------------------------------------------------------------
|
|
// FitoTrack-style filter:
|
|
// 1. Skip points too close in time (<500ms)
|
|
// 2. Skip points with unrealistic speed (>maxSpeedKmh)
|
|
// 3. Skip points too close in distance (<minDistM) — configurable per activity
|
|
// Nearest-neighbor reorder: from current point, always jump to the closest
|
|
// unused point. No points removed. Untangles AGPS alternation.
|
|
// Identical to the debug version that worked.
|
|
// Reorder points: normal points go chronologically, AGPS points (with wrong
|
|
// timestamps) get inserted at their nearest spatial neighbor.
|
|
// Detection: a point is "AGPS-displaced" if it's far (>50m) from BOTH its
|
|
// chronological predecessor and successor — meaning its timestamp is wrong.
|
|
// Just return points as-is. Order comes from DB: seq (FTB XML order), then ts.
|
|
// This matches FitoTrack v15.6 which draws by insertion order without sorting.
|
|
GPS.filterNoise = function(points, activityType) {
|
|
return points;
|
|
};
|
|
|
|
// Build polyline segments — break at gaps > maxGapSec
|
|
// Returns array of arrays of [lat,lon] for separate polylines
|
|
GPS.buildSegments = function(points, maxGapSec = 120) {
|
|
if (points.length === 0) return [];
|
|
const segments = [[[points[0].lat, points[0].lon]]];
|
|
for (let i = 1; i < points.length; i++) {
|
|
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
|
if (gap > maxGapSec) {
|
|
segments.push([]);
|
|
}
|
|
segments[segments.length - 1].push([points[i].lat, points[i].lon]);
|
|
}
|
|
return segments.filter(s => s.length > 1);
|
|
};
|
|
|
|
function actType(raw) {
|
|
const r = (raw || "").toLowerCase();
|
|
if (r.includes("bik") || r.includes("cycl")) return "bike";
|
|
if (r.includes("run")) return "run";
|
|
if (r.includes("walk") || r.includes("hik")) return "walk";
|
|
return "other";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format helpers
|
|
// ---------------------------------------------------------------------------
|
|
GPS.formatDuration = function(seconds) {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return h > 0 ? `${h}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`
|
|
: `${m}:${String(s).padStart(2,"0")}`;
|
|
};
|
|
|
|
GPS.formatDistance = function(meters) {
|
|
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : meters + " м";
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GPX Export
|
|
// ---------------------------------------------------------------------------
|
|
GPS.exportGPX = async function(sessionId) {
|
|
const session = await DB.getGpsSession(sessionId);
|
|
if (!session) return;
|
|
const points = await DB.getGpsPoints(sessionId);
|
|
if (!points.length) return;
|
|
|
|
const esc = s => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
|
const name = esc(session.activity_type || "Track") + " " + esc(session.date);
|
|
|
|
const cfg = GPS.config;
|
|
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<gpx creator="BalDav" version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
|
<metadata>
|
|
<name>${name}</name>
|
|
<time>${points[0].ts}</time>
|
|
<desc>minSpeed=${(cfg.minSpeedMs*3.6).toFixed(1)}kmh maxAcc=${cfg.minAccuracyM}m minDist=${cfg.minDistanceM}m points=${points.length}</desc>
|
|
</metadata>
|
|
<trk>
|
|
<name>${name}</name>
|
|
<type>${esc(session.activity_type || "other")}</type>
|
|
<trkseg>
|
|
`;
|
|
|
|
for (const p of points) {
|
|
gpx += ` <trkpt lat="${p.lat}" lon="${p.lon}">
|
|
`;
|
|
if (p.altitude_gps_m != null) gpx += ` <ele>${p.altitude_gps_m}</ele>\n`;
|
|
if (p.ts) gpx += ` <time>${p.ts}</time>\n`;
|
|
if (p.speed_ms != null) gpx += ` <extensions><speed>${p.speed_ms}</speed></extensions>\n`;
|
|
gpx += ` </trkpt>\n`;
|
|
}
|
|
|
|
gpx += ` </trkseg>
|
|
</trk>
|
|
</gpx>`;
|
|
|
|
// Save via Rust to Downloads folder
|
|
const time = (session.start_time || "").replace(/:/g, "-");
|
|
const filename = `baldav-${session.date}_${time}-${session.activity_type || "track"}.gpx`;
|
|
try {
|
|
const path = await window.__TAURI__.core.invoke("save_file", { filename, content: gpx });
|
|
console.log("[GPX] Saved to:", path);
|
|
} catch (e) {
|
|
console.error("[GPX] Save error:", e);
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recalculate session stats using physics-based formula
|
|
// ---------------------------------------------------------------------------
|
|
GPS.recalculateSession = async function(sessionId) {
|
|
const session = await DB.getGpsSession(sessionId);
|
|
if (!session) return null;
|
|
const points = await DB.getGpsPoints(sessionId);
|
|
if (points.length < 2) return null;
|
|
|
|
// Get user weight: latest weight log, or profile, or 80
|
|
const latestW = await DB.getLatestWeight();
|
|
const profile = await DB.getFullProfile();
|
|
const weightKg = latestW?.weight_kg ?? profile?.weight_kg ?? 80;
|
|
|
|
// Get bike if linked to session, otherwise use active bike
|
|
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
|
|
|
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
|
|
|
const stats = GPS.computeStats(points, weightKg, {
|
|
bikeWeightKg: bike?.weight_kg ?? 0,
|
|
activityType: session.activity_type ?? "bike",
|
|
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
|
chainringTeeth: bike?.chainring_teeth,
|
|
cogTeeth: bike?.cog_teeth,
|
|
wheelDiameterInch: bike?.wheel_diameter_inch,
|
|
});
|
|
|
|
// Update session in DB
|
|
await DB.finishGpsSession(sessionId,
|
|
session.end_time ?? session.start_time,
|
|
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
|
stats.elevGain, stats.elevLoss
|
|
);
|
|
|
|
return stats;
|
|
};
|
|
|
|
// Build opts from current recording state (for live stats during recording)
|
|
GPS.getRecordingOpts = function() {
|
|
return {
|
|
activityType: GPS.recording.activityType,
|
|
bikeWeightKg: GPS.recording.bikeWeightKg ?? 0,
|
|
surfaceCrr: GPS.recording.surfaceCrr,
|
|
chainringTeeth: GPS.recording.chainringTeeth,
|
|
cogTeeth: GPS.recording.cogTeeth,
|
|
wheelDiameterInch: GPS.recording.wheelDiameterInch,
|
|
};
|
|
};
|
|
|
|
window.GPS = GPS;
|
|
console.log("[gps.js] window.GPS ready");
|