baldav/frontend/js/gps.js
4aiman 4740bdbe11 Bike profiles, physics-based kcal, session recalculation
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
2026-05-25 02:09:50 +03:00

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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
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");