baldav/frontend/js/gps.js
4aiman 1affadb05d Initial commit: BalDav health tracker
Tauri 2 + pure JS + SQLite personal health tracking app.

Nutrition module:
- 9299 products in DB (Magnit SSR scraping + KDV)
- Product search with case-insensitive Cyrillic (name_lower column)
- Food logging from product DB or manual entry
- Supplement tracking with per-pill micronutrients
- Micronutrient % strip on Today screen

Fitness/GPS module:
- FitoTrack .ftb and GPX import with seq-based ordering
- Session detail view: map, speed/elevation charts (Chart.js)
- Offline tile caching (IndexedDB) with region download
- Track rendering with colored time-gradient points

UI:
- Fullscreen modals with back-button (history API)
- Dark theme, desktop-responsive layout
- 5 tabs: Today, Products, Track, Supplements, Profile

Tools:
- Magnit/KDV HTML parsers
- Dedup import with discrepancy detection
- FitoTrack backup analysis
2026-05-13 10:21:24 +03:00

416 lines
15 KiB
JavaScript

// GPS tracking module — recording, calculations, GPX import
// Exposes window.GPS
const GPS = {};
// ---------------------------------------------------------------------------
// Haversine distance (meters)
// ---------------------------------------------------------------------------
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));
};
// ---------------------------------------------------------------------------
// Kcal formula (FitoTrack-style MET based)
// kcal = minutes * (MET * 3.5 * weight_kg) / 200
// MET = speed_kmh * 0.97 (capped at minimum 1)
// + elevation_gain_m (1 kcal per meter ascent — rough estimate)
// ---------------------------------------------------------------------------
GPS.calcKcal = function(minutes, avgSpeedKmh, weightKg, elevGainM) {
const met = Math.max(avgSpeedKmh * 0.97, 1);
const base = minutes * (met * 3.5 * weightKg) / 200;
return Math.round(base + (elevGainM ?? 0));
};
// ---------------------------------------------------------------------------
// Compute session stats from array of {lat, lon, altitude, ts} points
// ---------------------------------------------------------------------------
GPS.computeStats = function(points, weightKg) {
if (points.length < 2) return { distance: 0, elevGain: 0, elevLoss: 0, avgSpeed: 0, maxSpeed: 0, kcal: 0, duration: 0 };
let distance = 0, elevGain = 0, elevLoss = 0;
const speeds = []; // m/s per segment
for (let i = 1; i < points.length; i++) {
const p = points[i], prev = points[i - 1];
const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon);
distance += d;
const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
speeds.push(dt > 0 ? d / dt : 0);
const alt = p.altitude ?? p.altitude_gps_m;
const prevAlt = prev.altitude ?? prev.altitude_gps_m;
if (alt != null && prevAlt != null) {
const diff = alt - prevAlt;
if (diff > 0) elevGain += diff;
else elevLoss += Math.abs(diff);
}
}
// 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(points[0].ts);
const last = new Date(points[points.length - 1].ts);
const duration = (last - first) / 1000;
const minutes = duration / 60;
const avgSpeed = duration > 0 ? (distance / duration) : 0;
const avgSpeedKmh = avgSpeed * 3.6;
const maxSpeedKmh = maxSpeed * 3.6;
const kcal = GPS.calcKcal(minutes, avgSpeedKmh, weightKg ?? 80, elevGain);
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,
duration: Math.round(duration),
};
};
// ---------------------------------------------------------------------------
// 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",
};
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 = new Date();
GPS.recording.weightKg = weightKg ?? 80;
const now = GPS.recording.startTime;
GPS.recording.sessionId = await DB.createGpsSession(
now.toISOString().slice(0, 10),
now.toTimeString().slice(0, 5),
GPS.recording.activityType
);
GPS.recording.watchId = navigator.geolocation.watchPosition(
async (pos) => {
const p = {
lat: pos.coords.latitude,
lon: pos.coords.longitude,
altitude: pos.coords.altitude,
speed_ms: pos.coords.speed,
ts: new Date(pos.timestamp).toISOString(),
};
GPS.recording.points.push(p);
await DB.addGpsPoint(
GPS.recording.sessionId, p.ts, p.lat, p.lon, p.altitude, p.speed_ms
);
if (GPS.recording.onPoint) GPS.recording.onPoint(p);
},
(err) => {
console.error("GPS error:", err.message);
if (GPS.recording.onError) GPS.recording.onError(err);
},
{ enableHighAccuracy: true, maximumAge: 2000, timeout: 10000 }
);
};
GPS.stopRecording = async function() {
if (!GPS.recording.active) return null;
GPS.recording.active = false;
if (GPS.recording.watchId != null) {
navigator.geolocation.clearWatch(GPS.recording.watchId);
GPS.recording.watchId = null;
}
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg);
const endTime = new Date().toTimeString().slice(0, 5);
await DB.finishGpsSession(
GPS.recording.sessionId, endTime,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss
);
const sid = GPS.recording.sessionId;
GPS.recording.sessionId = null;
GPS.recording.points = [];
return { sessionId: sid, stats };
};
// ---------------------------------------------------------------------------
// 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, 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 => ({
ts: p.ts, lat: p.lat, lon: p.lon, altitude: p.altitude, speed: p.speed,
}));
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 + " м";
};
window.GPS = GPS;
console.log("[gps.js] window.GPS ready");