- native_root(): pick app root by presence of gps_live/gps_points files. app_data_dir() is the root directly on this Tauri; the old .parent().parent() read the wrong dir, so background (screen-off) points were never imported. - recoverFromFiles: match file->session by id/date, plus session id stamped into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding (gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s, recalc with noDelete (never runs the destructive glitch/>24h filter that once wiped real rides), delete already-imported files. Progress bar UI. - bike kcal is NET (daily balance already books BMR*activityLevel): pedaling = max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike would roll unpowered) = basal only. Elevation via cumulative hysteresis. recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks without touching points. - live recording: the native file is the DB source of truth; watchPosition only drives the live marker, so a fix is not stored twice with two timestamps. - track list pagination, SAF local-file picker, tile regions from router.
1720 lines
75 KiB
JavaScript
1720 lines
75 KiB
JavaScript
// GPS tracking module — recording, calculations, GPX import
|
||
// Exposes window.GPS
|
||
|
||
const GPS = {};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Configurable thresholds (can be changed in settings)
|
||
GPS.config = {
|
||
minSpeedMs: 0.42, // 1.5 km/h — below this = stationary
|
||
minAccuracyM: 50, // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept.
|
||
minDistanceM: 1, // skip points closer than this
|
||
maxSpeedKmh: 60, // above this = GPS glitch, skip segment
|
||
gapThresholdSec: 30, // gap longer than this = new track segment
|
||
airDrag: 0.6, // аэродинамическое сопротивление (CdA, м2). Город=0.6, спорт=0.4
|
||
airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0
|
||
pedalEfficiency: 25, // КПД педалирования (%). Человек ~25%
|
||
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
|
||
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
|
||
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
|
||
bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов)
|
||
walkAvgSpeedKmh: 5, // средняя скорость ходьбы
|
||
runAvgSpeedKmh: 10, // средняя скорость бега
|
||
useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps.
|
||
// raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть.
|
||
autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец»)
|
||
};
|
||
|
||
// Load saved config
|
||
try {
|
||
let saved = localStorage.getItem("baldav_gps_config");
|
||
if (saved) Object.assign(GPS.config, JSON.parse(saved));
|
||
} catch {}
|
||
|
||
GPS.saveConfig = function() {
|
||
localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config));
|
||
};
|
||
|
||
// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default
|
||
// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle.
|
||
try {
|
||
if (localStorage.getItem("baldav_fused_migrated") !== "1") {
|
||
GPS.config.useFusedLocation = true;
|
||
localStorage.setItem("baldav_fused_migrated", "1");
|
||
GPS.saveConfig();
|
||
}
|
||
} catch {}
|
||
|
||
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
|
||
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
|
||
// Only touch the value if it's still the old default; respect a user-set threshold.
|
||
try {
|
||
if (localStorage.getItem("baldav_minacc50_migrated") !== "1") {
|
||
if (GPS.config.minAccuracyM <= 20) GPS.config.minAccuracyM = 50;
|
||
localStorage.setItem("baldav_minacc50_migrated", "1");
|
||
GPS.saveConfig();
|
||
}
|
||
} catch {}
|
||
|
||
// Haversine distance (meters)
|
||
// ---------------------------------------------------------------------------
|
||
// Compass bearing in degrees (0=N, clockwise) from point 1 to point 2.
|
||
GPS.bearing = function(lat1, lon1, lat2, lon2) {
|
||
let toRad = d => d * Math.PI / 180;
|
||
let f1 = toRad(lat1), f2 = toRad(lat2), dl = toRad(lon2 - lon1);
|
||
let y = Math.sin(dl) * Math.cos(f2);
|
||
let x = Math.cos(f1) * Math.sin(f2) - Math.sin(f1) * Math.cos(f2) * Math.cos(dl);
|
||
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
||
};
|
||
|
||
// Point [lat, lon] at `meters` distance along `bearingDeg` (0=N, CW) from a start point.
|
||
GPS.destination = function(lat, lon, bearingDeg, meters) {
|
||
let R = 6371000, d = meters / R, br = bearingDeg * Math.PI / 180;
|
||
let f1 = lat * Math.PI / 180, l1 = lon * Math.PI / 180;
|
||
let f2 = Math.asin(Math.sin(f1) * Math.cos(d) + Math.cos(f1) * Math.sin(d) * Math.cos(br));
|
||
let l2 = l1 + Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(f1), Math.cos(d) - Math.sin(f1) * Math.sin(f2));
|
||
return [f2 * 180 / Math.PI, ((l2 * 180 / Math.PI) + 540) % 360 - 180];
|
||
};
|
||
|
||
GPS.distance = function(lat1, lon1, lat2, lon2) {
|
||
const R = 6371000;
|
||
const toRad = d => d * Math.PI / 180;
|
||
const dLat = toRad(lat2 - lat1);
|
||
const dLon = toRad(lon2 - lon1);
|
||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Compute session stats from array of {lat, lon, altitude, ts} points
|
||
// weightKg = rider weight, opts = { bikeWeightKg, activityType, surfaceCrr,
|
||
// chainringTeeth, cogTeeth, wheelDiameterInch } (all optional)
|
||
// ---------------------------------------------------------------------------
|
||
GPS.computeStats = function(points, weightKg, opts) {
|
||
if (points.length < 2) return { distance: 0, elevGain: 0, elevLoss: 0, avgSpeed: 0, maxSpeed: 0, kcal: 0, duration: 0, avgCadence: null };
|
||
|
||
// Always sort by timestamp — seq can be wrong if points arrived out of order during recording
|
||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||
|
||
let distance = 0, elevGain = 0, elevLoss = 0;
|
||
let elevRef = null; // running reference for cumulative elevation hysteresis
|
||
const speeds = []; // m/s per segment
|
||
|
||
// 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise)
|
||
// Step 1: Extract raw altitudes and filter outliers.
|
||
// An outlier is a point that jumps far from its neighbors and returns back —
|
||
// e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch.
|
||
// Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy
|
||
// and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope.
|
||
const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null);
|
||
const filteredAlt = rawAlt.slice();
|
||
for (let i = 1; i < filteredAlt.length - 1; i++) {
|
||
if (filteredAlt[i] == null) continue;
|
||
const prev = filteredAlt[i - 1], next = filteredAlt[i + 1];
|
||
if (prev == null || next == null) continue;
|
||
const jumpFromPrev = Math.abs(filteredAlt[i] - prev);
|
||
const jumpFromNext = Math.abs(filteredAlt[i] - next);
|
||
const neighborDiff = Math.abs(next - prev);
|
||
// If this point jumps >4m from BOTH neighbors, but neighbors are close to each other — outlier
|
||
if (jumpFromPrev > 4 && jumpFromNext > 4 && neighborDiff < 3) {
|
||
filteredAlt[i] = (prev + next) / 2;
|
||
}
|
||
}
|
||
|
||
// Step 1b: Fix "stuck" altitude — same value repeated 5+ times = GPS frozen,
|
||
// replace with linear interpolation between surrounding real values
|
||
{
|
||
let i = 0;
|
||
while (i < filteredAlt.length) {
|
||
if (filteredAlt[i] == null) { i++; continue; }
|
||
let j = i + 1;
|
||
while (j < filteredAlt.length && filteredAlt[j] != null
|
||
&& Math.abs(filteredAlt[j] - filteredAlt[i]) < 0.2) j++;
|
||
if (j - i >= 5) {
|
||
// Stuck run from i to j-1. Interpolate using values before i and after j-1
|
||
const before = i > 0 ? filteredAlt[i - 1] : filteredAlt[i];
|
||
const after = j < filteredAlt.length ? filteredAlt[j] : filteredAlt[j - 1];
|
||
for (let k = i; k < j; k++) {
|
||
const t = (k - i + 1) / (j - i + 1);
|
||
filteredAlt[k] = before + (after - before) * t;
|
||
}
|
||
}
|
||
i = j;
|
||
}
|
||
}
|
||
|
||
// Step 2: Smooth filtered altitude
|
||
const SMOOTH_HALF = GPS.config.altSmoothHalf ?? 2;
|
||
const smoothedAlt = filteredAlt.map((alt, i) => {
|
||
if (alt == null) return null;
|
||
let sum = 0, cnt = 0;
|
||
for (let j = Math.max(0, i - SMOOTH_HALF); j <= Math.min(filteredAlt.length - 1, i + SMOOTH_HALF); j++) {
|
||
if (filteredAlt[j] != null) { sum += filteredAlt[j]; cnt++; }
|
||
}
|
||
return cnt > 0 ? sum / cnt : alt;
|
||
});
|
||
|
||
// Physics constants (configurable in settings)
|
||
const g = 9.81;
|
||
const CdA = GPS.config.airDrag ?? 0.6;
|
||
const rho = GPS.config.airDensity ?? 1.225;
|
||
const efficiency = (GPS.config.pedalEfficiency ?? 25) / 100;
|
||
// Rolling resistance: base from surface type, corrected by tire pressure.
|
||
// If accelerometer data available, estimate Crr from vibration (accel RMS):
|
||
// RMS < 3 → smooth asphalt (0.004), 3-7 → mixed (0.006), > 7 → gravel (0.008)
|
||
let baseCrr = opts?.surfaceCrr ?? 0.006;
|
||
const accelVals = sorted.map(p => p.accel_rms).filter(v => v != null && v > 0);
|
||
if (accelVals.length > 10) {
|
||
accelVals.sort((a, b) => a - b);
|
||
const medianAccel = accelVals[Math.floor(accelVals.length / 2)];
|
||
const accelCrr = medianAccel < 3 ? 0.004 : medianAccel < 7 ? 0.006 : 0.008;
|
||
baseCrr = accelCrr; // override with sensor-based estimate
|
||
}
|
||
const tirePressure = opts?.tirePressureBar ?? 3.0;
|
||
const Crr = baseCrr * Math.sqrt(3.0 / tirePressure);
|
||
const wKg = parseFloat(weightKg) || 80; // ensure numeric (profile stores strings)
|
||
const mTotal = wKg + (parseFloat(opts?.bikeWeightKg) || 0);
|
||
const isBike = (opts?.activityType ?? "bike") === "bike";
|
||
|
||
let totalKcal = 0;
|
||
const basalPerMin = (1.0 * 3.5 * wKg) / 200;
|
||
|
||
// Cadence tracking
|
||
let totalPedalRevs = 0;
|
||
let pedalingSeconds = 0;
|
||
const hasGearData = opts?.chainringTeeth && opts?.cogTeeth && opts?.wheelDiameterInch;
|
||
const wheelCirc = hasGearData ? Math.PI * opts.wheelDiameterInch * 0.0254 : 0; // meters
|
||
const gearRatio = hasGearData ? opts.chainringTeeth / opts.cogTeeth : 0;
|
||
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
let p = sorted[i], prev = sorted[i - 1];
|
||
let d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
|
||
let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
|
||
let v = dt > 0 ? d / dt : 0; // m/s
|
||
|
||
let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
|
||
let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
|
||
// Count a REAL segment: not a dead-reckoning guess, not a teleport glitch. We do NOT
|
||
// drop long-gap segments here — if GPS was lost briefly, the straight line is a fair
|
||
// lower-bound of real travel (implied speed v<=segMaxMs already rejects teleports).
|
||
// DR fakes are excluded via !isEstimated, so no gap-time gate is needed.
|
||
let validSeg = !isEstimated && dt > 0 && v <= segMaxMs;
|
||
|
||
if (validSeg) {
|
||
distance += d;
|
||
speeds.push(v);
|
||
// Elevation: 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) {
|
||
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 (isEstimated) {
|
||
// Estimated point (GPS dead, accel confirmed movement): use walking MET.
|
||
// Time-based only — no distance (dead-reckoning drift is not real travel).
|
||
let walkMet = 3.5; // moderate walking ~4-5 km/h
|
||
totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200;
|
||
continue;
|
||
}
|
||
if (!validSeg) continue;
|
||
|
||
if (v < 0.3) {
|
||
// Standing still: only basal metabolic rate
|
||
totalKcal += basalPerMin * (dt / 60);
|
||
continue;
|
||
}
|
||
|
||
if (isBike) {
|
||
// Gradient from 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)
|
||
? (ptAccel < 3 ? 0.004 : ptAccel < 7 ? 0.006 : 0.008) * Math.sqrt(3.0 / tirePressure)
|
||
: Crr;
|
||
|
||
// Power required (watts): gravity + rolling resistance + aerodynamic drag
|
||
const pGravity = mTotal * g * sinG;
|
||
const pRolling = ptCrr * mTotal * g * cosG;
|
||
const pAero = 0.5 * CdA * rho * v * v;
|
||
const power = v * (pGravity + pRolling + pAero);
|
||
|
||
// Pedaling vs coasting. If the bike is freewheeling (you're NOT turning the
|
||
// pedals) it costs only basal — no matter the speed. How we know:
|
||
// - Physics proxy (what we use): power<=0 means the bike would roll at >=
|
||
// this speed unpowered — a downhill coast → basal. Otherwise → pedaling.
|
||
// (This proxy misses FLAT coasting, but that's rare and minor.)
|
||
// - cadence_rpm hook: reserved for a real crank cadence sensor (BLE) if one
|
||
// is ever added — that's the only reliable source. Deriving cadence from
|
||
// the phone IMU was tested on real 50Hz data and does NOT separate
|
||
// pedaling from coasting (~76-81% ≈ baseline), so it is not used.
|
||
const cad = sorted[i].cadence_rpm;
|
||
const pedaling = (cad != null) ? (cad >= 20) : (power > 0);
|
||
|
||
if (pedaling) {
|
||
// Both estimates NET (above resting): the daily balance already books BMR
|
||
// for the whole day (expend = BMR*activityLevel + activity, main.js), so
|
||
// activity kcal must be NET — never add basal here.
|
||
// 1. physics: mechanical work / drivetrain efficiency. At 15-18 km/h the
|
||
// mechanical power is only ~50-80 W, so on the flat this UNDERESTIMATES
|
||
// real metabolic cost ~2x — a lower bound; wins only on steep climbs.
|
||
// 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest
|
||
// BMR already covers — the empirically measured cost.
|
||
const physicsKcal = Math.max(0, power) * dt / efficiency / 4184;
|
||
const vKmh = v * 3.6;
|
||
if (vKmh >= 5) {
|
||
const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0;
|
||
const metNetKcal = (dt / 60) * ((met - 1) * 3.5 * wKg) / 200;
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 9-point smoothed speeds for maxSpeed (kills spikes from GPS noise)
|
||
let maxSpeed = 0;
|
||
for (let i = 0; i < speeds.length; i++) {
|
||
let sum = 0, cnt = 0;
|
||
for (let j = Math.max(0, i - 4); j <= Math.min(speeds.length - 1, i + 4); j++) {
|
||
sum += speeds[j]; cnt++;
|
||
}
|
||
const smooth = sum / cnt;
|
||
if (smooth > maxSpeed) maxSpeed = smooth;
|
||
}
|
||
|
||
const first = new Date(sorted[0].ts);
|
||
const last = new Date(sorted[sorted.length - 1].ts);
|
||
const duration = (last - first) / 1000;
|
||
const avgSpeed = duration > 0 ? (distance / duration) : 0;
|
||
const avgSpeedKmh = avgSpeed * 3.6;
|
||
const maxSpeedKmh = maxSpeed * 3.6;
|
||
|
||
// Average cadence (RPM) — only if gear data was provided and there was pedaling
|
||
const avgCadence = (hasGearData && pedalingSeconds > 0)
|
||
? Math.round(totalPedalRevs / (pedalingSeconds / 60))
|
||
: null;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat
|
||
// Scans smoothed altitude. Merges adjacent segments of same type. Min 50m distance.
|
||
// ---------------------------------------------------------------------------
|
||
const segments = [];
|
||
if (sorted.length >= 3) {
|
||
// Compute per-point gradient from smoothed altitude, accumulate into raw segments
|
||
const rawSegs = []; // { type, startIdx, endIdx }
|
||
let segStartIdx = 0;
|
||
let segType = "flat";
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||
if (d < 1) continue; // skip negligible distance
|
||
let grad = 0;
|
||
if (smoothedAlt[i] != null && smoothedAlt[i-1] != null) {
|
||
grad = (smoothedAlt[i] - smoothedAlt[i-1]) / d;
|
||
}
|
||
let pType = "flat";
|
||
if (grad > 0.02) pType = "climb";
|
||
else if (grad < -0.02) pType = "descent";
|
||
|
||
if (pType !== segType && i > segStartIdx + 1) {
|
||
rawSegs.push({ type: segType, startIdx: segStartIdx, endIdx: i - 1 });
|
||
segStartIdx = i - 1;
|
||
segType = pType;
|
||
}
|
||
}
|
||
rawSegs.push({ type: segType, startIdx: segStartIdx, endIdx: sorted.length - 1 });
|
||
|
||
// Merge adjacent segments of same type, and merge tiny segments (<50m) into neighbors
|
||
const merged = [];
|
||
for (const seg of rawSegs) {
|
||
if (merged.length > 0 && merged[merged.length - 1].type === seg.type) {
|
||
merged[merged.length - 1].endIdx = seg.endIdx;
|
||
} else {
|
||
merged.push({ ...seg });
|
||
}
|
||
}
|
||
|
||
// Absorb tiny segments into surrounding ones
|
||
const merged2 = [];
|
||
for (let i = 0; i < merged.length; i++) {
|
||
const seg = merged[i];
|
||
// Calculate distance for this segment
|
||
let segDist = 0;
|
||
for (let j = seg.startIdx + 1; j <= seg.endIdx; j++) {
|
||
segDist += GPS.distance(sorted[j-1].lat, sorted[j-1].lon, sorted[j].lat, sorted[j].lon);
|
||
}
|
||
if (segDist < 50 && merged2.length > 0) {
|
||
// Absorb into previous segment
|
||
merged2[merged2.length - 1].endIdx = seg.endIdx;
|
||
} else {
|
||
merged2.push({ ...seg });
|
||
}
|
||
}
|
||
|
||
// Build final segment stats
|
||
for (const seg of merged2) {
|
||
let segDist = 0, segGain = 0, segLoss = 0;
|
||
for (let j = seg.startIdx + 1; j <= seg.endIdx; j++) {
|
||
segDist += GPS.distance(sorted[j-1].lat, sorted[j-1].lon, sorted[j].lat, sorted[j].lon);
|
||
if (smoothedAlt[j] != null && smoothedAlt[j-1] != null) {
|
||
const diff = smoothedAlt[j] - smoothedAlt[j-1];
|
||
if (diff > 0) segGain += diff;
|
||
else segLoss += Math.abs(diff);
|
||
}
|
||
}
|
||
const segDuration = (new Date(sorted[seg.endIdx].ts) - new Date(sorted[seg.startIdx].ts)) / 1000;
|
||
const segAvgSpeed = segDuration > 0 ? (segDist / segDuration) * 3.6 : 0;
|
||
const elevChange = seg.type === "climb" ? segGain : seg.type === "descent" ? segLoss : Math.max(segGain, segLoss);
|
||
const avgGradient = segDist > 0 ? (elevChange / segDist) * 100 : 0;
|
||
|
||
segments.push({
|
||
type: seg.type,
|
||
startIdx: seg.startIdx,
|
||
endIdx: seg.endIdx,
|
||
distance: Math.round(segDist),
|
||
elevGain: Math.round(segGain),
|
||
elevLoss: Math.round(segLoss),
|
||
avgGradient: Math.round(avgGradient * 10) / 10,
|
||
duration: Math.round(segDuration),
|
||
avgSpeed: Math.round(segAvgSpeed * 10) / 10,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
distance: Math.round(distance),
|
||
elevGain: Math.round(elevGain),
|
||
elevLoss: Math.round(elevLoss),
|
||
avgSpeed: Math.round(avgSpeedKmh * 10) / 10,
|
||
maxSpeed: Math.round(maxSpeedKmh * 10) / 10,
|
||
kcal: Math.round(totalKcal),
|
||
duration: Math.round(duration),
|
||
avgCadence,
|
||
segments,
|
||
};
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Speed smoothing — 3-point moving average
|
||
// ---------------------------------------------------------------------------
|
||
GPS.smoothSpeeds = function(points) {
|
||
const speeds = [];
|
||
for (let i = 0; i < points.length; i++) {
|
||
if (i === 0 || i === points.length - 1) {
|
||
speeds.push(points[i].speed_ms ?? 0);
|
||
} else {
|
||
const a = points[i-1].speed_ms ?? 0;
|
||
const b = points[i].speed_ms ?? 0;
|
||
const c = points[i+1].speed_ms ?? 0;
|
||
speeds.push((a + b + c) / 3);
|
||
}
|
||
}
|
||
return speeds;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Live recording state
|
||
// ---------------------------------------------------------------------------
|
||
GPS.recording = {
|
||
active: false,
|
||
sessionId: null,
|
||
watchId: null,
|
||
points: [],
|
||
startTime: null,
|
||
activityType: "bike",
|
||
currentHr: null,
|
||
currentBleData: null,
|
||
hrPollInterval: null,
|
||
};
|
||
|
||
// Recording states: waiting_gps → ready → recording (auto on first movement)
|
||
// Pause: speed < 1 m/s for 5+ seconds → stop counting distance/time
|
||
// Start native GPS service on Android (foreground service survives background)
|
||
GPS._startNativeService = async function() {
|
||
try {
|
||
// Per-activity speed limits
|
||
const type = GPS.recording.activityType;
|
||
const isWalk = type === "walk" || type === "run";
|
||
const minSpd = isWalk ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs;
|
||
const maxSpd = isWalk ? (GPS.config.walkMaxSpeedKmh / 3.6) : (GPS.config.maxSpeedKmh / 3.6);
|
||
let fused = GPS.config.useFusedLocation ? 1 : 0;
|
||
let cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}:${fused}`;
|
||
await window.__TAURI__.core.invoke("gps_service_command", { cmd });
|
||
console.log("[GPS] Native service started with config:", cmd);
|
||
GPS.recording.nativeService = true;
|
||
} catch (e) {
|
||
console.log("[GPS] No native service (desktop?), using WebView GPS");
|
||
GPS.recording.nativeService = false;
|
||
}
|
||
};
|
||
|
||
GPS._stopNativeService = async function() {
|
||
try {
|
||
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "stop" });
|
||
} catch {}
|
||
};
|
||
|
||
// Ask Android for background location ("Allow all the time") — required to record
|
||
// while the screen is off. On Android 11+ this opens the system location settings.
|
||
GPS.requestBackgroundLocation = async function() {
|
||
try {
|
||
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "reqbgloc" });
|
||
} catch (e) { console.log("[GPS] reqbgloc:", e); }
|
||
};
|
||
|
||
// BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses.
|
||
// HR keeps measuring even when rider is stationary.
|
||
GPS._startHrMonitor = async function() {
|
||
if (GPS.recording.hrPollInterval) return;
|
||
try {
|
||
const addr = await DB.getProfileValue("ble_device_addr");
|
||
const type = await DB.getProfileValue("ble_device_type");
|
||
if (!addr || !type) { console.log("[HR] No paired device"); return; }
|
||
|
||
const connected = await window.__TAURI__.core.invoke("ble_is_connected");
|
||
if (!connected) {
|
||
console.log("[HR] Connecting to", addr, type);
|
||
// Just send connect — Kotlin handles scan+connect internally
|
||
try {
|
||
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
|
||
} catch (e) {
|
||
console.log("[HR] Connect failed:", e);
|
||
}
|
||
}
|
||
// Start polling HR regardless — data will appear when device connects
|
||
console.log("[HR] Starting HR poll");
|
||
GPS.recording.hrPollInterval = setInterval(async () => {
|
||
try {
|
||
const reading = await window.__TAURI__.core.invoke("ble_get_hr");
|
||
if (reading && reading.hr > 0) {
|
||
GPS.recording.currentHr = reading.hr;
|
||
GPS.recording.currentBleData = reading;
|
||
if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading);
|
||
}
|
||
} catch {}
|
||
}, 2000);
|
||
} catch (e) {
|
||
console.log("[HR] Monitor start failed:", e);
|
||
}
|
||
};
|
||
|
||
GPS._stopHrMonitor = function() {
|
||
if (GPS.recording.hrPollInterval) {
|
||
clearInterval(GPS.recording.hrPollInterval);
|
||
GPS.recording.hrPollInterval = null;
|
||
}
|
||
GPS.recording.currentHr = null;
|
||
GPS.recording.currentBleData = null;
|
||
// Clear stale HR data file so next session doesn't show old values
|
||
try { window.__TAURI__.core.invoke("ble_clear_hr"); } catch {}
|
||
};
|
||
|
||
// Read points from native service file
|
||
// Parse raw JSONL (one native point per line) into sorted point objects.
|
||
GPS._parseNativePoints = function(raw) {
|
||
const points = [];
|
||
if (!raw) return points;
|
||
for (const line of raw.trim().split("\n")) {
|
||
if (!line) continue;
|
||
try {
|
||
const d = JSON.parse(line);
|
||
if (d.lat == null || d.lon == null) continue; // skip marker/invalid lines
|
||
points.push({
|
||
lat: d.lat, lon: d.lon, altitude: d.alt,
|
||
speed_ms: d.speed, accuracy: d.acc,
|
||
bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground
|
||
ts: new Date(d.ts).toISOString(),
|
||
accel_rms: d.accel_rms, accel_max: d.accel_max,
|
||
gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw,
|
||
estimated: d.estimated || false,
|
||
});
|
||
} catch { /* skip partial/corrupt lines */ }
|
||
}
|
||
points.sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||
return points;
|
||
};
|
||
|
||
GPS._readNativePoints = async function() {
|
||
try {
|
||
const raw = await window.__TAURI__.core.invoke("gps_service_read_points");
|
||
return GPS._parseNativePoints(raw);
|
||
} catch { return []; }
|
||
};
|
||
|
||
// On-device track recovery. The foreground service writes every point to a
|
||
// native gps_points_<ms>.jsonl regardless of screen state, so these files are
|
||
// the source of truth for screen-off stretches the WebView missed.
|
||
//
|
||
// MATCHING — both a file and a session carry their own start timestamp, so we
|
||
// match them directly by id/date, never by "which session has points here":
|
||
// * The filename's <ms> is when the service started; a session's id is
|
||
// Date.now() at first movement. Empirically the file always starts a few
|
||
// seconds to ~25 min BEFORE its session (warmup before moving), same date.
|
||
// * A file belongs to session S iff S.date == file's date AND the file's start
|
||
// falls inside S's point window ±30 min. That rejects orphan recordings that
|
||
// left no session (e.g. the 07-05 files, hours from any session) and can
|
||
// never glue a file onto a foreign date.
|
||
//
|
||
// SAFETY (learned the hard way — a naive version corrupted real rides):
|
||
// * Only import file points whose own date == the session's date, so a stray
|
||
// glitched timestamp inside a correctly-matched file can't leak in.
|
||
// * recalculateSession is called with {noDelete:true}: recovery ADDS points, it
|
||
// must never trigger the destructive glitch/>24h filter that once wiped the
|
||
// real 07-22/07-24 rides.
|
||
GPS.recoverFromFiles = async function(onProgress) {
|
||
if (GPS.recording.active) return { error: "recording" };
|
||
const invoke = window.__TAURI__.core.invoke;
|
||
const sel = (q, v) => invoke("plugin:sql|select", { db: "sqlite:baldav.db", query: q, values: v || [] });
|
||
let files = [];
|
||
try { files = JSON.parse(await invoke("list_points_files")); }
|
||
catch (e) { console.error("[recover] list_points_files FAILED", e); }
|
||
console.log(`[recover] files=${files.length}`);
|
||
|
||
// Load every session's date + actual point window once. CAST id to TEXT — the
|
||
// SQL plugin corrupts large i64 ids returned as numbers.
|
||
let sessRows = [];
|
||
try {
|
||
sessRows = await sel(
|
||
"SELECT CAST(s.id AS TEXT) AS sid, s.date AS d, MIN(p.ts) AS mn, MAX(p.ts) AS mx " +
|
||
"FROM gps_sessions s JOIN gps_points p ON p.session_id = s.id GROUP BY s.id"
|
||
);
|
||
} catch (e) { console.error("[recover] session windows FAILED", e); }
|
||
const sessions = sessRows.map(r => ({
|
||
sid: r.sid, date: r.d,
|
||
mn: new Date(r.mn).getTime(), mx: new Date(r.mx).getTime(),
|
||
}));
|
||
|
||
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||
const NEAR_MS = 30 * 60 * 1000; // file start may lead the session by up to ~25 min
|
||
|
||
// The file the service is (or was last) writing — never delete it, a resumed
|
||
// session may still append to it.
|
||
let activeFile = "";
|
||
try { activeFile = (await invoke("get_points_filename")) || ""; } catch {}
|
||
|
||
const results = [];
|
||
for (let fi = 0; fi < files.length; fi++) {
|
||
const name = files[fi];
|
||
if (onProgress) { try { onProgress(fi + 1, files.length); } catch {} }
|
||
|
||
// The file's start ms and (for new files) its owning session id live in the
|
||
// name: gps_points_<ms>.jsonl or gps_points_<ms>_s<sessionId>.jsonl.
|
||
const m = /gps_points_(\d+)(?:_s(\d+))?\.jsonl/.exec(name);
|
||
if (!m) { console.log(`[recover] f=${name} no id in name, skip`); continue; }
|
||
const fileMs = Number(m[1]);
|
||
const boundSid = m[2] || null; // service stamped the session id into the name
|
||
const fDate = dayOf(fileMs);
|
||
|
||
let match = null;
|
||
if (boundSid) {
|
||
// Unambiguous binding: the file already names its session. Bind directly;
|
||
// if that session was deleted, leave the file unmatched (never inject).
|
||
match = sessions.find(s => s.sid === boundSid) || null;
|
||
if (!match) {
|
||
console.log(`[recover] f=${name} bound sid=${boundSid} but session gone — UNMATCHED`);
|
||
results.push({ file: name, imported: 0, unmatched: true });
|
||
continue;
|
||
}
|
||
} else {
|
||
// Legacy files (no session id in name): match by id/date — same date, file
|
||
// start inside the session window ±30 min, nearest such session wins.
|
||
let bestGap = Infinity;
|
||
for (const s of sessions) {
|
||
if (s.date !== fDate) continue;
|
||
const gap = fileMs < s.mn ? s.mn - fileMs : (fileMs > s.mx ? fileMs - s.mx : 0);
|
||
if (gap <= NEAR_MS && gap < bestGap) { bestGap = gap; match = s; }
|
||
}
|
||
if (!match) {
|
||
console.log(`[recover] f=${name} start=${new Date(fileMs).toISOString()} UNMATCHED`);
|
||
results.push({ file: name, imported: 0, unmatched: true });
|
||
continue;
|
||
}
|
||
}
|
||
const sid = match.sid;
|
||
|
||
let raw = "";
|
||
try { raw = await invoke("read_points_file", { filename: name }); }
|
||
catch (e) { console.error(`[recover] read FAILED ${name}`, e); continue; }
|
||
const pts = GPS._parseNativePoints(raw);
|
||
if (pts.length < 2) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; }
|
||
|
||
// Only points on the matched session's own date may be imported (blocks a
|
||
// stray glitched timestamp inside an otherwise-correct file).
|
||
const sameDay = pts.filter(p => p.ts.slice(0, 10) === match.date);
|
||
if (!sameDay.length) { results.push({ file: name, sessionId: sid, imported: 0 }); continue; }
|
||
|
||
const existing = await DB.getGpsPoints(sid);
|
||
// Dedup by TIME PROXIMITY, not exact ms. The native service and the WebView
|
||
// watchPosition record the SAME fixes with slightly different millisecond
|
||
// stamps, so exact-ms dedup keeps both → interleaved zig-zag inflates
|
||
// distance/kcal. Skip a native point if any existing point is within PROX ms;
|
||
// keep only points in genuine gaps (screen-off stretches the WebView missed).
|
||
const PROX = 2500;
|
||
const existMs = existing.map(p => new Date(p.ts).getTime()).sort((a, b) => a - b);
|
||
const nearExisting = (t) => {
|
||
let lo = 0, hi = existMs.length - 1;
|
||
while (lo <= hi) { let mid = (lo + hi) >> 1; if (existMs[mid] < t) lo = mid + 1; else hi = mid - 1; }
|
||
let best = Infinity;
|
||
if (lo < existMs.length) best = Math.min(best, existMs[lo] - t);
|
||
if (lo > 0) best = Math.min(best, t - existMs[lo - 1]);
|
||
return best <= PROX;
|
||
};
|
||
const missing = sameDay.filter(p => !nearExisting(new Date(p.ts).getTime()));
|
||
let imported = 0;
|
||
if (missing.length) imported = await DB.addGpsPointsBulk(sid, missing, existing.length);
|
||
// Recalc so elevation/kcal fixes apply even when nothing new was imported.
|
||
// noDelete: recovery must NEVER run the destructive glitch/>24h filter.
|
||
try { await GPS.recalculateSession(sid, { noDelete: true }); }
|
||
catch (e) { console.error("[recover] recalc failed", e); }
|
||
// The file's points are now safely in the DB — delete it to clear the backlog
|
||
// clutter. Skip the active file. Orphan/unmatched files are kept (their points
|
||
// have no session, so deleting would lose data).
|
||
let removed = false;
|
||
if (name !== activeFile) {
|
||
try { await invoke("delete_points_file", { filename: name }); removed = true; }
|
||
catch (e) { console.warn(`[recover] delete failed ${name}`, e); }
|
||
}
|
||
console.log(`[recover] f=${name} pts=${pts.length} match=${sid} existing=${existing.length} imported=${imported} removed=${removed}`);
|
||
results.push({ file: name, sessionId: sid, imported, removed });
|
||
}
|
||
const removedN = results.filter(r => r.removed).length;
|
||
console.log(`[recover] DONE files=${results.length} imported=${results.reduce((a,r)=>a+(r.imported||0),0)} removed=${removedN}`);
|
||
return { results, removed: removedN };
|
||
};
|
||
|
||
// Core point processing — used by both WebView GPS (watchPosition) and native
|
||
// service polling. `fromNative` = the point came from the native file via the
|
||
// poll. On Android the native file is the complete, screen-independent source of
|
||
// truth, so only native points are stored; watchPosition then just drives the
|
||
// live marker — the same fix is never written twice with different timestamps.
|
||
GPS._processIncomingPoint = async function(p, fromNative) {
|
||
if (!GPS.recording.active) return;
|
||
// DB writes come only from the source of truth: native points on Android, or
|
||
// watchPosition on desktop (no native service).
|
||
const writeDb = !!fromNative || !GPS.recording.nativeService;
|
||
|
||
// State: waiting_gps → ready (got first accurate fix)
|
||
if (GPS.recording.state === "waiting_gps") {
|
||
GPS.recording.state = "ready";
|
||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("ready", p);
|
||
}
|
||
|
||
// Manual pause — show position but don't process
|
||
if (GPS.recording.manualPaused) {
|
||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||
return;
|
||
}
|
||
|
||
// Determine if moving — same per-activity threshold as the native service
|
||
// (walk/run → walkMinSpeedMs, else minSpeedMs). 0 means "always moving":
|
||
// no auto-pause, no sticking on "начните движение".
|
||
let speed = p.speed_ms != null ? p.speed_ms : 0;
|
||
let mvType = GPS.recording.activityType;
|
||
let effMinSpeed = (mvType === "walk" || mvType === "run") ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs;
|
||
let isMoving = effMinSpeed <= 0 ? true : speed > effMinSpeed;
|
||
if (!isMoving && GPS.recording.lastMovingPoint) {
|
||
const d = GPS.distance(GPS.recording.lastMovingPoint.lat, GPS.recording.lastMovingPoint.lon, p.lat, p.lon);
|
||
const dt = (new Date(p.ts) - new Date(GPS.recording.lastMovingPoint.ts)) / 1000;
|
||
if (d > GPS.config.minDistanceM * 3 && dt < 5 && dt > 0) isMoving = true;
|
||
}
|
||
|
||
// State: ready → recording (first movement detected)
|
||
if (GPS.recording.state === "ready" && isMoving) {
|
||
GPS.recording.state = "recording";
|
||
GPS.recording.startTime = new Date();
|
||
const now = GPS.recording.startTime;
|
||
try {
|
||
GPS.recording.sessionId = await DB.createGpsSession(
|
||
now.toISOString().slice(0, 10),
|
||
now.toTimeString().slice(0, 5),
|
||
GPS.recording.activityType
|
||
);
|
||
} catch (e) {
|
||
console.error("[GPS] CRITICAL: createGpsSession FAILED:", e);
|
||
// Show visible error — this is data loss
|
||
if (typeof showToast === "function") showToast("DB: " + (e.message || e));
|
||
}
|
||
// Stamp the session id into the native file's name so recovery binds it
|
||
// unambiguously (the service renames gps_points_<ms>.jsonl -> ..._s<id>.jsonl).
|
||
if (GPS.recording.sessionId != null) {
|
||
try { await window.__TAURI__.core.invoke("gps_set_session_id", { sessionId: String(GPS.recording.sessionId) }); }
|
||
catch (e) { console.warn("[GPS] gps_set_session_id failed (non-critical):", e); }
|
||
}
|
||
GPS._persistState();
|
||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("recording", p);
|
||
}
|
||
|
||
// If not yet recording, just update position display
|
||
if (GPS.recording.state !== "recording") {
|
||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||
return;
|
||
}
|
||
|
||
// Auto-pause detection — still save HR data during pause
|
||
if (!isMoving) {
|
||
if (!GPS.recording.pauseStart) {
|
||
GPS.recording.pauseStart = new Date(p.ts);
|
||
}
|
||
// Save point with HR even during pause (no seq = excluded from distance/kcal)
|
||
if (writeDb && GPS.recording.sessionId && GPS.recording.currentBleData) {
|
||
DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, 0, null, GPS.recording.currentBleData, p);
|
||
}
|
||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||
return;
|
||
}
|
||
|
||
// Resumed from auto-pause
|
||
if (GPS.recording.pauseStart) {
|
||
const pauseMs = new Date(p.ts) - GPS.recording.pauseStart;
|
||
GPS.recording.totalPauseMs += pauseMs;
|
||
GPS.recording.pauseStart = null;
|
||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("resumed", p);
|
||
}
|
||
|
||
// Save moving point with seq — only from the source of truth. watchPosition on
|
||
// Android skips the write (the poll stores the native equivalent), so distance
|
||
// and kcal aren't inflated by the same fix landing twice.
|
||
GPS.recording.lastMovingPoint = p;
|
||
if (writeDb) {
|
||
GPS.recording.points.push(p);
|
||
GPS.recording._pointSeq = (GPS.recording._pointSeq || 0) + 1;
|
||
if (GPS.recording.sessionId) {
|
||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, GPS.recording._pointSeq, GPS.recording.currentBleData, p);
|
||
}
|
||
}
|
||
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
|
||
};
|
||
|
||
// Persist recording state to localStorage for crash recovery
|
||
GPS._persistState = function() {
|
||
try {
|
||
localStorage.setItem("baldav_gps_active", JSON.stringify({
|
||
sessionId: GPS.recording.sessionId,
|
||
activityType: GPS.recording.activityType,
|
||
weightKg: GPS.recording.weightKg,
|
||
startTime: GPS.recording.startTime?.toISOString(),
|
||
totalPauseMs: GPS.recording.totalPauseMs,
|
||
manualPaused: GPS.recording.manualPaused,
|
||
pauses: GPS.recording.pauses || [],
|
||
pointCount: GPS.recording.points.length,
|
||
bikeId: GPS.recording.bikeId ?? null,
|
||
bikeWeightKg: GPS.recording.bikeWeightKg ?? null,
|
||
surfaceCrr: GPS.recording.surfaceCrr ?? null,
|
||
tirePressureBar: GPS.recording.tirePressureBar ?? null,
|
||
chainringTeeth: GPS.recording.chainringTeeth ?? null,
|
||
cogTeeth: GPS.recording.cogTeeth ?? null,
|
||
wheelDiameterInch: GPS.recording.wheelDiameterInch ?? null,
|
||
}));
|
||
} catch {}
|
||
};
|
||
|
||
GPS._clearPersistedState = function() {
|
||
localStorage.removeItem("baldav_gps_active");
|
||
};
|
||
|
||
// Manual pause/resume
|
||
GPS.pauseRecording = function() {
|
||
if (!GPS.recording.active || GPS.recording.manualPaused) return;
|
||
GPS.recording.manualPaused = true;
|
||
const last = GPS.recording.lastMovingPoint || GPS.recording.points[GPS.recording.points.length - 1];
|
||
GPS.recording._currentPause = {
|
||
startTs: Date.now(),
|
||
startLat: last?.lat, startLon: last?.lon,
|
||
};
|
||
GPS._persistState();
|
||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_paused");
|
||
};
|
||
|
||
GPS.resumeRecording = function() {
|
||
if (!GPS.recording.active || !GPS.recording.manualPaused) return;
|
||
GPS.recording.manualPaused = false;
|
||
const last = GPS.recording.lastMovingPoint || GPS.recording.points[GPS.recording.points.length - 1];
|
||
if (GPS.recording._currentPause) {
|
||
GPS.recording._currentPause.endTs = Date.now();
|
||
GPS.recording._currentPause.endLat = last?.lat;
|
||
GPS.recording._currentPause.endLon = last?.lon;
|
||
const dur = GPS.recording._currentPause.endTs - GPS.recording._currentPause.startTs;
|
||
GPS.recording.totalPauseMs += dur;
|
||
if (!GPS.recording.pauses) GPS.recording.pauses = [];
|
||
GPS.recording.pauses.push(GPS.recording._currentPause);
|
||
GPS.recording._currentPause = null;
|
||
}
|
||
GPS._persistState();
|
||
if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_resumed");
|
||
};
|
||
|
||
// True when an active session has had no movement for autoFinishIdleMin minutes —
|
||
// used to auto-finish a session the user forgot to stop, so the next outing is separate.
|
||
GPS.shouldAutoFinish = function() {
|
||
if (!GPS.recording.active || GPS.recording.manualPaused) return false;
|
||
if (!GPS.recording.sessionId) return false; // no session yet (no movement)
|
||
let last = GPS.recording.lastMovingPoint;
|
||
if (!last) return false;
|
||
let idleMs = Date.now() - new Date(last.ts).getTime();
|
||
return idleMs > (GPS.config.autoFinishIdleMin || 15) * 60000;
|
||
};
|
||
|
||
// Recover orphaned recording session on app startup
|
||
GPS.recoverSession = async function() {
|
||
const saved = localStorage.getItem("baldav_gps_active");
|
||
if (!saved) return false;
|
||
try {
|
||
const s = JSON.parse(saved);
|
||
if (!s.sessionId) { GPS._clearPersistedState(); return; }
|
||
// Import EVERY native point missing from the DB, deduped by the SET of existing
|
||
// timestamps — NOT "ts > maxDbTs". The live poll is screen-gated and leaves
|
||
// sparse points spread across the whole session, so the max DB ts can be the
|
||
// LAST point; "ts > maxDbTs" would then import nothing and the gaps stay.
|
||
const existingPts = await DB.getGpsPoints(s.sessionId);
|
||
const dbTs = new Set(existingPts.map(p => new Date(p.ts).getTime()));
|
||
const nativePoints = await GPS._readNativePoints();
|
||
let imported = 0;
|
||
let seq = existingPts.length;
|
||
for (const p of nativePoints) {
|
||
if (dbTs.has(new Date(p.ts).getTime())) continue;
|
||
seq++;
|
||
await DB.addGpsPoint(s.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
|
||
imported++;
|
||
}
|
||
// Finalize the session
|
||
const pts = await DB.getGpsPoints(s.sessionId);
|
||
if (pts.length > 0) {
|
||
const recoverOpts = {
|
||
activityType: s.activityType,
|
||
bikeWeightKg: s.bikeWeightKg ?? 0,
|
||
surfaceCrr: s.surfaceCrr,
|
||
tirePressureBar: s.tirePressureBar ?? 3.0,
|
||
chainringTeeth: s.chainringTeeth,
|
||
cogTeeth: s.cogTeeth,
|
||
wheelDiameterInch: s.wheelDiameterInch,
|
||
};
|
||
const stats = GPS.computeStats(pts, s.weightKg || 80, recoverOpts);
|
||
await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5),
|
||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||
// Store bike_id if present
|
||
if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId);
|
||
console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points");
|
||
GPS._clearPersistedState();
|
||
await GPS._stopNativeService();
|
||
return true;
|
||
}
|
||
GPS._clearPersistedState();
|
||
await GPS._stopNativeService();
|
||
return false;
|
||
} catch (e) {
|
||
console.error("[GPS] Recovery error:", e);
|
||
GPS._clearPersistedState();
|
||
}
|
||
};
|
||
|
||
GPS.startRecording = async function(activityType, weightKg) {
|
||
if (GPS.recording.active) return;
|
||
if (!navigator.geolocation) throw new Error("Geolocation not available");
|
||
|
||
GPS.recording.active = true;
|
||
GPS.recording.activityType = activityType || "bike";
|
||
GPS.recording.points = [];
|
||
GPS.recording.startTime = null;
|
||
GPS.recording.weightKg = parseFloat(weightKg) || 80;
|
||
GPS.recording.state = "waiting_gps";
|
||
GPS.recording.sessionId = null;
|
||
GPS.recording.pauseStart = null;
|
||
GPS.recording.totalPauseMs = 0;
|
||
GPS.recording.lastMovingPoint = null;
|
||
GPS.recording.manualPaused = false;
|
||
GPS.recording.pauses = [];
|
||
GPS.recording._currentPause = null;
|
||
GPS.recording.nativeService = false;
|
||
GPS.recording._pollTimer = null;
|
||
GPS.recording._lastProcessedTs = 0;
|
||
GPS.recording._pointSeq = 0;
|
||
|
||
// Start native foreground service (Android)
|
||
await GPS._startNativeService();
|
||
|
||
// Start BLE HR monitor (auto-connect to paired device)
|
||
try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); }
|
||
|
||
// Poll native service output file
|
||
if (GPS.recording.nativeService) {
|
||
let polling = false;
|
||
GPS.recording._pollTimer = setInterval(async () => {
|
||
console.log(`[poll] tick active=${GPS.recording.active} polling=${polling} hidden=${document.hidden} lastTs=${GPS.recording._lastProcessedTs}`);
|
||
if (!GPS.recording.active) { clearInterval(GPS.recording._pollTimer); return; }
|
||
if (polling) return; // skip if previous poll still running
|
||
polling = true;
|
||
try {
|
||
const nativePoints = await GPS._readNativePoints();
|
||
console.log(`[poll] native=${nativePoints.length} firstTs=${nativePoints[0]?.ts} lastNativeTs=${nativePoints[nativePoints.length-1]?.ts}`);
|
||
let newCount = 0;
|
||
// Keep the heavy per-point path off (onPoint runs computeStats over all
|
||
// points + DB writes → O(n²) on a big backlog). Draw each imported point
|
||
// cheaply via onImportPoint so the track visibly GROWS as points transfer
|
||
// from the native file, and yield a frame every ~20 points so Leaflet
|
||
// repaints — this makes the screen-wake catch-up visible again.
|
||
const origOnPoint = GPS.recording.onPoint;
|
||
GPS.recording.onPoint = null;
|
||
for (const p of nativePoints) {
|
||
const pTs = new Date(p.ts).getTime();
|
||
if (pTs <= GPS.recording._lastProcessedTs) continue;
|
||
await GPS._processIncomingPoint(p, true);
|
||
GPS.recording._lastProcessedTs = pTs;
|
||
newCount++;
|
||
if (GPS.recording.onImportPoint) GPS.recording.onImportPoint(p);
|
||
// Yield a frame so the growing track repaints — but ONLY when visible.
|
||
// requestAnimationFrame never fires while the screen is off (page hidden),
|
||
// so awaiting it there would hang this loop forever and freeze the import.
|
||
if (newCount % 20 === 0 && !document.hidden) {
|
||
console.log(`[poll] raf-wait at ${newCount}`);
|
||
await new Promise(r => requestAnimationFrame(r));
|
||
console.log(`[poll] raf-done at ${newCount}`);
|
||
}
|
||
}
|
||
GPS.recording.onPoint = origOnPoint;
|
||
console.log(`[poll] imported=${newCount} newLastTs=${GPS.recording._lastProcessedTs}`);
|
||
// Final sync — marker, pan, course and stats from the full point set.
|
||
if (newCount > 0 && GPS.recording.onBatchUpdate) {
|
||
GPS.recording.onBatchUpdate();
|
||
}
|
||
} catch (e) {
|
||
console.error("[GPS] poll error:", e);
|
||
}
|
||
polling = false;
|
||
}, 2000);
|
||
}
|
||
|
||
GPS.recording.watchId = navigator.geolocation.watchPosition(
|
||
async (pos) => {
|
||
if (pos.coords.accuracy > GPS.config.minAccuracyM) return;
|
||
await GPS._processIncomingPoint({
|
||
lat: pos.coords.latitude,
|
||
lon: pos.coords.longitude,
|
||
altitude: pos.coords.altitude,
|
||
speed_ms: pos.coords.speed,
|
||
accuracy: pos.coords.accuracy,
|
||
bearing: (pos.coords.heading != null && !isNaN(pos.coords.heading)) ? pos.coords.heading : null,
|
||
ts: new Date(pos.timestamp).toISOString(),
|
||
});
|
||
},
|
||
(err) => {
|
||
console.error("GPS error:", err.message);
|
||
if (GPS.recording.onError) GPS.recording.onError(err);
|
||
},
|
||
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
|
||
);
|
||
};
|
||
|
||
GPS.stopRecording = async function() {
|
||
if (!GPS.recording.active) return null;
|
||
GPS.recording.active = false;
|
||
|
||
if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer);
|
||
GPS._stopHrMonitor();
|
||
|
||
if (GPS.recording.watchId != null) {
|
||
navigator.geolocation.clearWatch(GPS.recording.watchId);
|
||
GPS.recording.watchId = null;
|
||
}
|
||
|
||
// Stop native service — read remaining points but DON'T delete the file yet
|
||
let nativePointsFile = null;
|
||
console.log(`[stop] enter nativeService=${GPS.recording.nativeService} sessionId=${GPS.recording.sessionId}`);
|
||
if (GPS.recording.nativeService) {
|
||
// Import EVERY native point not yet in the DB. The live poll is screen-gated
|
||
// (WebView freezes timers while the screen is off), so a walk with the screen
|
||
// off leaves most points only in the native file. Idempotent by timestamp —
|
||
// same pattern as recoverSession. Then recompute stats over the COMPLETE set.
|
||
const nativePoints = await GPS._readNativePoints();
|
||
let stopImported = 0;
|
||
if (GPS.recording.sessionId && nativePoints.length) {
|
||
const existing = await DB.getGpsPoints(GPS.recording.sessionId);
|
||
const dbTs = new Set(existing.map(p => new Date(p.ts).getTime()));
|
||
let seq = existing.length;
|
||
for (const p of nativePoints) {
|
||
if (dbTs.has(new Date(p.ts).getTime())) continue;
|
||
seq++;
|
||
await DB.addGpsPoint(GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms, seq, null, p);
|
||
stopImported++;
|
||
}
|
||
console.log(`[stop] native=${nativePoints.length} existing=${existing.length} 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,
|
||
};
|
||
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts);
|
||
const endTime = new Date().toTimeString().slice(0, 5);
|
||
|
||
try {
|
||
await DB.finishGpsSession(
|
||
GPS.recording.sessionId, endTime,
|
||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
||
stats.elevGain, stats.elevLoss
|
||
);
|
||
// DB save confirmed — now safe to delete the points file
|
||
if (nativePointsFile) {
|
||
try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {}
|
||
}
|
||
} catch (e) {
|
||
console.error("[GPS] finishGpsSession FAILED — points file preserved:", e);
|
||
if (typeof showToast === "function") showToast("Сохранение трека: " + (e.message || e));
|
||
}
|
||
|
||
// Store bike_id in session if present
|
||
if (GPS.recording.bikeId) {
|
||
await DB.updateSessionBike(GPS.recording.sessionId, GPS.recording.bikeId);
|
||
}
|
||
|
||
const sid = GPS.recording.sessionId;
|
||
const pauses = GPS.recording.pauses || [];
|
||
GPS.recording.sessionId = null;
|
||
GPS.recording.points = [];
|
||
GPS.recording.pauses = [];
|
||
GPS.recording.manualPaused = false;
|
||
GPS.recording.bikeId = null;
|
||
GPS.recording.bikeWeightKg = null;
|
||
GPS.recording.surfaceCrr = null;
|
||
GPS.recording.tirePressureBar = null;
|
||
GPS.recording.chainringTeeth = null;
|
||
GPS.recording.cogTeeth = null;
|
||
GPS.recording.wheelDiameterInch = null;
|
||
GPS._clearPersistedState();
|
||
return { sessionId: sid, stats, pauses };
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GPX import
|
||
// ---------------------------------------------------------------------------
|
||
GPS.parseGPX = function(xmlString) {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(xmlString, "application/xml");
|
||
const trkpts = doc.querySelectorAll("trkpt");
|
||
const points = [];
|
||
|
||
for (const pt of trkpts) {
|
||
const lat = parseFloat(pt.getAttribute("lat"));
|
||
const lon = parseFloat(pt.getAttribute("lon"));
|
||
const eleEl = pt.querySelector("ele");
|
||
const timeEl = pt.querySelector("time");
|
||
const spdEl = pt.querySelector("speed");
|
||
|
||
points.push({
|
||
lat, lon,
|
||
altitude: eleEl ? parseFloat(eleEl.textContent) : null,
|
||
ts: timeEl ? timeEl.textContent : null,
|
||
speed: spdEl ? parseFloat(spdEl.textContent) : null,
|
||
});
|
||
}
|
||
|
||
// Activity type from GPX <type> tag
|
||
const typeEl = doc.querySelector("type");
|
||
const actType = typeEl ? typeEl.textContent.toLowerCase() : "other";
|
||
|
||
return { points, activityType: actType };
|
||
};
|
||
|
||
GPS.importGPX = async function(xmlString, weightKg) {
|
||
const { points, activityType } = GPS.parseGPX(xmlString);
|
||
if (points.length < 2) throw new Error("GPX has fewer than 2 points");
|
||
|
||
const stats = GPS.computeStats(points, parseFloat(weightKg) || 80);
|
||
const first = points[0].ts ? new Date(points[0].ts) : new Date();
|
||
const last = points[points.length - 1].ts ? new Date(points[points.length - 1].ts) : new Date();
|
||
|
||
const session = {
|
||
date: first.toISOString().slice(0, 10),
|
||
start_time: first.toTimeString().slice(0, 5),
|
||
end_time: last.toTimeString().slice(0, 5),
|
||
activity_type: actType(activityType),
|
||
total_distance_m: stats.distance,
|
||
total_kcal: stats.kcal,
|
||
avg_speed_kmh: stats.avgSpeed,
|
||
max_speed_kmh: stats.maxSpeed,
|
||
elevation_gain_m: stats.elevGain,
|
||
elevation_loss_m: stats.elevLoss,
|
||
source: "gpx_import",
|
||
};
|
||
|
||
const dbPoints = points.map((p, idx) => ({
|
||
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
|
||
}));
|
||
|
||
return DB.importGpxSession(session, dbPoints);
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FitoTrack .ftb import (zip containing data.xml)
|
||
// ---------------------------------------------------------------------------
|
||
GPS.importFTB = async function(arrayBuffer, weightKg) {
|
||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||
const xmlFile = zip.file("data.xml");
|
||
if (!xmlFile) throw new Error("data.xml not found in .ftb");
|
||
const xmlStr = await xmlFile.async("string");
|
||
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(xmlStr, "application/xml");
|
||
|
||
// Parse workouts
|
||
const workoutEls = doc.querySelectorAll("workouts > workouts");
|
||
const sampleEls = doc.querySelectorAll("samples > samples");
|
||
|
||
// Group samples by workoutId
|
||
const samplesByWorkout = {};
|
||
for (const s of sampleEls) {
|
||
const wid = s.querySelector("workoutId")?.textContent;
|
||
if (!wid) continue;
|
||
if (!samplesByWorkout[wid]) samplesByWorkout[wid] = [];
|
||
samplesByWorkout[wid].push({
|
||
lat: parseFloat(s.querySelector("lat")?.textContent),
|
||
lon: parseFloat(s.querySelector("lon")?.textContent),
|
||
altitude: parseFloat(s.querySelector("elevationMSL")?.textContent) || parseFloat(s.querySelector("elevation")?.textContent) || null,
|
||
speed: parseFloat(s.querySelector("speed")?.textContent) || null,
|
||
ts: new Date(parseInt(s.querySelector("absoluteTime")?.textContent)).toISOString(),
|
||
});
|
||
}
|
||
|
||
let imported = 0;
|
||
for (const w of workoutEls) {
|
||
const el = tag => w.querySelector(tag)?.textContent;
|
||
const wid = el("id");
|
||
const samples = samplesByWorkout[wid] || [];
|
||
if (samples.length < 2) continue;
|
||
|
||
const startMs = parseInt(el("start"));
|
||
const endMs = parseInt(el("end"));
|
||
const startDate = new Date(startMs);
|
||
const endDate = new Date(endMs);
|
||
|
||
const session = {
|
||
date: startDate.toISOString().slice(0, 10),
|
||
start_time: startDate.toTimeString().slice(0, 5),
|
||
end_time: endDate.toTimeString().slice(0, 5),
|
||
activity_type: ftbType(el("workoutType")),
|
||
total_distance_m: parseFloat(el("length")) || null,
|
||
total_kcal: parseFloat(el("calorie")) || null,
|
||
avg_speed_kmh: parseFloat(el("avgSpeed")) ? Math.round(parseFloat(el("avgSpeed")) * 3.6 * 10) / 10 : null,
|
||
max_speed_kmh: parseFloat(el("topSpeed")) ? Math.round(parseFloat(el("topSpeed")) * 3.6 * 10) / 10 : null,
|
||
elevation_gain_m: parseFloat(el("ascent")) || null,
|
||
elevation_loss_m: parseFloat(el("descent")) || null,
|
||
source: "fitotrack_import",
|
||
};
|
||
|
||
const dbPoints = samples.map((p, idx) => ({
|
||
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed, seq: idx,
|
||
}));
|
||
|
||
await DB.importGpxSession(session, dbPoints);
|
||
imported++;
|
||
}
|
||
|
||
return imported;
|
||
};
|
||
|
||
function ftbType(raw) {
|
||
const r = (raw || "").toLowerCase();
|
||
if (r.includes("cycl") || r.includes("bik")) return "bike";
|
||
if (r.includes("run")) return "run";
|
||
if (r.includes("walk") || r.includes("hik")) return "walk";
|
||
return "other";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interpolate points where GPS gap > threshold (e.g. tunnel, signal loss)
|
||
// Adds intermediate points with linear lat/lon/altitude interpolation
|
||
// ---------------------------------------------------------------------------
|
||
GPS.interpolate = function(points, maxGapSec = 30) {
|
||
if (points.length < 2) return points;
|
||
const result = [points[0]];
|
||
for (let i = 1; i < points.length; i++) {
|
||
const prev = points[i - 1];
|
||
const cur = points[i];
|
||
const prevT = new Date(prev.ts).getTime();
|
||
const curT = new Date(cur.ts).getTime();
|
||
const gapSec = (curT - prevT) / 1000;
|
||
|
||
if (gapSec > maxGapSec) {
|
||
// Insert interpolated points every ~3 seconds
|
||
const steps = Math.min(Math.ceil(gapSec / 3), 100);
|
||
for (let s = 1; s < steps; s++) {
|
||
const frac = s / steps;
|
||
const t = prevT + (curT - prevT) * frac;
|
||
result.push({
|
||
lat: prev.lat + (cur.lat - prev.lat) * frac,
|
||
lon: prev.lon + (cur.lon - prev.lon) * frac,
|
||
altitude: (prev.altitude != null && cur.altitude != null)
|
||
? prev.altitude + (cur.altitude - prev.altitude) * frac : null,
|
||
altitude_gps_m: (prev.altitude_gps_m != null && cur.altitude_gps_m != null)
|
||
? prev.altitude_gps_m + (cur.altitude_gps_m - prev.altitude_gps_m) * frac : null,
|
||
speed_ms: (prev.speed_ms != null && cur.speed_ms != null)
|
||
? prev.speed_ms + (cur.speed_ms - prev.speed_ms) * frac : null,
|
||
ts: new Date(t).toISOString(),
|
||
interpolated: true,
|
||
});
|
||
}
|
||
}
|
||
result.push(cur);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter GPS noise — remove points with unrealistic speed (>150 km/h)
|
||
// and break track into segments at large time gaps
|
||
// ---------------------------------------------------------------------------
|
||
// FitoTrack-style filter:
|
||
// 1. Skip points too close in time (<500ms)
|
||
// 2. Skip points with unrealistic speed (>maxSpeedKmh)
|
||
// 3. Skip points too close in distance (<minDistM) — configurable per activity
|
||
// Nearest-neighbor reorder: from current point, always jump to the closest
|
||
// unused point. No points removed. Untangles AGPS alternation.
|
||
// Identical to the debug version that worked.
|
||
// Reorder points: normal points go chronologically, AGPS points (with wrong
|
||
// timestamps) get inserted at their nearest spatial neighbor.
|
||
// Detection: a point is "AGPS-displaced" if it's far (>50m) from BOTH its
|
||
// chronological predecessor and successor — meaning its timestamp is wrong.
|
||
// Just return points as-is. Order comes from DB: seq (FTB XML order), then ts.
|
||
// This matches FitoTrack v15.6 which draws by insertion order without sorting.
|
||
GPS.filterNoise = function(points, activityType) {
|
||
return points;
|
||
};
|
||
|
||
// Build polyline segments — break at gaps > maxGapSec
|
||
// Returns array of arrays of [lat,lon] for separate polylines
|
||
GPS.buildSegments = function(points, maxGapSec = 120) {
|
||
if (points.length === 0) return [];
|
||
const segments = [[[points[0].lat, points[0].lon]]];
|
||
for (let i = 1; i < points.length; i++) {
|
||
const gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||
if (gap > maxGapSec) {
|
||
segments.push([]);
|
||
}
|
||
segments[segments.length - 1].push([points[i].lat, points[i].lon]);
|
||
}
|
||
return segments.filter(s => s.length > 1);
|
||
};
|
||
|
||
function actType(raw) {
|
||
const r = (raw || "").toLowerCase();
|
||
if (r.includes("bik") || r.includes("cycl")) return "bike";
|
||
if (r.includes("run")) return "run";
|
||
if (r.includes("walk") || r.includes("hik")) return "walk";
|
||
return "other";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Format helpers
|
||
// ---------------------------------------------------------------------------
|
||
GPS.formatDuration = function(seconds) {
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = Math.floor(seconds % 60);
|
||
return h > 0 ? `${h}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`
|
||
: `${m}:${String(s).padStart(2,"0")}`;
|
||
};
|
||
|
||
GPS.formatDistance = function(meters) {
|
||
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : meters + " м";
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GPX Export
|
||
// ---------------------------------------------------------------------------
|
||
GPS.exportGPX = async function(sessionId) {
|
||
const session = await DB.getGpsSession(sessionId);
|
||
if (!session) return;
|
||
const points = await DB.getGpsPoints(sessionId);
|
||
if (!points.length) return;
|
||
|
||
const esc = s => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||
const name = esc(session.activity_type || "Track") + " " + esc(session.date);
|
||
|
||
const cfg = GPS.config;
|
||
let gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<gpx creator="BalDav" version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
|
||
<metadata>
|
||
<name>${name}</name>
|
||
<time>${points[0].ts}</time>
|
||
<desc>minSpeed=${(cfg.minSpeedMs*3.6).toFixed(1)}kmh maxAcc=${cfg.minAccuracyM}m minDist=${cfg.minDistanceM}m points=${points.length}</desc>
|
||
</metadata>
|
||
<trk>
|
||
<name>${name}</name>
|
||
<type>${esc(session.activity_type || "other")}</type>
|
||
<trkseg>
|
||
`;
|
||
|
||
for (const p of points) {
|
||
gpx += ` <trkpt lat="${p.lat}" lon="${p.lon}">
|
||
`;
|
||
if (p.altitude_gps_m != null) gpx += ` <ele>${p.altitude_gps_m}</ele>\n`;
|
||
if (p.ts) gpx += ` <time>${p.ts}</time>\n`;
|
||
if (p.speed_ms != null) gpx += ` <extensions><speed>${p.speed_ms}</speed></extensions>\n`;
|
||
gpx += ` </trkpt>\n`;
|
||
}
|
||
|
||
gpx += ` </trkseg>
|
||
</trk>
|
||
</gpx>`;
|
||
|
||
// Save via Rust to Downloads folder
|
||
const time = (session.start_time || "").replace(/:/g, "-");
|
||
const filename = `baldav-${session.date}_${time}-${session.activity_type || "track"}.gpx`;
|
||
try {
|
||
const path = await window.__TAURI__.core.invoke("save_file", { filename, content: gpx });
|
||
console.log("[GPX] Saved to:", path);
|
||
} catch (e) {
|
||
console.error("[GPX] Save error:", e);
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Recalculate session stats using physics-based formula
|
||
// ---------------------------------------------------------------------------
|
||
GPS.recalculateSession = async function(sessionId, opts) {
|
||
opts = opts || {};
|
||
const session = await DB.getGpsSession(sessionId);
|
||
if (!session) return null;
|
||
const points = await DB.getGpsPoints(sessionId);
|
||
if (points.length < 2) return null;
|
||
|
||
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
|
||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||
|
||
// Manual routes: skip glitch filtering (points are user-placed, not GPS).
|
||
// Just fix timestamps and recompute stats.
|
||
if (session.source === "manual") {
|
||
const isWalkM = session.activity_type === "walk" || session.activity_type === "run";
|
||
const avgSpeedMs = isWalkM
|
||
? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6
|
||
: GPS.config.bikeAvgSpeedKmh / 3.6;
|
||
let cumTime = 0;
|
||
const baseTs = new Date(sorted[0].ts).getTime();
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
if (i > 0) {
|
||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||
cumTime += d / avgSpeedMs;
|
||
}
|
||
const newTs = new Date(baseTs + cumTime * 1000).toISOString();
|
||
await invoke("UPDATE gps_points SET ts=?1, speed_ms=?2, seq=?3 WHERE session_id=?4 AND id=?5",
|
||
[newTs, avgSpeedMs, i + 1, sessionId, sorted[i].id]);
|
||
sorted[i].ts = newTs;
|
||
sorted[i].speed_ms = avgSpeedMs;
|
||
}
|
||
// Compute stats and finish
|
||
const latestW = await DB.getLatestWeight();
|
||
const profile = await DB.getFullProfile();
|
||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
||
const stats = GPS.computeStats(sorted, weightKg, {
|
||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||
activityType: session.activity_type ?? "bike",
|
||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
|
||
});
|
||
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
|
||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||
return stats;
|
||
}
|
||
|
||
// --- Recorded sessions: glitch filtering ---
|
||
const isWalk = session.activity_type === "walk" || session.activity_type === "run";
|
||
const maxMs = (isWalk ? GPS.config.walkMaxSpeedKmh : GPS.config.maxSpeedKmh) / 3.6;
|
||
|
||
// Glitch filtering DELETES points. It is destructive and MUST be skipped for
|
||
// recovery (opts.noDelete): if a wrong-date file ever slips in, the ">24h"
|
||
// rule below would wipe the real track. Recovery only adds points, never deletes.
|
||
let deleted = 0;
|
||
if (!opts.noDelete) {
|
||
// First pass: remove points with absurd timestamps (>1 day jump)
|
||
if (sorted.length >= 3) {
|
||
const sessionStart = new Date(sorted[0].ts).getTime();
|
||
const maxDuration = 24 * 3600 * 1000; // 24 hours max session
|
||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||
const t = new Date(sorted[i].ts).getTime();
|
||
if (t - sessionStart > maxDuration || t < sessionStart - 60000) {
|
||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||
sorted.splice(i, 1);
|
||
deleted++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second pass: speed + accel + altitude filter
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||
const speedMs = dt > 0 ? d / dt : 0;
|
||
const accel = sorted[i].accel_rms;
|
||
|
||
// Altitude jump: >100m between consecutive points = impossible
|
||
const alt = sorted[i].altitude_gps_m;
|
||
const prevAlt = sorted[i-1].altitude_gps_m;
|
||
const altJump = (alt != null && prevAlt != null) ? Math.abs(alt - prevAlt) : 0;
|
||
|
||
// Glitch: speed over max, accel=0 but moving, fast+walking-accel, or >100m alt jump.
|
||
const isGlitch =
|
||
(dt > 0 && speedMs > maxMs) ||
|
||
(accel != null && accel === 0 && speedMs > 3) ||
|
||
(isWalk && accel != null && accel < 15 && speedMs > maxMs * 0.8) ||
|
||
(altJump > 100);
|
||
|
||
if (isGlitch) {
|
||
await invoke("DELETE FROM gps_points WHERE session_id = ?1 AND ts = ?2", [sessionId, sorted[i].ts]);
|
||
sorted.splice(i, 1);
|
||
i--;
|
||
deleted++;
|
||
}
|
||
}
|
||
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
|
||
}
|
||
|
||
// Interpolate gaps where accelerometer shows movement
|
||
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
|
||
let interpolated = 0;
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
const dt = (new Date(sorted[i].ts) - new Date(sorted[i-1].ts)) / 1000;
|
||
if (dt < 30 || dt > 3600) continue; // only gaps 30s - 1h
|
||
const accelBefore = sorted[i-1].accel_rms;
|
||
const accelAfter = sorted[i].accel_rms;
|
||
// Both ends show movement → person was moving during gap
|
||
if (accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) {
|
||
// Add interpolated distance to total (will be counted by computeStats naturally
|
||
// since the gap points are adjacent and haversine gives straight-line distance)
|
||
// Mark gap as "moving" by ensuring speed_ms is set
|
||
interpolated++;
|
||
}
|
||
}
|
||
if (interpolated) console.log(`[GPS] Found ${interpolated} gaps with movement (accel-confirmed)`);
|
||
|
||
// Fix manual routes: timestamps are fake (1s apart), recalculate based on realistic speed
|
||
if (session.source === "manual" || sorted.length >= 2) {
|
||
const t0 = new Date(sorted[0].ts).getTime();
|
||
const tN = new Date(sorted[sorted.length - 1].ts).getTime();
|
||
const totalTimeMs = tN - t0;
|
||
// Compute total distance
|
||
let totalDist = 0;
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
totalDist += GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||
}
|
||
const impliedSpeedMs = totalTimeMs > 0 ? (totalDist / (totalTimeMs / 1000)) : 0;
|
||
// If implied speed is absurd (>30 m/s for walk, >50 for bike) → fix timestamps
|
||
const saneMaxMs = isWalk ? 5 : 15; // 18 km/h walk, 54 km/h bike
|
||
if (impliedSpeedMs > saneMaxMs && totalDist > 100) {
|
||
const avgSpeedMs = isWalk
|
||
? (session.activity_type === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6
|
||
: GPS.config.bikeAvgSpeedKmh / 3.6;
|
||
let cumTime = 0;
|
||
const baseTs = new Date(sorted[0].ts).getTime();
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
if (i > 0) {
|
||
const d = GPS.distance(sorted[i-1].lat, sorted[i-1].lon, sorted[i].lat, sorted[i].lon);
|
||
cumTime += d / avgSpeedMs;
|
||
}
|
||
const newTs = new Date(baseTs + cumTime * 1000).toISOString();
|
||
await invoke("UPDATE gps_points SET ts = ?1, speed_ms = ?2, seq = ?3 WHERE session_id = ?4 AND ts = ?5",
|
||
[newTs, avgSpeedMs, i + 1, sessionId, sorted[i].ts]);
|
||
sorted[i].ts = newTs;
|
||
sorted[i].speed_ms = avgSpeedMs;
|
||
}
|
||
console.log(`[GPS] Fixed manual route timestamps: ${sorted.length} pts, avg ${avgSpeedMs} m/s`);
|
||
} else if (deleted > 0) {
|
||
// Just re-assign seq
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
await invoke("UPDATE gps_points SET seq = ?1 WHERE session_id = ?2 AND ts = ?3", [i + 1, sessionId, sorted[i].ts]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get user weight: latest weight log, or profile, or 80
|
||
const latestW = await DB.getLatestWeight();
|
||
const profile = await DB.getFullProfile();
|
||
const weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
|
||
|
||
// Get bike if linked to session, otherwise use active bike
|
||
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
|
||
|
||
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
|
||
|
||
// Use cleaned sorted array, not original points
|
||
const stats = GPS.computeStats(sorted, weightKg, {
|
||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||
activityType: session.activity_type ?? "bike",
|
||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
|
||
chainringTeeth: bike?.chainring_teeth,
|
||
cogTeeth: bike?.cog_teeth,
|
||
wheelDiameterInch: bike?.wheel_diameter_inch,
|
||
});
|
||
|
||
// Update session in DB
|
||
await DB.finishGpsSession(sessionId,
|
||
session.end_time ?? session.start_time,
|
||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
|
||
stats.elevGain, stats.elevLoss
|
||
);
|
||
|
||
return stats;
|
||
};
|
||
|
||
// 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 stats = GPS.computeStats(points, weightKg, {
|
||
bikeWeightKg: bike?.weight_kg ?? 0,
|
||
activityType: session.activity_type ?? "bike",
|
||
surfaceCrr: CRR_MAP[bike?.surface_type] ?? 0.006,
|
||
tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
|
||
chainringTeeth: bike?.chainring_teeth,
|
||
cogTeeth: bike?.cog_teeth,
|
||
wheelDiameterInch: bike?.wheel_diameter_inch,
|
||
});
|
||
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
|
||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
|
||
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;
|
||
});
|
||
};
|
||
|
||
window.GPS = GPS;
|
||
console.log("[gps.js] window.GPS ready");
|