baldav/frontend/js/gps.js
4aiman 9251c1dd90 ble: barometer on its own connection - simultaneous HR+baro, auto-connect, UI
Separate BleBaroService so the HR strap and the barometer each hold their own GATT link and
work at the same time (one adapter, several LE connections). Verified on device against a PC
BlueZ emulator of the sensor: BleHR and BleBaro deliver in parallel, no cross-talk.

- BleBaroService: dedicated baro connection (scan-before-connect + direct-connect fallback,
  ESS subscribe, writes ble_baro_data.json). MainActivity routes baro connects to it and
  auto-connects both HR (ble_device.json) and baro (baro_device.json) at start.
- Rust: ble_baro_disconnect, ble_save_baro_device.
- Settings: a 'Датчики (BLE)' section - heart-rate and barometer as separate rows with their
  own slots, bind/connect/unbind, and a live pressure/temperature/altitude readout.
- Tracking: altitude_baro_m stored per point, preferred in elevation stats (null without baro).
- Record screen: 'Высота' from the barometer (1 Hz, dot marker), temperature from the sensor.
- Fixes: direct-connect on scan timeout; Locale.US JSON (RU decimal comma broke the parse);
  parsing routed by characteristic (baro no longer written into the pulse); VR/HR stop returns
  to the tracks list instead of into details.
- tools/baro-sensor/emulate_baro.py: PC BlueZ peripheral that emulates the sensor for testing.

Emulator-verified only; real board still pending.
2026-10-30 22:46:26 +03:00

2558 lines
121 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%
hrCalibFactor: 0.65, // грубый персональный множитель для справочной "ЧСС откалибр." (Keytel x этого). Пульс завышает ~в 1.5x vs физика/вес; 0.65 мирит по 2 точкам (поездка 90км + сидячий день). НЕ истина, для справки.
tefFrac: 0.10, // термический эффект пищи (TEF): доля прихода, уходящая на переваривание. 0 = выкл. Вводится С СОХРАНЕНИЕМ поддержки: база = BMR*actLevel*(1-tefFrac), затем +tefFrac*приход, так что на поддержке итог не меняется, но TEF масштабируется от реальной еды. actLevel 1.2 уже включал TEF — поэтому не прибавляем сверху, а перераспределяем.
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
walkDetectKmh: 8, // в вело-треке устойчивая скорость ниже = ведёшь/идёшь (не едешь)
walkMinRunSec: 60, // минимум длины пешего куска, чтобы засчитать (не притормозил у столба)
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));
};
// HR-only session kinds (no GPS, no track — just heart rate + timer): "workout" (зарядка)
// and "vr" (руки/VR/танцы). Both estimate kcal from HR (Keytel). Kept as a set so new
// HR-only kinds get the same no-GPS recording/detail path without touching every check.
GPS.isHrOnly = function(t) { return t === "workout" || t === "vr"; };
// 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 {}
// Detect "walking" runs inside a ride (pushing the bike / on foot across a field): contiguous
// runs where the smoothed speed stays below a threshold for at least walkMinRunSec. The
// threshold is the single-speed push speed when gear data is given (30-rpm cadence), else
// config.walkDetectKmh — universal by speed, refined by gear. Points are assumed time-ordered
// (as read from DB). Returns a boolean array parallel to `points` (true = walking).
GPS.detectWalk = function(points, opts) {
opts = opts || {};
let n = points ? points.length : 0;
let out = new Array(n).fill(false);
if (n < 3) return out;
let thrMs;
let hasGear = opts.chainringTeeth && opts.cogTeeth && opts.wheelDiameterInch;
if (hasGear && (opts.gearCount == null || opts.gearCount <= 1)) {
let circ = Math.PI * opts.wheelDiameterInch * 0.0254, ratio = opts.chainringTeeth / opts.cogTeeth;
thrMs = (30 * circ * ratio) / 60;
} else {
thrMs = (GPS.config.walkDetectKmh ?? 8) / 3.6;
}
let minRun = opts.walkMinRunSec ?? (GPS.config.walkMinRunSec ?? 60);
let v = new Array(n).fill(0);
for (let i = 1; i < n; i++) {
let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
let sp = points[i].speed_ms;
v[i] = (sp != null && sp >= 0) ? sp : (dt > 0 ? d / dt : 0);
}
let sm = v.map((_, i) => { let s = 0, c = 0; for (let j = Math.max(0, i-2); j <= Math.min(n-1, i+2); j++) { s += v[j]; c++; } return c ? s / c : 0; });
let i = 1;
while (i < n) {
if (sm[i] > 0.15 && sm[i] < thrMs) { // > 0.15 m/s excludes standing still
let j = i, dur = 0;
while (j < n && sm[j] > 0.15 && sm[j] < thrMs) { dur += Math.max(0, (new Date(points[j].ts) - new Date(points[j-1].ts)) / 1000); j++; }
if (dur >= minRun) for (let k = i - 1; k < j; k++) out[k] = true;
i = j;
} else i++;
}
return out;
};
// Auto-detect repeated segments within ONE ride (interval training: the same climb
// ridden N times, one track, no stop/restart). The repeated corridor is found by
// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run
// where position along a reference segment rises monotonically), but the reference
// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride
// revisits), not given as road_points ids.
// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }.
// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST
// (uphill) position, so coasting back down makes position fall and ends the pass, and
// the "must start near seq beginning" guard stops a descent from opening a new pass.
GPS.detectRepeats = function(points, opts) {
opts = opts || {};
let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap)
let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long
let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate)
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment
let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long
let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor
let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor
let backM = opts.backM ?? 30; // progress may dip this far without ending the pass
let n = points ? points.length : 0;
if (n < 20) return { found: false };
// Grid cell key per point.
let key = new Array(n);
for (let i = 0; i < n; i++) {
let la = points[i].lat, lo = points[i].lon;
if (la == null || lo == null) { key[i] = null; continue; }
key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
}
// Entries per cell: count each time the track enters a cell (key changes into it).
let visits = new Map();
for (let i = 0; i < n; i++) {
if (key[i] == null) continue;
if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1);
}
let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v;
if (maxEntries < 2) return { found: false };
// Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor).
// Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot
// cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where
// progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a
// small backstep. Pass time = moving seconds only (stops trimmed).
let detectAt = (hotThr) => {
let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr;
let buildTraversal = (start) => {
let seq = [], order = new Map(), end = start;
for (let k = start, gap = 0; k < n; k++) {
let c = key[k];
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
gap = 0;
if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell
if (order.has(c)) break; // revisit of an earlier cell -> traversal complete
order.set(c, seq.length); seq.push(c); end = k;
}
let len = 0;
for (let k = start + 1; k <= end; k++)
len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
return { seq, len, start, end };
};
let best = null;
for (let k = 0; k < n; k++) {
if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start
let t = buildTraversal(k);
if (!best || t.len > best.len) best = t;
}
if (!best || best.seq.length < 3 || best.len < minLenM) return null;
let rLat = [], rLon = [], cum = [0];
for (let k = best.start; k <= best.end; k++) {
rLat.push(points[k].lat); rLon.push(points[k].lon);
if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon));
}
let refLen = cum[cum.length - 1];
if (rLat.length < 3 || refLen < minLenM) return null;
let refCell = new Map();
for (let v = 0; v < rLat.length; v++) {
let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell);
(refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v);
}
let rad = Math.ceil(projTolM / cell);
let prog = new Array(n).fill(null);
for (let i = 0; i < n; i++) {
if (points[i].lat == null) continue;
let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell);
let bv = -1, bd = projTolM;
for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) {
let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue;
for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } }
}
if (bv >= 0) prog[i] = cum[bv];
}
let passes = [];
let p = 0;
while (p < n) {
if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; }
let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0;
while (q < n - 1) {
q++;
if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; }
let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000;
let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon);
if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue;
break;
}
if ((hi - lo) >= refLen * minFrac) {
let mov = 0;
for (let k = p + 1; k <= anchor; k++) {
let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000;
let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded
}
passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) });
}
p = Math.max(q, anchor) + 1;
}
return { segLength: Math.round(refLen), passes };
};
// A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the
// reference, inflating it so a full rep no longer covers minFrac (measured on a real
// out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to
// the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment).
let bestRes = null;
for (let T = 2; T <= Math.min(maxEntries, 20); T++) {
let r = detectAt(T);
if (!r || r.passes.length < minReps) continue;
if (!bestRes || r.passes.length > bestRes.passes.length ||
(r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r;
}
if (!bestRes) return { found: false };
return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes };
};
// 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.
// Prefer the barometer (best for real climbs/descents) when present, then terrain (SRTM), then GPS.
const rawAlt = sorted.map(p => p.altitude_baro_m ?? 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;
// Mechanical work while pedalling, for avgPower output (used by the repeat/interval
// analysis to compare passes). Averaged over pedalling time, like tools/track pers.py.
let mechWorkJ = 0, powerSec = 0;
// 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;
mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power
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;
// Average mechanical power (W) over pedalling time, null if never pedalled.
const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : 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,
avgPower,
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); }
};
// Is the OS location toggle ON (not just the permission)? Kotlin checks LocationManager and
// writes location_state.json; we read it back. Returns true/false, or null if undeterminable
// (desktop / no answer) — callers must not prompt on null.
GPS.isLocationEnabled = async function() {
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_location" });
await new Promise(r => setTimeout(r, 300));
let raw = await window.__TAURI__.core.invoke("read_state_file", { name: "location_state.json" });
if (!raw) return null;
return !!JSON.parse(raw).enabled;
} catch { return null; }
};
// 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; }
// Do NOT auto-connect/scan here. That ran a BLE scan+connect on every ride even when the
// band was absent or Bluetooth was off, holding the radio and draining the battery for
// nothing. Connecting is MANUAL now (Settings -> "Подключить", works mid-ride too). We only
// run the cheap HR poll (reads a local file), so a manual connect now or later starts
// feeding HR into the track via currentHr.
console.log("[HR] Starting HR poll (connect is manual)");
GPS.recording.hrPollInterval = setInterval(async () => {
try {
const reading = await window.__TAURI__.core.invoke("ble_get_hr");
if (reading && reading.battery != null) { GPS.recording.currentBattery = reading.battery; GPS._checkBatteryAlert(reading.battery); }
if (reading && reading.hr > 0) {
GPS.recording.currentHr = reading.hr;
GPS.recording.currentBleData = reading;
// HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading.
if (GPS.isHrOnly(GPS.recording.activityType) && GPS.recording.startTime) {
GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]);
}
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 {}
};
// Rebuild the workout's HR series from the NATIVE log (ble_hr_log.jsonl), which the
// foreground service appends to even with the screen off — unlike the JS poll, which
// Android freezes in the background (so a sleep-long workout kept only the on-screen bits).
// Returns [[relSec, bpm], …] relative to the recording start, sorted and de-duplicated,
// or [] if the log is empty/unavailable.
GPS._readHrLogSamples = async function() {
if (!GPS.recording.startTime) return [];
let raw = "";
try { raw = await window.__TAURI__.core.invoke("ble_read_hr_log"); } catch { return []; }
if (!raw) return [];
const startSec = GPS.recording.startTime.getTime() / 1000;
const out = [];
let lastRel = -1;
for (const line of raw.split("\n")) {
const s = line.trim();
if (!s) continue;
let o; try { o = JSON.parse(s); } catch { continue; } // skip a torn last line
if (!o || !(o.hr > 0) || o.ts == null) continue;
const rel = Math.round(o.ts - startSec);
if (rel < 0 || rel === lastRel) continue; // before start, or same-second dup
out.push([rel, o.hr]);
lastRel = rel;
}
out.sort((a, b) => a[0] - b[0]);
return out;
};
// Turn HR on/off during a recording (the toggle on the record screen). ON connects to the
// paired strap (Kotlin scans ~5s and stops cleanly if it's absent — so no constant scanning /
// drain when the strap isn't there) and polls; OFF stops the poll and disconnects.
//
// `interactive` = the user tapped something (the record-screen toggle, "Подключить"). Only then
// may we pop the OS "turn on Bluetooth?" dialog when BT is off. Auto-calls at recording start
// pass interactive=false: if BT is off they just leave HR off (no second popup after the single
// start-time prompt in ensureSystemReady), and a later manual connect still works.
GPS.setHrOn = async function(on, interactive = false) {
GPS.recording.hrOn = !!on;
if (!on) {
GPS._stopHrMonitor();
try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {}
return false;
}
const addr = await DB.getProfileValue("ble_device_addr");
const type = await DB.getProfileValue("ble_device_type");
if (!addr) { GPS.recording.hrOn = false; return false; }
try {
const bt = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (bt === "off") {
// Auto path never nags; only a user tap asks the OS to enable BT. Either way we can't
// connect with the radio off — leave HR off so the toggle shows grey, not a fake "on".
if (interactive) { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} }
GPS.recording.hrOn = false;
return false;
}
if (bt !== "no_adapter") { try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type || "hr_standard" }); } catch {} }
} catch {}
try { await GPS._startHrMonitor(); } catch {}
return true;
};
// Low-battery warning for the HR strap: announce (voice) and beep ONCE at 10% and 5%.
// Resets when the strap reads healthy again (recharged / battery replaced).
GPS._batAlerted = {};
GPS._checkBatteryAlert = function(bat) {
if (bat == null || bat < 0 || bat > 100) return;
if (bat > 15) { GPS._batAlerted = {}; return; }
for (let thr of [10, 5]) {
if (bat <= thr && !GPS._batAlerted[thr]) {
GPS._batAlerted[thr] = true;
GPS._speakOrBeep(`Заряд пульсометра ${thr} процентов`);
}
}
};
GPS._speakOrBeep = function(text) {
let spoke = false;
try {
if (window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined") {
let u = new SpeechSynthesisUtterance(text);
u.lang = "ru-RU"; u.rate = 0.95;
window.speechSynthesis.speak(u);
spoke = true;
}
} catch {}
// Always beep too (TTS may be absent on a de-Googled ROM; the beep is the guaranteed cue).
try { GPS._beep(spoke ? 1 : 3); } catch {}
};
GPS._beep = function(times) {
times = times || 1;
let ctx = new (window.AudioContext || window.webkitAudioContext)();
let one = (i) => {
let o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = 880; o.type = "sine";
o.connect(g); g.connect(ctx.destination);
g.gain.value = 0.25;
let t0 = ctx.currentTime + i * 0.35;
o.start(t0); o.stop(t0 + 0.2);
};
for (let i = 0; i < times; i++) one(i);
setTimeout(() => { try { ctx.close(); } catch {} }, times * 350 + 400);
};
// 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;
// Attach barometric altitude to this point when the BLE baro sensor is live and anchored.
// Null without a barometer, so elevation stats fall back to GPS/SRTM exactly as before.
try {
if (window.BARO && BARO.isLive()) {
let ba = BARO.altitude();
if (ba != null && isFinite(ba)) p.altitude_baro_m = ba;
}
} catch (e) {}
// 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);
};
// Headwind labelling (variant 5 as VALIDATION, not measurement): the rider taps
// to open a "wind in the face" interval and taps again to close it. Marks are
// stored per session ([{startTs, endTs}] ms epoch) and later compared against the
// from-track wind estimate — they are ground truth, they do NOT feed kcal/power.
// Returns true if a headwind interval is now open (button should show active).
GPS.toggleWindMark = function() {
if (!GPS.recording.windMarks) GPS.recording.windMarks = [];
if (GPS.recording._currentWind) {
GPS.recording._currentWind.endTs = Date.now();
GPS.recording._currentWind = null;
} else {
GPS.recording._currentWind = { startTs: Date.now(), endTs: null };
GPS.recording.windMarks.push(GPS.recording._currentWind);
}
GPS._persistState();
return GPS.recording._currentWind != null;
};
// Close an interval left open (rider forgot to tap off before Stop / recovery).
GPS._closeOpenWindMark = function() {
if (GPS.recording._currentWind && GPS.recording._currentWind.endTs == null) {
GPS.recording._currentWind.endTs = Date.now();
}
GPS.recording._currentWind = null;
};
// 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 || [],
windMarks: GPS.recording.windMarks || [],
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 || []);
// Restore headwind marks captured before the crash (close a dangling open one)
if (Array.isArray(s.windMarks) && s.windMarks.length) {
for (const w of s.windMarks) if (w && w.endTs == null) w.endTs = new Date(pts[pts.length - 1].ts).getTime();
try { await DB.setSessionWindMarks(s.sessionId, s.windMarks); } catch (e) { console.error("[GPS] wind marks restore failed:", e); }
}
// 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();
}
};
// Net kcal from a heart-rate series (Keytel 2005 gross, minus BMR for the same time so it is
// consistent with the app's NET model — BMR is already booked in the daily balance).
// samples: [[relSec, bpm], ...]. profile: {weightKg, age, sex ("m"/"f"), heightCm}.
GPS.hrKcalNet = function(samples, profile) {
if (!samples || samples.length < 2) return 0;
profile = profile || {};
const W = parseFloat(profile.weightKg) || 75, A = parseInt(profile.age) || 38;
const H = parseFloat(profile.heightCm) || 178, male = (profile.sex || "m") === "m";
const bmrMin = (10 * W + 6.25 * H - 5 * A + (male ? -5 : 161)) / 1440; // Mifflin -> kcal/min
const grossPerMin = hr => male
? (-55.0969 + 0.6309 * hr + 0.1988 * W + 0.2017 * A) / 4.184
: (-20.4022 + 0.4472 * hr - 0.1263 * W + 0.0740 * A) / 4.184;
let kcal = 0;
for (let i = 1; i < samples.length; i++) {
const dtMin = Math.max(0, samples[i][0] - samples[i-1][0]) / 60;
if (dtMin <= 0 || dtMin > 1) continue; // skip > 1 min gaps (disconnect)
const net = grossPerMin((samples[i-1][1] + samples[i][1]) / 2) - bmrMin;
if (net > 0) kcal += net * dtMin;
}
return Math.round(kcal);
};
GPS.startRecording = async function(activityType, weightKg, profile) {
if (GPS.recording.active) return;
GPS.recording.active = true;
GPS.recording.activityType = activityType || "bike";
GPS.recording.points = [];
GPS.recording.hrSamples = [];
GPS.recording.hrProfile = profile || null;
// Clear any HR left over from the PREVIOUS session (else a stale bpm hangs on the display and
// gets written onto this session's points). Only a fresh reading this session re-fills it.
GPS.recording.currentHr = null;
GPS.recording.currentBleData = null;
GPS.recording.currentBattery = null;
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
GPS.recording.startTime = null;
GPS.recording.weightKg = parseFloat(weightKg) || 80;
// HR-only "workout"/"vr": no GPS service, no location, no track points — just the
// heart-rate poll + a timer. kcal comes from HR (Keytel) at finish.
if (GPS.isHrOnly(GPS.recording.activityType)) {
GPS.recording.state = "recording";
GPS.recording.startTime = new Date();
GPS.recording.nativeService = false;
GPS.recording.sessionId = null;
try {
GPS.recording.sessionId = await DB.createGpsSession(
new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), GPS.recording.activityType);
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
// Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean.
try {
const hrAddr = await DB.getProfileValue("ble_device_addr");
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
} catch (e) { console.error("[HR] start error:", e); }
return;
}
if (!navigator.geolocation) throw new Error("Geolocation not available");
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.windMarks = []; // headwind labels: [{startTs, endTs}] ms epoch
GPS.recording._currentWind = null; // open headwind interval, if any
GPS.recording.nativeService = false;
GPS.recording._pollTimer = null;
GPS.recording._lastProcessedTs = 0;
GPS.recording._pointSeq = 0;
// Start native foreground service (Android)
await GPS._startNativeService();
// HR: default ON if a strap is paired — auto-connect + poll at start (the record screen has an
// on/off toggle). Safe: BleHrService scans briefly and stops if the strap isn't present, so no
// constant scanning/drain when it's absent. No paired strap -> HR stays off.
try {
const hrAddr = await DB.getProfileValue("ble_device_addr");
if (hrAddr) await GPS.setHrOn(true); else GPS.recording.hrOn = false;
} 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);
// HR-only workout/vr: no GPS to stop. Compute net kcal from HR, save samples, finalise.
if (GPS.isHrOnly(GPS.recording.activityType)) {
// Prefer the native HR log (complete through screen-off) over the JS poll's in-memory
// array (frozen in the background). Fall back to the poll array if the log is empty.
let samples = GPS.recording.hrSamples || [];
try {
const native = await GPS._readHrLogSamples();
if (native.length > samples.length) samples = native;
} catch (e) { console.error("[workout] native hr log read", e); }
const kcal = GPS.hrKcalNet(samples, GPS.recording.hrProfile);
const durationSec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0;
const hrs = samples.map(s => s[1]);
const avgHr = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
const maxHr = hrs.length ? Math.max(...hrs) : null;
const sid = GPS.recording.sessionId;
GPS._stopHrMonitor();
if (sid) {
try { await DB.setSessionHrSamples(sid, JSON.stringify(samples)); } catch (e) { console.error("[workout] save samples", e); }
// Reuse finishGpsSession: distance 0, kcal from HR; avg/max HR ride the speed columns' siblings? No —
// store them nowhere extra; detail derives avg/max from hr_samples. Just distance 0 + kcal.
try { await DB.finishGpsSession(sid, new Date().toTimeString().slice(0, 5), 0, kcal, null, null, 0, 0); } catch (e) { console.error("[workout] finish", e); }
}
return { sessionId: sid, workout: true, kcal, durationSec, avgHr, maxHr, samples };
}
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);
// Close an open headwind interval and persist all marks with the session.
GPS._closeOpenWindMark();
const recWindMarks = GPS.recording.windMarks || [];
try {
await DB.finishGpsSession(
GPS.recording.sessionId, endTime,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss, recPauses
);
try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); }
// Persist the rider weight used for this ride, so a later recompute keeps ride-time weight.
try { await DB.setSessionRiderWeight(GPS.recording.sessionId, GPS.recording.weightKg); } catch (e) { console.error("[GPS] rider weight save failed:", e); }
// 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.recording.windMarks = [];
GPS.recording._currentWind = 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 };
};
// Parse a BRouter GeoJSON response (format=geojson / OsmTrack.formatAsGeoJson). Returns
// { name, points:[{lat,lon,ele}], dangers:[{lat,lon,type,tag}] }. Danger comes from the
// per-segment messages table (WayTags/NodeTags): fords (water), rough/abandoned tracks,
// and (informational) ferries — so the rider sees the "ад" BEFORE riding it. Geometry is
// identical to the GPX, so this can replace parseRouteGPX where tags are wanted.
GPS.parseRouteGeoJson = function(jsonString) {
let d;
try { d = (typeof jsonString === "string") ? JSON.parse(jsonString) : jsonString; } catch { return null; }
let feat = d && d.features && d.features[0];
if (!feat) return null;
let coords = (feat.geometry && feat.geometry.coordinates) || [];
let points = coords.map(c => {
let p = { lon: c[0], lat: c[1] };
if (c.length > 2 && c[2] != null && isFinite(c[2])) p.ele = c[2];
return p;
});
let name = (feat.properties && feat.properties.name) || "";
let dangers = [];
let msgs = feat.properties && feat.properties.messages;
if (Array.isArray(msgs) && msgs.length > 1) {
let hdr = msgs[0];
let li = hdr.indexOf("Longitude"), ai = hdr.indexOf("Latitude");
let wi = hdr.indexOf("WayTags"), ni = hdr.indexOf("NodeTags");
for (let r = 1; r < msgs.length; r++) {
let row = msgs[r];
let wt = (wi >= 0 ? row[wi] : "") || "";
let nt = (ni >= 0 ? row[ni] : "") || "";
let both = wt + " " + nt;
let type = null;
if (/\bford=(yes|stepping_stones)\b/.test(both)) type = "ford";
// likely (untagged) water crossing: a track/path/footway with a high BRouter
// river-class estimate and no bridge/tunnel. Catches fords OSM never tagged ford=yes
// (verified: a real ford showed estimated_river_class=5 on a track, no ford tag).
else if (/\bhighway=(track|path|footway)\b/.test(wt) && /\bestimated_river_class=(5|6|7|8|9|10)\b/.test(wt)
&& !/\bbridge=/.test(wt) && !/\btunnel=/.test(wt)) type = "wetcross";
else if (/\btracktype=grade[45]\b/.test(wt) || /\bsurface=(mud|sand)\b/.test(wt)
|| /\btrail_visibility=(bad|horrible|no)\b/.test(wt)
|| /\bsac_scale=(demanding_mountain_hiking|alpine_hiking|demanding_alpine_hiking|difficult_alpine_hiking)\b/.test(wt)
|| /\bmtb:scale=[3-6]\b/.test(wt)) type = "rough";
else if (/\broute=ferry\b/.test(wt)) type = "ferry";
if (type && li >= 0 && ai >= 0) {
let lon = parseFloat(row[li]) / 1e6, lat = parseFloat(row[ai]) / 1e6;
if (isFinite(lat) && isFinite(lon)) dangers.push({ lat, lon, type, tag: (wt || nt).trim() });
}
}
}
return { name, points, dangers };
};
// Fill GPS dropouts along roads: for each REAL signal loss (a long time gap with a large
// chord), route the SHORTEST road path between the pre/post-gap points and splice the routed
// points in (flagged estimated). Deliberately conservative so normal slow/dense riding is
// NOT touched: only long gaps (dt > gapThresholdSec, default 60s) with a big chord
// (> minChordM, default 150m) qualify; a fill is kept only if the road neither detours too
// far (rlen <= maxRatio×chord) nor barely differs from the straight line (adds >= minGainM),
// since a road ≈ chord means the straight was already right ("+0.00 km" fills). Async
// (BRouter). Returns { points, gaps }:
// points — a NEW full track array with routed points spliced into filled gaps (each
// flagged estimated:true, with an interpolated ts across the gap so DB ordering by ts
// is correct and they don't collide on a.ts).
// gaps — one entry per DETECTED gap: { a, b, routed:[{lat,lon}], status, addedM, chordM }
// status: "filled" | "skipped-long" | "skipped-nogain" | "skipped-noroute".
// Lets the UI draw ONLY the gap patches (not the whole track) and report what failed.
GPS.fillGaps = async function(points, opts) {
opts = opts || {};
let gapThr = opts.gapThresholdSec ?? 60; // only bridge a real signal loss, not a 30s hiccup
let minChord = opts.minChordM ?? 150; // and only when the endpoints are genuinely far apart
let ratio = opts.maxRatio ?? 2; // road > 2× chord = big detour, likely wrong → keep straight
let minGain = opts.minGainM ?? 40; // road ≈ chord = nothing to gain → keep straight
// Router for one gap: the caller can prefer already-ridden roads (returns {points, ridden:true}
// when the bridge runs over the user's own tracks); default is the public BRouter shortest.
// A ridden path is what actually happened, so it is NOT subject to the "detour too long" guard.
let routeFn = opts.routeFn || (async (a, b) => {
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false };
});
let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let out = [];
let gaps = [];
for (let i = 0; i < sorted.length; i++) {
out.push(sorted[i]);
let a = sorted[i], b = sorted[i + 1];
if (!b) continue;
let dt = (new Date(b.ts) - new Date(a.ts)) / 1000;
let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon);
if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging
let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime();
let routed = [], status = "skipped-noroute", addedM = 0, ridden = false;
try {
let r = await routeFn(a, b);
let rp = (r && r.points) || [];
ridden = !!(r && r.ridden);
if (rp.length >= 3) {
let rlen = 0;
for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
addedM = Math.max(0, rlen - chord);
if (!ridden && rlen > chord * ratio) {
status = "skipped-long"; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is)
} else if (addedM < minGain) {
status = "skipped-nogain"; // road ≈ straight — nothing to bridge
} else {
status = "filled";
for (let k = 1; k < rp.length - 1; k++) {
let frac = k / (rp.length - 1);
let ts = new Date(ta + (tb - ta) * frac).toISOString();
out.push({ lat: rp[k].lat, lon: rp[k].lon, ts, estimated: true });
routed.push({ lat: rp[k].lat, lon: rp[k].lon });
}
}
}
} catch (e) { status = "skipped-noroute"; }
gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord, ridden });
}
return { points: out, gaps };
};
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) + " км" : Math.round(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 || {};
// Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer.
const onProg = (typeof opts.onProgress === "function") ? opts.onProgress : () => {};
onProg(5);
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));
// Persist the per-point "walking" flag (pushed bike / on foot inside a ride) for the roads
// overlay and the ridden-graph router. Not for walk/run sessions (all on foot already).
if (session.activity_type !== "walk" && session.activity_type !== "run") {
try {
const wf = GPS.detectWalk(sorted);
const ids = sorted.filter((p, i) => wf[i] && p.id != null).map(p => p.id);
await DB.setWalkFlags(sessionId, ids);
} catch (e) { console.warn("[walk] persist failed", e); }
}
// 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(session.rider_weight_kg) || 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);
onProg(100);
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}`);
}
onProg(40);
// 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]);
}
}
}
// Rider weight: the ride's stored weight (ride time), else latest weigh-in, else profile, else 80.
const latestW = await DB.getLatestWeight();
const profile = await DB.getFullProfile();
const weightKg = parseFloat(session.rider_weight_kg) || 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 []; } })() : []);
onProg(70);
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
);
onProg(100);
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 sessW = parseFloat(session.rider_weight_kg) || weightKg; // ride-time weight, else the fallback
const stats = GPS.computeStats(points, sessW, {
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);
// Persist the per-point "walking" flag too (roads overlay + ridden-graph router).
if (session.activity_type !== "walk" && session.activity_type !== "run") {
try {
const sw = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
const wf = GPS.detectWalk(sw);
await DB.setWalkFlags(sid, sw.filter((p, k) => wf[k] && p.id != null).map(p => p.id));
} catch (e) { console.warn("[walk] persist failed", sid, e); }
}
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;
}
// Grade over a DISTANCE window (~one DEM cell), not between adjacent points. DEM
// elevations are quantised to 30m cells: dense points inside a cell share one value
// and the whole rise lands on the single boundary segment → a false steep spike.
// A windowed central difference spreads that step across the cell so the colour
// reflects the real grade. Window W metres (config gradeWindowM, default 30).
const W = GPS.config.gradeWindowM ?? 30;
const cumM = cumKm.map(k => k * 1000);
const grades = new Array(n).fill(0);
let maxGrade = 0;
for (let i = 0; i < n - 1; i++) {
const center = (cumM[i] + cumM[i+1]) / 2;
let a = i, b = i + 1;
while (a > 0 && center - cumM[a] < W / 2) a--;
while (b < n - 1 && cumM[b] - center < W / 2) b++;
// pull window ends in off any null-elevation points
while (a < i && smoothed[a] == null) a++;
while (b > i + 1 && smoothed[b] == null) b--;
const span = cumM[b] - cumM[a];
let g = 0;
if (span > 1 && smoothed[a] != null && smoothed[b] != null) {
g = ((smoothed[b] - smoothed[a]) / span) * 100;
}
grades[i] = g;
if (span > 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");