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
416 lines
15 KiB
JavaScript
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");
|