baldav/frontend/js/gps.js
4aiman 9f8ab0eeb1 GPS model: accel-based Crr scale, auto walk-the-bike split; document climb/elevation limits
Acted on docs/gps-model-findings.md (independent analysis of 156 rides).

Implemented:
- crrFromAccel extended past the old 0.008 ceiling: 7-11 -> 0.008,
  11-15 -> 0.012 (packed dirt), >15 -> 0.020 (grass/rut/gravel). Rough
  surfaces were undercounted ~3x. Applied to session median and per-point Crr.
- Auto walk-the-bike detection (single-speed only, gear_count 1/unset):
  a normal-fix segment slower than the 30-rpm-cadence speed is the rider
  pushing, not riding. Excluded from ride distance/speed/elevation (elevRef
  reset so a pushed climb doesn't merge with the ridden one); effort counted
  as walking, not bike physics. Every sub-threshold point excluded regardless
  of duration; only runs >=30s reported as walked (walkBikeSec/walkBikeMeters).
  gearCount threaded through every computeStats/finish caller.

Documented as known limitations (left as-is by decision, need calibration/DEM):
- kcal max(physics, MET) never sums the biological overhead, so climbs are
  slightly undercounted (comment + README + PROJECT_MAP).
- absolute elevation gain is bounded by the raw GPS altimeter (tens of
  percent; needs a terrain model / wired DEM preload).
2026-09-07 15:50:19 +03:00

1962 lines
88 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 мин без движения (забыл «Конец»)
trackColor: '#bb86fc', // цвет линии текущего/записанного трека (по умолчанию фиолетовый темы)
roadColorWalk: '#8B5E3C', // цвет наложения проезженных/пройденных дорог: ходьба
roadColorBike: '#2266aa', // ... вело
roadColorRun: '#e67e22', // ... бег
gpxzKey: '', // API-ключ GPXZ (точные высоты Copernicus GLO-30). Пусто = open-elevation (SRTM). Хранится только тут (localStorage), не в git.
};
// 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 };
// Accuracy-threshold recompute: exclude real GPS fixes coarser than minAccuracyM
// (kept: no-accuracy imports and estimated fills). Non-destructive — this only
// affects the computed stats; every recompute path funnels through here.
points = GPS.filterByAccuracy(points);
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;
let elevRef = null; // running reference for cumulative elevation hysteresis
const speeds = []; // m/s per segment
// Manual pauses (ms epoch): the rider hand-paused, so that stretch must NOT count
// (walked, not ridden) — unlike an automatic GPS gap in motion, which we keep.
// A segment that touches a pause interval is dropped and elevRef is reset (so the
// elevation delta across the pause is not banked as a climb).
const manualPauses = (opts && opts.pauses ? opts.pauses : [])
.map(z => ({ s: z.startTs, e: z.endTs })).filter(z => z.s != null && z.e != null);
const inManualPause = (aMs, bMs) => manualPauses.some(z => aMs < z.e && bMs > z.s);
const MAX_GRADE = GPS.config.maxRealGradePct ?? 1.0; // 1.0 = 100% = 45°; steeper adjacent alt step = glitch
let pausedSec = 0;
// 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).
// Scale (lower bound of a back-solve on measured tracks — better to undercount):
// RMS < 3 → 0.004 smooth asphalt | 11-15 → 0.012 packed dirt
// 3-7 → 0.006 mixed | > 15 → 0.020 grass / rut / gravel
// 7-11 → 0.008 broken asphalt
// The old ceiling of 0.008 (gravel) undercounted rough surfaces ~3x: measured
// grass+tractor-rut needs 0.020-0.031, packed dirt 0.012. crrFromAccel maps both
// the session median (default Crr) and each point (ptCrr below) through this scale.
const crrFromAccel = (rms) => rms < 3 ? 0.004 : rms < 7 ? 0.006 : rms < 11 ? 0.008 : rms < 15 ? 0.012 : 0.020;
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)];
baseCrr = crrFromAccel(medianAccel); // 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;
// Walking the bike (single-speed only). On a fixed ratio, below ~30 rpm cadence
// the bike can't stay upright — so a sustained sub-30-rpm speed means the rider
// is pushing it, not riding. speed = cadence * distance-per-crank-rev.
// distance-per-rev = wheelCirc * gearRatio; at 30 rpm → walkBikeMs m/s.
// Only for single-speed (gear_count 1 or unset): on a geared bike low speed proves
// nothing (accelerometer can't tell pushing from riding — measured). Sub-threshold
// segments are excluded from ride distance/speed/elevation so a pushed climb doesn't
// merge with the ridden one; the effort is counted as walking, not bike physics.
const gearCount = opts?.gearCount;
const singleSpeed = isBike && hasGearData && (gearCount == null || gearCount <= 1);
const walkBikeMs = singleSpeed ? (30 * wheelCirc * gearRatio) / 60 : 0;
let walkBikeSec = 0, walkBikeDist = 0; // reported total (only runs ≥30s, see #7)
let wbRunSec = 0, wbRunDist = 0; // current contiguous push run
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
// Manual pause: exclude entirely (walked, not ridden). Reset elevRef so the
// elevation gained during the pause isn't banked on the next real segment.
let prevMs = new Date(prev.ts).getTime(), pMs = new Date(p.ts).getTime();
if (inManualPause(prevMs, pMs)) { pausedSec += Math.max(0, dt); elevRef = null; continue; }
let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
let gapThr = GPS.config.gapThresholdSec ?? 30;
// Per-segment distance. Across a GPS gap (dt > gapThreshold — the fix rate
// dropped, or dead-reckoning) the straight chord under/over-counts, so estimate
// the path by reported speed x time, capped at the max speed. A tiny chord over
// a long gap means the rider was stopped and the reported speed is stale — trust
// the position. A fast jump WITHOUT a time gap (dt small, v > max) is a teleport
// glitch → drop it. The estimate depends on ts + speed only, so a track computes
// the same whether from live points (which carry an `estimated` flag) or DB rows
// (which don't) — live, Stop and recompute now agree.
let isSpike = dt > 0 && dt <= gapThr && v > segMaxMs;
let isGap = dt > gapThr;
let segDist;
if (isSpike) {
segDist = 0;
} else if (isGap) {
let avgV = ((sorted[i].speed_ms ?? 0) + (sorted[i - 1].speed_ms ?? 0)) / 2;
segDist = (d < 20) ? d : Math.min(avgV * dt, segMaxMs * dt);
} else {
segDist = d;
}
// A normal moving segment (continuous fix within the speed limit) drives the
// altitude and max-speed stats; gap and spike segments do not.
let normalMove = !isGap && !isSpike && dt > 0;
// Walking the bike (single-speed): a normal-fix segment slower than the 30-rpm
// speed. Exclude it from ride distance/speed/elevation (elevRef reset breaks the
// pushed-vs-ridden climb merge) and count the effort as walking, not bike physics.
// Per #7: exclude EVERY such point regardless of duration (brief brake-to-stops
// land here too — harmless, ~0 m and low cost); accumulate contiguous runs and
// only report those ≥30s as "walked" (a stop before a pothole isn't walking).
if (walkBikeMs > 0 && normalMove && v > 0 && v < walkBikeMs) {
wbRunSec += dt; wbRunDist += segDist;
elevRef = null;
const wKmh = v * 3.6;
const wmet = wKmh < 4 ? 2.5 : wKmh < 5.5 ? 3.5 : wKmh < 7 ? 5.0 : 8.0;
totalKcal += (dt / 60) * ((wmet - 1) * 3.5 * wKg) / 200; // NET (BMR booked in daily balance)
continue;
}
// Not (or no longer) pushing → flush a completed push run into the reported total.
if (wbRunSec >= 30) { walkBikeSec += wbRunSec; walkBikeDist += wbRunDist; }
wbRunSec = 0; wbRunDist = 0;
if (segDist > 0) distance += segDist;
if (normalMove) {
speeds.push(v);
// Elevation: cumulative hysteresis against a running reference — NOT the diff
// between adjacent points. On dense tracks (e.g. 5000 pts / 16 km) the per-point
// change is < 1 m, so an adjacent-diff dead zone accumulates 0 even on real
// climbs. Accumulate the rise/fall from the reference and only bank it (and
// advance the reference) once it clears the 1 m dead zone.
if (smoothedAlt[i] != null) {
// Reject a physically impossible adjacent altitude step (glitch — incl. an
// Android no-fix 0.0): grade over the segment steeper than MAX_GRADE (45°).
// A smooth change to any value (e.g. a real 0 near a river) passes.
let prevAlt = smoothedAlt[i - 1];
let glitch = prevAlt != null && segDist > 0 && Math.abs(smoothedAlt[i] - prevAlt) / segDist > MAX_GRADE;
if (!glitch) {
if (elevRef == null) elevRef = smoothedAlt[i];
let rise = smoothedAlt[i] - elevRef;
if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; }
else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; }
}
}
}
// Kcal calculation per segment
if (isSpike) continue;
if (isGap || isEstimated) {
// GPS gap / dead-reckoning: time-based walking MET (no reliable physics here).
let walkMet = 3.5; // moderate walking ~4-5 km/h
totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200;
continue;
}
if (v < 0.3) {
// Standing still: only basal metabolic rate
totalKcal += basalPerMin * (dt / 60);
continue;
}
if (isBike) {
// Gradient from SMOOTHED altitude (terrain-preferred, outlier-removed, smoothed)
// — using the raw filtered altitude let GPS-altitude noise inflate the gradient
// (and kcal) on un-refined tracks, making sessions inconsistent.
const altHere = smoothedAlt[i] ?? filteredAlt[i] ?? 0;
const altPrev = smoothedAlt[i-1] ?? 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)
? crrFromAccel(ptAccel) * 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);
// Pedaling vs coasting. If the bike is freewheeling (you're NOT turning the
// pedals) it costs only basal — no matter the speed. How we know:
// - Physics proxy (what we use): power<=0 means the bike would roll at >=
// this speed unpowered — a downhill coast → basal. Otherwise → pedaling.
// (This proxy misses FLAT coasting, but that's rare and minor.)
// - cadence_rpm hook: reserved for a real crank cadence sensor (BLE) if one
// is ever added — that's the only reliable source. Deriving cadence from
// the phone IMU was tested on real 50Hz data and does NOT separate
// pedaling from coasting (~76-81% ≈ baseline), so it is not used.
const cad = sorted[i].cadence_rpm;
const pedaling = (cad != null) ? (cad >= 20) : (power > 0);
if (pedaling) {
// Both estimates NET (above resting): the daily balance already books BMR
// for the whole day (expend = BMR*activityLevel + activity, main.js), so
// activity kcal must be NET — never add basal here.
// 1. physics: mechanical work / drivetrain efficiency. At 15-18 km/h the
// mechanical power is only ~50-80 W, so on the flat this UNDERESTIMATES
// real metabolic cost ~2x — a lower bound; wins only on steep climbs.
// 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest
// BMR already covers — the empirically measured cost.
const physicsKcal = Math.max(0, power) * dt / efficiency / 4184;
const vKmh = v * 3.6;
if (vKmh >= 5) {
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
const metNetKcal = (dt / 60) * ((met - 1) * 3.5 * wKg) / 200;
// KNOWN LIMITATION (measured, left as-is by decision): max() never SUMS the
// two, so on climbs — where physics wins big — the biological overhead the
// MET floor captures (balancing, core stabilisation, no external work) is
// dropped. Direction of the error is known: climbs are undercounted (whole-
// ride ~1.34x MET/physics, but flat 1.85x and climb 0.41x). A principled fix
// (physics/eff + overhead) needs calibration, so it is deferred, not applied.
totalKcal += Math.max(physicsKcal, metNetKcal);
} else {
totalKcal += physicsKcal; // creeping < 5 km/h: negligible
}
// Cadence estimate from gearing (theoretical) for avgCadence output.
if (hasGearData && wheelCirc > 0) {
const wheelRevs = d / wheelCirc;
totalPedalRevs += wheelRevs / gearRatio;
pedalingSeconds += dt;
}
} else {
// Coasting / freewheeling: 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;
}
}
// Flush a push run still open at the track's end.
if (wbRunSec >= 30) { walkBikeSec += wbRunSec; walkBikeDist += wbRunDist; }
// 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);
// Exclude manual-pause time and sustained bike-push time — both keep avgSpeed
// (distance/duration) a pure riding figure, consistent with the excluded distance.
const duration = Math.max(0, (last - first) / 1000 - pausedSec - walkBikeSec);
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,
walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs)
walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance)
};
};
// ---------------------------------------------------------------------------
// Savitzky-Golay track smoothing (de-jitter). Quadratic least-squares fit of
// x(i) and y(i) over a window, evaluated at the centre, iterated. Removes GPS
// sideways jitter while keeping real corners better than a plain moving average.
// Returns NEW point objects with smoothed lat/lon; all other fields preserved.
// Endpoints anchored. window = points on each side, iters = passes.
// ---------------------------------------------------------------------------
GPS.smoothTrackSG = function(points, window, iters) {
const n = points.length;
if (n < 5) return points.map(p => ({ ...p }));
const W = Math.max(1, window || 4), IT = Math.max(1, iters || 3);
const lat0 = points[0].lat * Math.PI / 180, mLat = 111320, mLon = 111320 * Math.cos(lat0);
let xy = points.map(p => ({ x: (p.lon - points[0].lon) * mLon, y: (p.lat - points[0].lat) * mLat }));
const det3 = m =>
m[0][0]*(m[1][1]*m[2][2]-m[1][2]*m[2][1]) - m[0][1]*(m[1][0]*m[2][2]-m[1][2]*m[2][0]) + m[0][2]*(m[1][0]*m[2][1]-m[1][1]*m[2][0]);
const pass = a => {
const out = a.map(p => ({ x: p.x, y: p.y }));
for (let i = 0; i < a.length; i++) {
const s = Math.max(0, i - W), e = Math.min(a.length - 1, i + W);
const fit = get => {
let S0=0,S1=0,S2=0,S3=0,S4=0,T0=0,T1=0,T2=0;
for (let j = s; j <= e; j++) { const t = j - i, v = get(a[j]); S0+=1;S1+=t;S2+=t*t;S3+=t*t*t;S4+=t*t*t*t;T0+=v;T1+=v*t;T2+=v*t*t; }
const M=[[S0,S1,S2],[S1,S2,S3],[S2,S3,S4]], D=det3(M);
if (Math.abs(D) < 1e-9) return get(a[i]);
const M0=[[T0,M[0][1],M[0][2]],[T1,M[1][1],M[1][2]],[T2,M[2][1],M[2][2]]];
return det3(M0)/D;
};
out[i] = { x: fit(p => p.x), y: fit(p => p.y) };
}
out[0] = a[0]; out[a.length-1] = a[a.length-1];
return out;
};
for (let k = 0; k < IT; k++) xy = pass(xy);
return points.map((p, i) => ({ ...p, lat: points[0].lat + xy[i].y / mLat, lon: points[0].lon + xy[i].x / mLon }));
};
// ---------------------------------------------------------------------------
// 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
// Parse raw JSONL (one native point per line) into sorted point objects.
GPS._parseNativePoints = function(raw) {
const points = [];
if (!raw) return 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 */ }
}
points.sort((a, b) => new Date(a.ts) - new Date(b.ts));
return points;
};
GPS._readNativePoints = async function() {
try {
const raw = await window.__TAURI__.core.invoke("gps_service_read_points");
return GPS._parseNativePoints(raw);
} catch { return []; }
};
// On-device track recovery. The foreground service writes every point to a
// native gps_points_<ms>.jsonl regardless of screen state, so these files are
// the source of truth for screen-off stretches the WebView missed.
//
// MATCHING — both a file and a session carry their own start timestamp, so we
// match them directly by id/date, never by "which session has points here":
// * The filename's <ms> is when the service started; a session's id is
// Date.now() at first movement. Empirically the file always starts a few
// seconds to ~25 min BEFORE its session (warmup before moving), same date.
// * A file belongs to session S iff S.date == file's date AND the file's start
// falls inside S's point window ±30 min. That rejects orphan recordings that
// left no session (e.g. the 07-05 files, hours from any session) and can
// never glue a file onto a foreign date.
//
// SAFETY (learned the hard way — a naive version corrupted real rides):
// * Only import file points whose own date == the session's date, so a stray
// glitched timestamp inside a correctly-matched file can't leak in.
// * recalculateSession is called with {noDelete:true}: recovery ADDS points, it
// must never trigger the destructive glitch/>24h filter that once wiped the
// real 07-22/07-24 rides.
GPS.recoverFromFiles = async function(onProgress) {
if (GPS.recording.active) return { error: "recording" };
const invoke = window.__TAURI__.core.invoke;
const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] });
let files = [];
try { files = JSON.parse(await invoke("list_points_files")); }
catch (e) { console.error("[recover] list_points_files FAILED", e); }
console.log(`[recover] files=${files.length}`);
// Load every session's date + actual point window once. CAST id to TEXT — the
// SQL plugin corrupts large i64 ids returned as numbers.
let sessRows = [];
try {
sessRows = await sel(
"SELECT CAST(s.id AS TEXT) AS sid, s.date AS d, MIN(p.ts) AS mn, MAX(p.ts) AS mx " +
"FROM gps_sessions s JOIN gps_points p ON p.session_id = s.id GROUP BY s.id"
);
} catch (e) { console.error("[recover] session windows FAILED", e); }
const sessions = sessRows.map(r => ({
sid: r.sid, date: r.d,
mn: new Date(r.mn).getTime(), mx: new Date(r.mx).getTime(),
}));
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
const NEAR_MS = 30 * 60 * 1000; // file start may lead the session by up to ~25 min
// The file the service is (or was last) writing — never delete it, a resumed
// session may still append to it.
let activeFile = "";
try { activeFile = (await invoke("get_points_filename")) || ""; } catch {}
const results = [];
for (let fi = 0; fi < files.length; fi++) {
const name = files[fi];
if (onProgress) { try { onProgress(fi + 1, files.length); } catch {} }
// The file's start ms and (for new files) its owning session id live in the
// name: gps_points_<ms>.jsonl or gps_points_<ms>_s<sessionId>.jsonl.
const m = /gps_points_(\d+)(?:_s(\d+))?\.jsonl/.exec(name);
if (!m) { console.log(`[recover] f=${name} no id in name, skip`); continue; }
const fileMs = Number(m[1]);
const boundSid = m[2] || null; // service stamped the session id into the name
const fDate = dayOf(fileMs);
let match = null;
if (boundSid) {
// Unambiguous binding: the file already names its session. Bind directly;
// if that session was deleted, leave the file unmatched (never inject).
match = sessions.find(s => s.sid === boundSid) || null;
if (!match) {
console.log(`[recover] f=${name} bound sid=${boundSid} but session gone — UNMATCHED`);
results.push({ file: name, imported: 0, unmatched: true });
continue;
}
} else {
// Legacy files (no session id in name): match by id/date — same date, file
// start inside the session window ±30 min, nearest such session wins.
let bestGap = Infinity;
for (const s of sessions) {
if (s.date !== fDate) continue;
const gap = fileMs < s.mn ? s.mn - fileMs : (fileMs > s.mx ? fileMs - s.mx : 0);
if (gap <= NEAR_MS && gap < bestGap) { bestGap = gap; match = s; }
}
if (!match) {
console.log(`[recover] f=${name} start=${new Date(fileMs).toISOString()} UNMATCHED`);
results.push({ file: name, imported: 0, unmatched: true });
continue;
}
}
const sid = match.sid;
let raw = "";
try { raw = await invoke("read_points_file", { filename: name }); }
catch (e) { console.error(`[recover] read FAILED ${name}`, e); continue; }
const pts = GPS._parseNativePoints(raw);
if (pts.length < 2) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; }
// Only points on the matched session's own date may be imported (blocks a
// stray glitched timestamp inside an otherwise-correct file).
const sameDay = pts.filter(p => p.ts.slice(0, 10) === match.date);
if (!sameDay.length) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; }
const existing = await DB.getGpsPoints(sid);
// Dedup by TIME PROXIMITY, not exact ms. The native service and the WebView
// watchPosition record the SAME fixes with slightly different millisecond
// stamps, so exact-ms dedup keeps both → interleaved zig-zag inflates
// distance/kcal. Skip a native point if any existing point is within PROX ms;
// keep only points in genuine gaps (screen-off stretches the WebView missed).
const PROX = 2500;
const existMs = existing.map(p => new Date(p.ts).getTime()).sort((a, b) => a - b);
const nearExisting = (t) => {
let lo = 0, hi = existMs.length - 1;
while (lo <= hi) { let mid = (lo + hi) >> 1; if (existMs[mid] < t) lo = mid + 1; else hi = mid - 1; }
let best = Infinity;
if (lo < existMs.length) best = Math.min(best, existMs[lo] - t);
if (lo > 0) best = Math.min(best, t - existMs[lo - 1]);
return best <= PROX;
};
const missing = sameDay.filter(p => !nearExisting(new Date(p.ts).getTime()));
let imported = 0;
if (missing.length) imported = await DB.addGpsPointsBulk(sid, missing, existing.length);
// Recalc so elevation/kcal fixes apply even when nothing new was imported.
// noDelete: recovery must NEVER run the destructive glitch/>24h filter.
try { await GPS.recalculateSession(sid, { noDelete: true }); }
catch (e) { console.error("[recover] recalc failed", e); }
// The file's points are now safely in the DB — delete it to clear the backlog
// clutter. Skip the active file. Orphan/unmatched files are kept (their points
// have no session, so deleting would lose data).
let removed = false;
if (name !== activeFile) {
try { await invoke("delete_points_file", { filename: name }); removed = true; }
catch (e) { console.warn(`[recover] delete failed ${name}`, e); }
}
console.log(`[recover] f=${name} pts=${pts.length} match=${sid} existing=${existing.length} imported=${imported} removed=${removed}`);
results.push({ file: name, sessionId: sid, imported, removed });
}
const removedN = results.filter(r => r.removed).length;
console.log(`[recover] DONE files=${results.length} imported=${results.reduce((a,r)=>a+(r.imported||0),0)} removed=${removedN}`);
return { results, removed: removedN };
};
// Core point processing — used by both WebView GPS (watchPosition) and native
// service polling. `fromNative` = the point came from the native file via the
// poll. On Android the native file is the complete, screen-independent source of
// truth, so only native points are stored; watchPosition then just drives the
// live marker — the same fix is never written twice with different timestamps.
GPS._processIncomingPoint = async function(p, fromNative) {
if (!GPS.recording.active) return;
// DB writes come only from the source of truth: native points on Android, or
// watchPosition on desktop (no native service).
const writeDb = !!fromNative || !GPS.recording.nativeService;
// 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));
}
// Stamp the session id into the native file's name so recovery binds it
// unambiguously (the service renames gps_points_<ms>.jsonl -> ..._s<id>.jsonl).
if (GPS.recording.sessionId != null) {
try { await window.__TAURI__.core.invoke("gps_set_session_id", { sessionId: String(GPS.recording.sessionId) }); }
catch (e) { console.warn("[GPS] gps_set_session_id failed (non-critical):", 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 (writeDb && 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 — only from the source of truth. watchPosition on
// Android skips the write (the poll stores the native equivalent), so distance
// and kcal aren't inflated by the same fix landing twice.
GPS.recording.lastMovingPoint = p;
if (writeDb) {
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,
gearCount: GPS.recording.gearCount ?? 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,
gearCount: s.gearCount,
pauses: s.pauses || [],
};
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, s.pauses || []);
// 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 () => {
console.log(`[poll] tick active=${GPS.recording.active} polling=${polling} hidden=${document.hidden} lastTs=${GPS.recording._lastProcessedTs}`);
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();
console.log(`[poll] native=${nativePoints.length} firstTs=${nativePoints[0]?.ts} lastNativeTs=${nativePoints[nativePoints.length-1]?.ts}`);
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, true);
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) {
console.log(`[poll] raf-wait at ${newCount}`);
await new Promise(r => requestAnimationFrame(r));
console.log(`[poll] raf-done at ${newCount}`);
}
}
GPS.recording.onPoint = origOnPoint;
console.log(`[poll] imported=${newCount} newLastTs=${GPS.recording._lastProcessedTs}`);
// 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;
console.log(`[stop] enter nativeService=${GPS.recording.nativeService} sessionId=${GPS.recording.sessionId}`);
if (GPS.recording.nativeService) {
// Import ONLY the un-polled tail: native points with ts beyond the last one
// the live poll already PROCESSED (_lastProcessedTs). The poll advances that
// marker for every native point it handles, including ones it intentionally
// dropped (stationary / auto-pause / wrong state). The old code re-scanned the
// whole file and re-added everything "not in the DB" — which raw-inserted those
// dropped points back, interleaving them into the clean track and inflating
// distance with zigzag. Screen-off case still works: the poll was frozen, so
// _lastProcessedTs stays old and the whole dark stretch is beyond it.
const nativePoints = await GPS._readNativePoints();
let stopImported = 0;
if (GPS.recording.sessionId && nativePoints.length) {
const existing = await DB.getGpsPoints(GPS.recording.sessionId);
let seq = existing.length;
for (const p of nativePoints) {
const pTs = new Date(p.ts).getTime();
if (pTs <= GPS.recording._lastProcessedTs) continue;
seq++;
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
GPS.recording._lastProcessedTs = pTs;
stopImported++;
}
console.log(`[stop] native=${nativePoints.length} existing=${existing.length} tail-imported=${stopImported}`);
GPS.recording.points = await DB.getGpsPoints(GPS.recording.sessionId);
} else {
console.log(`[stop] SKIP backfill: sessionId=${GPS.recording.sessionId} native=${nativePoints.length}`);
}
// 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,
gearCount: GPS.recording.gearCount,
};
const recPauses = GPS.recording.pauses || [];
recOpts.pauses = recPauses;
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, recPauses
);
// 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.recording.gearCount = 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 };
};
// Parse a GPX file as a REFERENCE ROUTE (a line to overlay on the map), not an
// activity. Accepts <rtept> (Yandex Map Constructor), <trkpt> or <wpt> — whichever
// the file uses. Returns { name, points:[{lat,lon}] }.
GPS.parseRouteGPX = function(xmlString) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, "application/xml");
const points = [];
for (const tag of ["rtept", "trkpt", "wpt"]) {
const els = doc.querySelectorAll(tag);
if (els.length) {
els.forEach(pt => {
const lat = parseFloat(pt.getAttribute("lat"));
const lon = parseFloat(pt.getAttribute("lon"));
if (isNaN(lat) || isNaN(lon)) return;
const point = { lat, lon };
// Elevation is optional: tools like Komoot/BRouter emit <ele>, Yandex does not.
const eleEl = pt.querySelector("ele");
if (eleEl) { const e = parseFloat(eleEl.textContent); if (!isNaN(e)) point.ele = e; }
points.push(point);
});
break; // first tag type that has points wins
}
}
let name = "";
const nameEl = doc.querySelector("metadata > name") || doc.querySelector("rte > name")
|| doc.querySelector("trk > name") || doc.querySelector("name");
if (nameEl) name = (nameEl.textContent || "").trim();
return { name, points };
};
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.
// Drop real GPS fixes coarser than the accuracy threshold (GPS.config.minAccuracyM).
// This is the post-hoc "recompute with an accuracy threshold": NON-destructive —
// DB points are untouched, so raising the threshold brings points back. Points with
// no measured accuracy (imported GPX/FTB, older recordings) and estimated dead-
// reckoning fills (sentinel accuracy >= 999) are always kept.
GPS.filterByAccuracy = function(points, threshold) {
const t = (threshold != null) ? threshold : GPS.config.minAccuracyM;
if (!t || t <= 0) return points;
return points.filter(p => {
const acc = (p.accuracy_m != null) ? p.accuracy_m : p.accuracy;
if (acc == null || acc >= 999) return true; // no measure / estimated → keep
return acc <= t;
});
};
GPS.filterNoise = function(points, activityType) {
return GPS.filterByAccuracy(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, opts) {
opts = opts || {};
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;
// Glitch filtering DELETES points. It is destructive and MUST be skipped for
// recovery (opts.noDelete): if a wrong-date file ever slips in, the ">24h"
// rule below would wipe the real track. Recovery only adds points, never deletes.
let deleted = 0;
if (!opts.noDelete) {
// First pass: remove points with absurd timestamps (>1 day jump)
if (sorted.length >= 3) {
const sessionStart = new Date(sorted[0].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: speed over max, accel=0 but moving, fast+walking-accel, or >100m alt jump.
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 sessPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
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,
gearCount: bike?.gear_count,
pauses: sessPauses,
});
// Update session in DB (preserve pauses)
await DB.finishGpsSession(sessionId,
session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss, sessPauses
);
return stats;
};
// Recompute ONLY the stored stats (distance / kcal / elevation / speeds) for
// every recorded session with the current computeStats. No point is added,
// deleted, or retimed — this touches just the 7 stat columns. Use it to refresh
// historical sessions after a formula change (the elevation-hysteresis and
// smoothed-gradient kcal fix left old sessions showing ~0 gain / inflated kcal).
GPS.recomputeAllStats = async function(onProgress) {
const invoke = window.__TAURI__.core.invoke;
const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] });
let ids = [];
try { ids = (await sel("SELECT CAST(id AS TEXT) AS sid FROM gps_sessions ORDER BY id")).map(r => r.sid); }
catch (e) { console.error("[recalc-all] list FAILED", e); return { error: String(e) }; }
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
let updated = 0, skipped = 0;
for (let i = 0; i < ids.length; i++) {
const sid = ids[i];
if (onProgress) { try { onProgress(i + 1, ids.length); } catch {} }
try {
const session = await DB.getGpsSession(sid);
if (!session) { skipped++; continue; }
// Manual routes are user-drawn estimates (no altitude, fake timestamps) —
// leave them untouched.
if (session.source === "manual") { skipped++; continue; }
const points = await DB.getGpsPoints(sid);
if (points.length < 2) { skipped++; continue; }
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
const bulkPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
const stats = GPS.computeStats(points, 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,
gearCount: bike?.gear_count,
pauses: bulkPauses,
});
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses);
updated++;
} catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; }
}
console.log(`[recalc-all] DONE updated=${updated} skipped=${skipped} total=${ids.length}`);
return { updated, skipped, total: ids.length };
};
// 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;
});
};
// Elevation profile of a saved route (points {lat,lon,ele?}). Smooths the raw
// elevations (filterAndSmoothAlt kills SRTM vertical noise) so the grade — the
// thing that matters for a singlespeed rider — isn't spiky. Returns per-point
// cumulative distance (km), smoothed elevation, grade-to-next (%), plus ascent/
// descent (1m hysteresis) and the steepest segment.
GPS.routeElevStats = function(points) {
const n = points.length;
const hasEle = points.some(p => p.ele != null);
const smoothed = GPS.filterAndSmoothAlt(points.map(p => (p.ele != null ? p.ele : null)));
const cumKm = new Array(n).fill(0);
for (let i = 1; i < n; i++) {
cumKm[i] = cumKm[i-1] + GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon) / 1000;
}
const grades = new Array(n).fill(0);
let maxGrade = 0;
for (let i = 0; i < n - 1; i++) {
const dm = GPS.distance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon);
const de = (smoothed[i] != null && smoothed[i+1] != null) ? (smoothed[i+1] - smoothed[i]) : 0;
const g = dm > 1 ? (de / dm) * 100 : 0;
grades[i] = g;
if (dm > 10 && Math.abs(g) > Math.abs(maxGrade)) maxGrade = g;
}
let ascent = 0, descent = 0, ref = null;
for (let i = 0; i < n; i++) {
const e = smoothed[i];
if (e == null) continue;
if (ref == null) { ref = e; continue; }
const rise = e - ref;
if (rise > 1) { ascent += rise; ref = e; }
else if (rise < -1) { descent += -rise; ref = e; }
}
return {
hasEle, smoothed, cumKm, grades,
ascent: Math.round(ascent), descent: Math.round(descent),
maxGrade: Math.round(maxGrade * 10) / 10,
distanceKm: cumKm[n-1] || 0,
};
};
window.GPS = GPS;
console.log("[gps.js] window.GPS ready");