baldav/frontend/js/gps.js
4aiman 84a2b786ca GPS: course-up map rotation in landscape + robust screen-off import
- applyBearing: use h, not h - rotOffset. The .rec-screen (map + panel) is
  CSS-rotated by rotOffset in landscape and setBearing adds another rotate on
  the map pane; subtracting rotOffset cancelled the screen rotation for the
  map only, gluing course to the portrait top edge while the panel turned.
  Using h lets the map turn with the screen -> course points to physical top.
- Poll: guard the requestAnimationFrame yield with !document.hidden. rAF never
  fires while the screen is off, so awaiting it there hung the import loop and
  froze point import mid-walk (regression from the progressive-draw change).
- recoverSession: import missing native points by the SET of existing
  timestamps instead of ts > maxDbTs. Sparse screen-gated points can have a
  late max ts, so the old check imported nothing and left the track full of
  gaps; also pass sensorData so bearing/accel/gyro are stored.
2026-07-08 22:11:17 +03:00

1464 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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: 50, // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept.
minDistanceM: 1, // skip points closer than this
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
gapThresholdSec: 30, // gap longer than this = new track segment
airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов)
walkAvgSpeedKmh: 5, // средняя скорость ходьбы
runAvgSpeedKmh: 10, // средняя скорость бега
useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps.
// raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть.
autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец»)
};
// Load saved config
try {
let 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));
};
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
try {
if (localStorage.getItem("baldav_fused_migrated") !== "1") {
GPS.config.useFusedLocation = true;
localStorage.setItem("baldav_fused_migrated", "1");
GPS.saveConfig();
}
} catch {}
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
// Only touch the value if it's still the old default; respect a user-set threshold.
try {
if (localStorage.getItem("baldav_minacc50_migrated") !== "1") {
if (GPS.config.minAccuracyM <= 20) GPS.config.minAccuracyM = 50;
localStorage.setItem("baldav_minacc50_migrated", "1");
GPS.saveConfig();
}
} catch {}
// Haversine distance (meters)
// ---------------------------------------------------------------------------
// Compass bearing in degrees (0=N, clockwise) from point 1 to point 2.
GPS.bearing = function(lat1, lon1, lat2, lon2) {
let toRad = d => d * Math.PI / 180;
let f1 = toRad(lat1), f2 = toRad(lat2), dl = toRad(lon2 - lon1);
let y = Math.sin(dl) * Math.cos(f2);
let x = Math.cos(f1) * Math.sin(f2) - Math.sin(f1) * Math.cos(f2) * Math.cos(dl);
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
};
// Point [lat, lon] at `meters` distance along `bearingDeg` (0=N, CW) from a start point.
GPS.destination = function(lat, lon, bearingDeg, meters) {
let R = 6371000, d = meters / R, br = bearingDeg * Math.PI / 180;
let f1 = lat * Math.PI / 180, l1 = lon * Math.PI / 180;
let f2 = Math.asin(Math.sin(f1) * Math.cos(d) + Math.cos(f1) * Math.sin(d) * Math.cos(br));
let l2 = l1 + Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(f1), Math.cos(d) - Math.sin(f1) * Math.sin(f2));
return [f2 * 180 / Math.PI, ((l2 * 180 / Math.PI) + 540) % 360 - 180];
};
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 };
// Always sort by timestamp — seq can be wrong if points arrived out of order during recording
const sorted = points.slice().sort((a, b) => 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)
// Step 1: Extract raw altitudes and filter outliers.
// An outlier is a point that jumps far from its neighbors and returns back —
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
const filteredAlt = rawAlt.slice();
for (let i = 1; i < filteredAlt.length - 1; i++) {
if (filteredAlt[i] == null) continue;
const prev = filteredAlt[i - 1], next = filteredAlt[i + 1];
if (prev == null || next == null) continue;
const jumpFromPrev = Math.abs(filteredAlt[i] - prev);
const jumpFromNext = Math.abs(filteredAlt[i] - next);
const neighborDiff = Math.abs(next - prev);
// If this point jumps >4m from BOTH neighbors, but neighbors are close to each other — outlier
if (jumpFromPrev > 4 && jumpFromNext > 4 && neighborDiff < 3) {
filteredAlt[i] = (prev + next) / 2;
}
}
// Step 1b: Fix "stuck" altitude — same value repeated 5+ times = GPS frozen,
// replace with linear interpolation between surrounding real values
{
let i = 0;
while (i < filteredAlt.length) {
if (filteredAlt[i] == null) { i++; continue; }
let j = i + 1;
while (j < filteredAlt.length && filteredAlt[j] != null
&& Math.abs(filteredAlt[j] - filteredAlt[i]) < 0.2) j++;
if (j - i >= 5) {
// Stuck run from i to j-1. Interpolate using values before i and after j-1
const before = i > 0 ? filteredAlt[i - 1] : filteredAlt[i];
const after = j < filteredAlt.length ? filteredAlt[j] : filteredAlt[j - 1];
for (let k = i; k < j; k++) {
const t = (k - i + 1) / (j - i + 1);
filteredAlt[k] = before + (after - before) * t;
}
}
i = j;
}
}
// Step 2: Smooth filtered altitude
const SMOOTH_HALF = GPS.config.altSmoothHalf ?? 2;
const smoothedAlt = filteredAlt.map((alt, i) => {
if (alt == null) return null;
let sum = 0, cnt = 0;
for (let j = Math.max(0, i - SMOOTH_HALF); j <= Math.min(filteredAlt.length - 1, i + SMOOTH_HALF); j++) {
if (filteredAlt[j] != null) { sum += filteredAlt[j]; cnt++; }
}
return cnt > 0 ? sum / cnt : alt;
});
// Physics constants (configurable in settings)
const g = 9.81;
const CdA = GPS.config.airDrag ?? 0.6;
const rho = GPS.config.airDensity ?? 1.225;
const efficiency = (GPS.config.pedalEfficiency ?? 25) / 100;
// Rolling resistance: base from surface type, corrected by tire pressure.
// If accelerometer data available, estimate Crr from vibration (accel RMS):
// RMS < 3 → smooth asphalt (0.004), 3-7 → mixed (0.006), > 7 → gravel (0.008)
let baseCrr = opts?.surfaceCrr ?? 0.006;
const accelVals = sorted.map(p => p.accel_rms).filter(v => v != null && v > 0);
if (accelVals.length > 10) {
accelVals.sort((a, b) => a - b);
const medianAccel = accelVals[Math.floor(accelVals.length / 2)];
const accelCrr = medianAccel < 3 ? 0.004 : medianAccel < 7 ? 0.006 : 0.008;
baseCrr = accelCrr; // override with sensor-based estimate
}
const tirePressure = opts?.tirePressureBar ?? 3.0;
const Crr = baseCrr * Math.sqrt(3.0 / tirePressure);
const wKg = parseFloat(weightKg) || 80; // ensure numeric (profile stores strings)
const mTotal = wKg + (parseFloat(opts?.bikeWeightKg) || 0);
const isBike = (opts?.activityType ?? "bike") === "bike";
let totalKcal = 0;
const basalPerMin = (1.0 * 3.5 * wKg) / 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++) {
let p = sorted[i], prev = sorted[i - 1];
let d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
let v = dt > 0 ? d / dt : 0; // m/s
let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
// Count a REAL segment: not a dead-reckoning guess, not a teleport glitch. We do NOT
// drop long-gap segments here — if GPS was lost briefly, the straight line is a fair
// lower-bound of real travel (implied speed v<=segMaxMs already rejects teleports).
// DR fakes are excluded via !isEstimated, so no gap-time gate is needed.
let validSeg = !isEstimated && dt > 0 && v <= segMaxMs;
if (validSeg) {
distance += d;
speeds.push(v);
// Elevation: accumulate from smoothed altitude with 1m dead zone.
if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) {
let diff = smoothedAlt[i] - smoothedAlt[i - 1];
if (diff > 1) elevGain += diff;
else if (diff < -1) elevLoss += Math.abs(diff);
}
}
// Kcal calculation per segment
if (isEstimated) {
// Estimated point (GPS dead, accel confirmed movement): use walking MET.
// Time-based only — no distance (dead-reckoning drift is not real travel).
let walkMet = 3.5; // moderate walking ~4-5 km/h
totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200;
continue;
}
if (!validSeg) continue;
if (v < 0.3) {
// Standing still: only basal metabolic rate
totalKcal += basalPerMin * (dt / 60);
continue;
}
if (isBike) {
// Gradient from filtered (outlier-removed) raw altitude
const altHere = filteredAlt[i] ?? 0;
const altPrev = filteredAlt[i-1] ?? 0;
const altDiff = altHere - altPrev;
const gradient = d > 2 ? Math.max(-0.5, Math.min(0.5, altDiff / d)) : 0;
const sinG = gradient;
const cosG = Math.sqrt(1 - sinG * sinG);
// Per-point Crr from accelerometer (if available), else session-wide Crr
const ptAccel = sorted[i].accel_rms;
const ptCrr = (ptAccel != null && ptAccel > 0)
? (ptAccel < 3 ? 0.004 : ptAccel < 7 ? 0.006 : 0.008) * Math.sqrt(3.0 / tirePressure)
: Crr;
// Power required (watts): gravity + rolling resistance + aerodynamic drag
const pGravity = mTotal * g * sinG;
const pRolling = ptCrr * 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 * wKg) / 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;
// ---------------------------------------------------------------------------
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
// Scans smoothed altitude. Merges adjacent segments of same type. Min 50m distance.
// ---------------------------------------------------------------------------
const segments = [];
if (sorted.length >= 3) {
// Compute per-point gradient from smoothed altitude, accumulate into raw segments
const rawSegs = []; // { type, startIdx, endIdx }
let segStartIdx = 0;
let segType = "flat";
for (let i = 1; i < sorted.length; i++) {
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
if (d < 1) continue; // skip negligible distance
let grad = 0;
if (smoothedAlt[i] != null && smoothedAlt[i-1] != null) {
grad = (smoothedAlt[i] - smoothedAlt[i-1]) / d;
}
let pType = "flat";
if (grad > 0.02) pType = "climb";
else if (grad < -0.02) pType = "descent";
if (pType !== segType && i > segStartIdx + 1) {
rawSegs.push({ type: segType, startIdx: segStartIdx, endIdx: i - 1 });
segStartIdx = i - 1;
segType = pType;
}
}
rawSegs.push({ type: segType, startIdx: segStartIdx, endIdx: sorted.length - 1 });
// Merge adjacent segments of same type, and merge tiny segments (<50m) into neighbors
const merged = [];
for (const seg of rawSegs) {
if (merged.length > 0 && merged[merged.length - 1].type === seg.type) {
merged[merged.length - 1].endIdx = seg.endIdx;
} else {
merged.push({ ...seg });
}
}
// Absorb tiny segments into surrounding ones
const merged2 = [];
for (let i = 0; i < merged.length; i++) {
const seg = merged[i];
// Calculate distance for this segment
let segDist = 0;
for (let j = seg.startIdx + 1; j <= seg.endIdx; j++) {
segDist += GPS.distance(sorted[j-1].lat, sorted[j-1].lon, sorted[j].lat, sorted[j].lon);
}
if (segDist < 50 && merged2.length > 0) {
// Absorb into previous segment
merged2[merged2.length - 1].endIdx = seg.endIdx;
} else {
merged2.push({ ...seg });
}
}
// Build final segment stats
for (const seg of merged2) {
let segDist = 0, segGain = 0, segLoss = 0;
for (let j = seg.startIdx + 1; j <= seg.endIdx; j++) {
segDist += GPS.distance(sorted[j-1].lat, sorted[j-1].lon, sorted[j].lat, sorted[j].lon);
if (smoothedAlt[j] != null && smoothedAlt[j-1] != null) {
const diff = smoothedAlt[j] - smoothedAlt[j-1];
if (diff > 0) segGain += diff;
else segLoss += Math.abs(diff);
}
}
const segDuration = (new Date(sorted[seg.endIdx].ts) - new Date(sorted[seg.startIdx].ts)) / 1000;
const segAvgSpeed = segDuration > 0 ? (segDist / segDuration) * 3.6 : 0;
const elevChange = seg.type === "climb" ? segGain : seg.type === "descent" ? segLoss : Math.max(segGain, segLoss);
const avgGradient = segDist > 0 ? (elevChange / segDist) * 100 : 0;
segments.push({
type: seg.type,
startIdx: seg.startIdx,
endIdx: seg.endIdx,
distance: Math.round(segDist),
elevGain: Math.round(segGain),
elevLoss: Math.round(segLoss),
avgGradient: Math.round(avgGradient * 10) / 10,
duration: Math.round(segDuration),
avgSpeed: Math.round(segAvgSpeed * 10) / 10,
});
}
}
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,
segments,
};
};
// ---------------------------------------------------------------------------
// 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",
currentHr: null,
currentBleData: null,
hrPollInterval: null,
};
// 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 {
// Per-activity speed limits
const type = GPS.recording.activityType;
const isWalk = type === "walk" || type === "run";
const minSpd = isWalk ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs;
const maxSpd = isWalk ? (GPS.config.walkMaxSpeedKmh / 3.6) : (GPS.config.maxSpeedKmh / 3.6);
let fused = GPS.config.useFusedLocation ? 1 : 0;
let cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}:${fused}`;
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 {}
};
// Ask Android for background location ("Allow all the time") — required to record
// while the screen is off. On Android 11+ this opens the system location settings.
GPS.requestBackgroundLocation = async function() {
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "reqbgloc" });
} catch (e) { console.log("[GPS] reqbgloc:", e); }
};
// BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses.
// HR keeps measuring even when rider is stationary.
GPS._startHrMonitor = async function() {
if (GPS.recording.hrPollInterval) return;
try {
const addr = await DB.getProfileValue("ble_device_addr");
const type = await DB.getProfileValue("ble_device_type");
if (!addr || !type) { console.log("[HR] No paired device"); return; }
const connected = await window.__TAURI__.core.invoke("ble_is_connected");
if (!connected) {
console.log("[HR] Connecting to", addr, type);
// Just send connect — Kotlin handles scan+connect internally
try {
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
} catch (e) {
console.log("[HR] Connect failed:", e);
}
}
// Start polling HR regardless — data will appear when device connects
console.log("[HR] Starting HR poll");
GPS.recording.hrPollInterval = setInterval(async () => {
try {
const reading = await window.__TAURI__.core.invoke("ble_get_hr");
if (reading && reading.hr > 0) {
GPS.recording.currentHr = reading.hr;
GPS.recording.currentBleData = reading;
if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading);
}
} catch {}
}, 2000);
} catch (e) {
console.log("[HR] Monitor start failed:", e);
}
};
GPS._stopHrMonitor = function() {
if (GPS.recording.hrPollInterval) {
clearInterval(GPS.recording.hrPollInterval);
GPS.recording.hrPollInterval = null;
}
GPS.recording.currentHr = null;
GPS.recording.currentBleData = null;
// Clear stale HR data file so next session doesn't show old values
try { window.__TAURI__.core.invoke("ble_clear_hr"); } 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,
bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground
ts: new Date(d.ts).toISOString(),
accel_rms: d.accel_rms, accel_max: d.accel_max,
gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw,
estimated: d.estimated || false,
});
} 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 — same per-activity threshold as the native service
// (walk/run → walkMinSpeedMs, else minSpeedMs). 0 means "always moving":
// no auto-pause, no sticking on "начните движение".
let speed = p.speed_ms != null ? p.speed_ms : 0;
let mvType = GPS.recording.activityType;
let effMinSpeed = (mvType === "walk" || mvType === "run") ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs;
let isMoving = effMinSpeed <= 0 ? true : speed > effMinSpeed;
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;
try {
GPS.recording.sessionId = await DB.createGpsSession(
now.toISOString().slice(0, 10),
now.toTimeString().slice(0, 5),
GPS.recording.activityType
);
} catch (e) {
console.error("[GPS] CRITICAL: createGpsSession FAILED:", e);
// Show visible error — this is data loss
if (typeof showToast === "function") showToast("DB: " + (e.message || e));
}
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 — still save HR data during pause
if (!isMoving) {
if (!GPS.recording.pauseStart) {
GPS.recording.pauseStart = new Date(p.ts);
}
// Save point with HR even during pause (no seq = excluded from distance/kcal)
if (GPS.recording.sessionId && GPS.recording.currentBleData) {
DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData, p);
}
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, GPS.recording.currentBleData, p);
}
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,
tirePressureBar: GPS.recording.tirePressureBar ?? 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");
};
// True when an active session has had no movement for autoFinishIdleMin minutes —
// used to auto-finish a session the user forgot to stop, so the next outing is separate.
GPS.shouldAutoFinish = function() {
if (!GPS.recording.active || GPS.recording.manualPaused) return false;
if (!GPS.recording.sessionId) return false; // no session yet (no movement)
let last = GPS.recording.lastMovingPoint;
if (!last) return false;
let idleMs = Date.now() - new Date(last.ts).getTime();
return idleMs > (GPS.config.autoFinishIdleMin || 15) * 60000;
};
// Recover orphaned recording session on app startup
GPS.recoverSession = async function() {
const saved = localStorage.getItem("baldav_gps_active");
if (!saved) return false;
try {
const s = JSON.parse(saved);
if (!s.sessionId) { GPS._clearPersistedState(); return; }
// Import EVERY native point missing from the DB, deduped by the SET of existing
// timestamps — NOT "ts > maxDbTs". The live poll is screen-gated and leaves
// sparse points spread across the whole session, so the max DB ts can be the
// LAST point; "ts > maxDbTs" would then import nothing and the gaps stay.
const existingPts = await DB.getGpsPoints(s.sessionId);
const dbTs = new Set(existingPts.map(p => new Date(p.ts).getTime()));
const nativePoints = await GPS._readNativePoints();
let imported = 0;
let seq = existingPts.length;
for (const p of nativePoints) {
if (dbTs.has(new Date(p.ts).getTime())) continue;
seq++;
await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
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,
tirePressureBar: s.tirePressureBar ?? 3.0,
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();
return true;
}
GPS._clearPersistedState();
await GPS._stopNativeService();
return false;
} 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 = parseFloat(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();
// Start BLE HR monitor (auto-connect to paired device)
try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); }
// 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;
// Keep the heavy per-point path off (onPoint runs computeStats over all
// points + DB writes → O(n²) on a big backlog). Draw each imported point
// cheaply via onImportPoint so the track visibly GROWS as points transfer
// from the native file, and yield a frame every ~20 points so Leaflet
// repaints — this makes the screen-wake catch-up visible again.
const origOnPoint = GPS.recording.onPoint;
GPS.recording.onPoint = null;
for (const p of nativePoints) {
const pTs = new Date(p.ts).getTime();
if (pTs <= GPS.recording._lastProcessedTs) continue;
await GPS._processIncomingPoint(p);
GPS.recording._lastProcessedTs = pTs;
newCount++;
if (GPS.recording.onImportPoint) GPS.recording.onImportPoint(p);
// Yield a frame so the growing track repaints — but ONLY when visible.
// requestAnimationFrame never fires while the screen is off (page hidden),
// so awaiting it there would hang this loop forever and freeze the import.
if (newCount % 20 === 0 && !document.hidden) await new Promise(r => requestAnimationFrame(r));
}
GPS.recording.onPoint = origOnPoint;
// Final sync — marker, pan, course and stats from the full point set.
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,
bearing: (pos.coords.heading != null && !isNaN(pos.coords.heading)) ? pos.coords.heading : null,
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);
GPS._stopHrMonitor();
if (GPS.recording.watchId != null) {
navigator.geolocation.clearWatch(GPS.recording.watchId);
GPS.recording.watchId = null;
}
// Stop native service — read remaining points but DON'T delete the file yet
let nativePointsFile = null;
if (GPS.recording.nativeService) {
// Import EVERY native point not yet in the DB. The live poll is screen-gated
// (WebView freezes timers while the screen is off), so a walk with the screen
// off leaves most points only in the native file. Idempotent by timestamp —
// same pattern as recoverSession. Then recompute stats over the COMPLETE set.
const nativePoints = await GPS._readNativePoints();
if (GPS.recording.sessionId && nativePoints.length) {
const existing = await DB.getGpsPoints(GPS.recording.sessionId);
const dbTs = new Set(existing.map(p => new Date(p.ts).getTime()));
let seq = existing.length;
for (const p of nativePoints) {
if (dbTs.has(new Date(p.ts).getTime())) continue;
seq++;
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
}
GPS.recording.points = await DB.getGpsPoints(GPS.recording.sessionId);
}
// Remember the file name for cleanup after DB save
try {
nativePointsFile = await window.__TAURI__.core.invoke("get_points_filename");
} catch {}
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,
tirePressureBar: GPS.recording.tirePressureBar ?? 3.0,
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);
try {
await DB.finishGpsSession(
GPS.recording.sessionId, endTime,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss
);
// DB save confirmed — now safe to delete the points file
if (nativePointsFile) {
try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {}
}
} catch (e) {
console.error("[GPS] finishGpsSession FAILED — points file preserved:", e);
if (typeof showToast === "function") showToast("Сохранение трека: " + (e.message || e));
}
// 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.tirePressureBar = 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, parseFloat(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;
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
// Manual routes: skip glitch filtering (points are user-placed, not GPS).
// Just fix timestamps and recompute stats.
if (session.source === "manual") {
const isWalkM = session.activity_type === "walk" || session.activity_type === "run";
const avgSpeedMs = isWalkM
? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6
: GPS.config.bikeAvgSpeedKmh / 3.6;
let cumTime = 0;
const baseTs = new Date(sorted[0].ts).getTime();
for (let i = 0; i < sorted.length; i++) {
if (i > 0) {
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
cumTime += d / avgSpeedMs;
}
const newTs = new Date(baseTs + cumTime * 1000).toISOString();
await invoke("UPDATE gps_points SET ts=?1, speed_ms=?2, seq=?3 WHERE session_id=?4 AND id=?5",
[newTs, avgSpeedMs, i + 1, sessionId, sorted[i].id]);
sorted[i].ts = newTs;
sorted[i].speed_ms = avgSpeedMs;
}
// Compute stats and finish
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
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(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
});
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
return stats;
}
// --- Recorded sessions: glitch filtering ---
const isWalk = session.activity_type === "walk" || session.activity_type === "run";
const maxMs = (isWalk ? GPS.config.walkMaxSpeedKmh : GPS.config.maxSpeedKmh) / 3.6;
// First pass: remove points with absurd timestamps (>1 day jump)
let deleted = 0;
if (sorted.length >= 3) {
const sessionStart = new Date(sorted[0].ts).getTime();
const sessionEnd = new Date(sorted[sorted.length - 1].ts).getTime();
const maxDuration = 24 * 3600 * 1000; // 24 hours max session
for (let i = sorted.length - 1; i >= 0; i--) {
const t = new Date(sorted[i].ts).getTime();
if (t - sessionStart > maxDuration || t < sessionStart - 60000) {
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
sorted.splice(i, 1);
deleted++;
}
}
}
// Second pass: speed + accel + altitude filter
for (let i = 1; i < sorted.length; i++) {
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
const speedMs = dt > 0 ? d / dt : 0;
const accel = sorted[i].accel_rms;
// Altitude jump: >100m between consecutive points = impossible
const alt = sorted[i].altitude_gps_m;
const prevAlt = sorted[i-1].altitude_gps_m;
const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0;
// Glitch detection:
// 1. Speed exceeds max for activity type
// 2. Accelerometer = 0 but GPS shows movement (phone was still → GPS drift)
// 3. High speed + normal walking accel (can't walk at 40 km/h)
// 4. Altitude jumps >100m in one step (impossible on foot or bike)
const isGlitch =
(dt > 0 && speedMs > maxMs) ||
(accel != null && accel === 0 && speedMs > 3) ||
(isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) ||
(altJump > 100);
if (isGlitch) {
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
sorted.splice(i, 1);
i--;
deleted++;
}
}
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
// Interpolate gaps where accelerometer shows movement
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
let interpolated = 0;
for (let i = 1; i < sorted.length; i++) {
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
if (dt < 30 || dt > 3600) continue; // only gaps 30s - 1h
const accelBefore = sorted[i-1].accel_rms;
const accelAfter = sorted[i].accel_rms;
// Both ends show movement → person was moving during gap
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
// Add interpolated distance to total (will be counted by computeStats naturally
// since the gap points are adjacent and haversine gives straight-line distance)
// Mark gap as "moving" by ensuring speed_ms is set
interpolated++;
}
}
if (interpolated) console.log(`[GPS] Found ${interpolated} gaps with movement (accel-confirmed)`);
// Fix manual routes: timestamps are fake (1s apart), recalculate based on realistic speed
if (session.source === "manual" || sorted.length >= 2) {
const t0 = new Date(sorted[0].ts).getTime();
const tN = new Date(sorted[sorted.length - 1].ts).getTime();
const totalTimeMs = tN - t0;
// Compute total distance
let totalDist = 0;
for (let i = 1; i < sorted.length; i++) {
totalDist += GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
}
const impliedSpeedMs = totalTimeMs > 0 ? (totalDist / (totalTimeMs / 1000)) : 0;
// If implied speed is absurd (>30 m/s for walk, >50 for bike) → fix timestamps
const saneMaxMs = isWalk ? 5 : 15; // 18 km/h walk, 54 km/h bike
if (impliedSpeedMs > saneMaxMs && totalDist > 100) {
const avgSpeedMs = isWalk
? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6
: GPS.config.bikeAvgSpeedKmh / 3.6;
let cumTime = 0;
const baseTs = new Date(sorted[0].ts).getTime();
for (let i = 0; i < sorted.length; i++) {
if (i > 0) {
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
cumTime += d / avgSpeedMs;
}
const newTs = new Date(baseTs + cumTime * 1000).toISOString();
await invoke("UPDATE gps_points SET ts = ?1, speed_ms = ?2, seq = ?3 WHERE session_id = ?4 AND ts = ?5",
[newTs, avgSpeedMs, i + 1, sessionId, sorted[i].ts]);
sorted[i].ts = newTs;
sorted[i].speed_ms = avgSpeedMs;
}
console.log(`[GPS] Fixed manual route timestamps: ${sorted.length} pts, avg ${avgSpeedMs} m/s`);
} else if (deleted > 0) {
// Just re-assign seq
for (let i = 0; i < sorted.length; i++) {
await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]);
}
}
}
// Get user weight: latest weight log, or profile, or 80
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(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 };
// Use cleaned sorted array, not original points
const stats = GPS.computeStats(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
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,
tirePressureBar: GPS.recording.tirePressureBar ?? 3.0,
chainringTeeth: GPS.recording.chainringTeeth,
cogTeeth: GPS.recording.cogTeeth,
wheelDiameterInch: GPS.recording.wheelDiameterInch,
};
};
// Filter outliers and smooth altitude array for chart display
GPS.filterAndSmoothAlt = function(rawAlts) {
const f = rawAlts.slice();
// Outlier spikes
for (let i = 1; i < f.length - 1; i++) {
if (f[i] == null) continue;
const prev = f[i-1], next = f[i+1];
if (prev == null || next == null) continue;
if (Math.abs(f[i] - prev) > 4 && Math.abs(f[i] - next) > 4 && Math.abs(next - prev) < 3) {
f[i] = (prev + next) / 2;
}
}
// Stuck runs: same value 5+ times = GPS frozen → interpolate
let i = 0;
while (i < f.length) {
if (f[i] == null) { i++; continue; }
let j = i + 1;
while (j < f.length && f[j] != null && Math.abs(f[j] - f[i]) < 0.2) j++;
if (j - i >= 5) {
const before = i > 0 ? f[i - 1] : f[i];
const after = j < f.length ? f[j] : f[j - 1];
for (let k = i; k < j; k++) {
const t = (k - i + 1) / (j - i + 1);
f[k] = before + (after - before) * t;
}
}
i = j;
}
// Smooth
const half = GPS.config.altSmoothHalf ?? 2;
return f.map((alt, idx) => {
if (alt == null) return null;
let sum = 0, cnt = 0;
for (let j = Math.max(0, idx - half); j <= Math.min(f.length - 1, idx + half); j++) {
if (f[j] != null) { sum += f[j]; cnt++; }
}
return cnt > 0 ? sum / cnt : alt;
});
};
window.GPS = GPS;
console.log("[gps.js] window.GPS ready");