// 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_.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 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_.jsonl or gps_points__s.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_.jsonl -> ..._s.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 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 (Yandex Map Constructor), or — 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 , 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 (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,"&").replace(//g,">"); const name = esc(session.activity_type || "Track") + " " + esc(session.date); const cfg = GPS.config; let gpx = ` ${name} minSpeed=${(cfg.minSpeedMs*3.6).toFixed(1)}kmh maxAcc=${cfg.minAccuracyM}m minDist=${cfg.minDistanceM}m points=${points.length} ${name} ${esc(session.activity_type || "other")} `; for (const p of points) { gpx += ` `; if (p.altitude_gps_m != null) gpx += ` ${p.altitude_gps_m}\n`; if (p.ts) gpx += ` \n`; if (p.speed_ms != null) gpx += ` ${p.speed_ms}\n`; gpx += ` \n`; } 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");