diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 9b667a7..670e282 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1095,13 +1095,15 @@ select { cursor: pointer; } .rec-map-btn.active { - background: rgba(187, 134, 252, 0.4); - border-color: var(--primary); + background: rgba(255, 255, 255, 0.5); + color: #1a1a1a; + border-color: rgba(255, 255, 255, 0.6); } .rec-rotate-btn { right: 8px; bottom: 48px; font-size: 18px; transition: transform 0.15s; } .rec-freeze-btn { right: 8px; bottom: 86px; } .rec-roads-toggle { right: 8px; bottom: 124px; } .rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; } +.rec-route-toggle { right: 8px; bottom: 200px; } /* Always-on compass needle: red half = North, grey = South. Rotates to point to real north. Top-RIGHT (zoom controls are top-left), below the status-bar safe area. */ .rec-compass { @@ -1395,6 +1397,27 @@ select { font-size: 18px; box-shadow: 0 2px 6px rgba(0,0,0,0.3); } +.sd-map-fs-btn.active { + background: rgba(255, 255, 255, 0.5); + color: #1a1a1a; + border-color: rgba(255, 255, 255, 0.6); +} +.sd-smooth-panel { + position: fixed; + z-index: 10001; + left: 8px; + bottom: 8px; + width: 210px; + background: rgba(20, 20, 20, 0.93); + color: #eee; + font-size: 12px; + padding: 8px 10px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); +} +.sd-smooth-panel label { display: block; margin: 6px 0 2px; } +.sd-smooth-panel input[type=range] { width: 100%; } +.sd-smooth-panel .sm-stat { margin: 4px 0; color: #9be89b; } .zoom-display { background: rgba(0,0,0,0.6); diff --git a/frontend/js/db.js b/frontend/js/db.js index 0d4bef2..23114b8 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -702,7 +702,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat ["bp_sys", bleData?.bp_sys], ["bp_dia", bleData?.bp_dia], ["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max], ["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw], - ["bearing_deg", sensorData?.bearing], + ["bearing_deg", sensorData?.bearing], ["accuracy_m", sensorData?.accuracy], ]; for (const [col, val] of extra) { if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); } @@ -722,7 +722,7 @@ async function addGpsPointsBulk(sessionId, points, startSeq) { catch { _gpsPointCols = new Set(["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]); } } const base = ["session_id","ts","lat","lon","altitude_gps_m","speed_ms","seq"]; - const extra = ["accel_rms","accel_max","gyro_pitch","gyro_yaw","bearing_deg"].filter(c => _gpsPointCols.has(c)); + const extra = ["accel_rms","accel_max","gyro_pitch","gyro_yaw","bearing_deg","accuracy_m"].filter(c => _gpsPointCols.has(c)); const allCols = base.concat(extra); const perRow = allCols.length; const maxRows = Math.max(1, Math.floor(900 / perRow)); // stay under SQLite bind-var limit @@ -736,7 +736,7 @@ async function addGpsPointsBulk(sessionId, points, startSeq) { for (const p of chunk) { seq++; const row = [sessionId, p.ts, p.lat, p.lon, p.altitude ?? null, p.speed_ms ?? null, seq]; - for (const c of extra) row.push(c === "bearing_deg" ? (p.bearing ?? null) : (p[c] ?? null)); + for (const c of extra) row.push(c === "bearing_deg" ? (p.bearing ?? null) : c === "accuracy_m" ? (p.accuracy ?? null) : (p[c] ?? null)); rowSql.push("(" + row.map(() => `?${ph++}`).join(",") + ")"); vals.push(...row); } @@ -757,6 +757,24 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa ); } +// Persist a de-jittered (smoothed) variant: write lat_smoothed/lon_smoothed per +// point (original lat/lon untouched) and flag the session. Reversible via +// setSessionSmoothed(id, false). `smoothedPoints` carry each point's `id` + the +// smoothed `lat`/`lon`. +async function saveSmoothedTrack(sessionId, smoothedPoints, window, iters, onProgress) { + for (let i = 0; i < smoothedPoints.length; i++) { + const p = smoothedPoints[i]; + if (p.id == null) continue; + await execute("UPDATE gps_points SET lat_smoothed=?1, lon_smoothed=?2 WHERE id=?3", [p.lat, p.lon, p.id]); + if (onProgress && (i % 100 === 0 || i === smoothedPoints.length - 1)) { try { onProgress(i + 1, smoothedPoints.length); } catch {} } + } + await execute("UPDATE gps_sessions SET smoothed=1, smooth_window=?1, smooth_iters=?2 WHERE id=?3", [window, iters, sessionId]); +} + +async function setSessionSmoothed(sessionId, on) { + await execute("UPDATE gps_sessions SET smoothed=?1 WHERE id=?2", [on ? 1 : 0, sessionId]); +} + // NOTE: tauri-plugin-sql corrupts large i64 ids when returned as JS numbers // (off by ±1). Always read/pass session ids via the text `id_str` column. async function getGpsSessions(limit, offset) { @@ -957,6 +975,47 @@ async function deleteGoalMarker(id) { await execute("DELETE FROM goal_markers WHERE id = ?1", [id]); } +// --------------------------------------------------------------------------- +// Saved routes (hand-drawn reference paths overlaid on the map) +// --------------------------------------------------------------------------- +async function getRoutes() { + // Routes are few and small — load the blob to report a point count, drop it. + const rows = await select("SELECT id, name, color, created_at, points_json FROM routes ORDER BY created_at DESC"); + return rows.map(r => { + let n = 0; try { n = (JSON.parse(r.points_json) || []).length; } catch {} + return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, pts: n }; + }); +} + +async function getRoute(id) { + const rows = await select("SELECT * FROM routes WHERE id = ?1", [id]); + if (!rows.length) return null; + const r = rows[0]; + let points = []; + try { points = JSON.parse(r.points_json) || []; } catch {} + return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, points }; +} + +async function addRoute(name, color, points) { + const r = await execute( + "INSERT INTO routes (name, color, points_json) VALUES (?1, ?2, ?3)", + [name, color || "#e53935", JSON.stringify(points || [])] + ); + return r.lastInsertId; +} + +async function renameRoute(id, name) { + await execute("UPDATE routes SET name = ?1 WHERE id = ?2", [name, id]); +} + +async function setRouteColor(id, color) { + await execute("UPDATE routes SET color = ?1 WHERE id = ?2", [color, id]); +} + +async function deleteRoute(id) { + await execute("DELETE FROM routes WHERE id = ?1", [id]); +} + // --------------------------------------------------------------------------- // Food cost helper — most frequently eaten foods in last 7 days // --------------------------------------------------------------------------- @@ -1070,6 +1129,8 @@ window.DB.createGpsSession = createGpsSession; window.DB.addGpsPoint = addGpsPoint; window.DB.addGpsPointsBulk = addGpsPointsBulk; window.DB.finishGpsSession = finishGpsSession; +window.DB.saveSmoothedTrack = saveSmoothedTrack; +window.DB.setSessionSmoothed = setSessionSmoothed; window.DB.getGpsSessions = getGpsSessions; window.DB.getGpsSessionsForDate = getGpsSessionsForDate; window.DB.getGpsSession = getGpsSession; @@ -1105,6 +1166,12 @@ window.DB.getBikeMaintenanceDue = getBikeMaintenanceDue; window.DB.getGoalMarkers = getGoalMarkers; window.DB.addGoalMarker = addGoalMarker; window.DB.deleteGoalMarker = deleteGoalMarker; +window.DB.getRoutes = getRoutes; +window.DB.getRoute = getRoute; +window.DB.addRoute = addRoute; +window.DB.renameRoute = renameRoute; +window.DB.setRouteColor = setRouteColor; +window.DB.deleteRoute = deleteRoute; window.DB.getFrequentFoods = getFrequentFoods; window.DB.getGoalProgress = getGoalProgress; @@ -1284,6 +1351,9 @@ async function runMigrations() { if (!gpsCols.includes("gyro_pitch")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_pitch REAL"); if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL"); if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL"); + if (!gpsCols.includes("accuracy_m")) await execute("ALTER TABLE gps_points ADD COLUMN accuracy_m REAL"); + if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL"); + if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL"); // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( @@ -1394,6 +1464,11 @@ async function runMigrations() { // Add bike_id column to gps_sessions const gsCols = (await select("PRAGMA table_info(gps_sessions)")).map(r => r.name); if (!gsCols.includes("bike_id")) await execute("ALTER TABLE gps_sessions ADD COLUMN bike_id INTEGER"); + // Track de-jitter: reversible smoothing (points keep both original lat/lon and + // lat_smoothed/lon_smoothed; `smoothed`=1 means the smoothed track is active). + if (!gsCols.includes("smoothed")) await execute("ALTER TABLE gps_sessions ADD COLUMN smoothed INTEGER DEFAULT 0"); + if (!gsCols.includes("smooth_window")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_window INTEGER"); + if (!gsCols.includes("smooth_iters")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_iters INTEGER"); // Add tire_pressure_bar to bikes (for Crr correction by tire pressure) const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name); @@ -1432,6 +1507,15 @@ async function runMigrations() { created_at TEXT DEFAULT (datetime('now')) )`); + // --- Saved routes (hand-drawn reference paths overlaid on the map) --- + await execute(`CREATE TABLE IF NOT EXISTS routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + color TEXT DEFAULT '#e53935', + points_json TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + )`); + // --- Supplement columns migration: add missing micronutrient columns --- const suppCols = (await select("PRAGMA table_info(supplements)")).map(r => r.name); const addSuppCol = async (col, type) => { if (!suppCols.includes(col)) await execute(`ALTER TABLE supplements ADD COLUMN ${col} ${type}`); }; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index a591d24..c4d4ab3 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -23,6 +23,9 @@ GPS.config = { useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps. // raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть. autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец») + roadColorWalk: '#8B5E3C', // цвет наложения проезженных/пройденных дорог: ходьба + roadColorBike: '#2266aa', // ... вело + roadColorRun: '#e67e22', // ... бег }; // Load saved config @@ -451,6 +454,42 @@ GPS.computeStats = function(points, weightKg, opts) { }; }; +// --------------------------------------------------------------------------- +// Savitzky-Golay track smoothing (de-jitter). Quadratic least-squares fit of +// x(i) and y(i) over a window, evaluated at the centre, iterated. Removes GPS +// sideways jitter while keeping real corners better than a plain moving average. +// Returns NEW point objects with smoothed lat/lon; all other fields preserved. +// Endpoints anchored. window = points on each side, iters = passes. +// --------------------------------------------------------------------------- +GPS.smoothTrackSG = function(points, window, iters) { + const n = points.length; + if (n < 5) return points.map(p => ({ ...p })); + const W = Math.max(1, window || 4), IT = Math.max(1, iters || 3); + const lat0 = points[0].lat * Math.PI / 180, mLat = 111320, mLon = 111320 * Math.cos(lat0); + let xy = points.map(p => ({ x: (p.lon - points[0].lon) * mLon, y: (p.lat - points[0].lat) * mLat })); + const det3 = m => + m[0][0]*(m[1][1]*m[2][2]-m[1][2]*m[2][1]) - m[0][1]*(m[1][0]*m[2][2]-m[1][2]*m[2][0]) + m[0][2]*(m[1][0]*m[2][1]-m[1][1]*m[2][0]); + const pass = a => { + const out = a.map(p => ({ x: p.x, y: p.y })); + for (let i = 0; i < a.length; i++) { + const s = Math.max(0, i - W), e = Math.min(a.length - 1, i + W); + const fit = get => { + let S0=0,S1=0,S2=0,S3=0,S4=0,T0=0,T1=0,T2=0; + for (let j = s; j <= e; j++) { const t = j - i, v = get(a[j]); S0+=1;S1+=t;S2+=t*t;S3+=t*t*t;S4+=t*t*t*t;T0+=v;T1+=v*t;T2+=v*t*t; } + const M=[[S0,S1,S2],[S1,S2,S3],[S2,S3,S4]], D=det3(M); + if (Math.abs(D) < 1e-9) return get(a[i]); + const M0=[[T0,M[0][1],M[0][2]],[T1,M[1][1],M[1][2]],[T2,M[2][1],M[2][2]]]; + return det3(M0)/D; + }; + out[i] = { x: fit(p => p.x), y: fit(p => p.y) }; + } + out[0] = a[0]; out[a.length-1] = a[a.length-1]; + return out; + }; + for (let k = 0; k < IT; k++) xy = pass(xy); + return points.map((p, i) => ({ ...p, lat: points[0].lat + xy[i].y / mLat, lon: points[0].lon + xy[i].x / mLon })); +}; + // --------------------------------------------------------------------------- // Speed smoothing — 3-point moving average // --------------------------------------------------------------------------- @@ -1195,6 +1234,31 @@ GPS.parseGPX = function(xmlString) { return { points, activityType: actType }; }; +// Parse a GPX file as a REFERENCE ROUTE (a line to overlay on the map), not an +// activity. Accepts (Yandex Map Constructor), or — whichever +// the file uses. Returns { name, points:[{lat,lon}] }. +GPS.parseRouteGPX = function(xmlString) { + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlString, "application/xml"); + const points = []; + for (const tag of ["rtept", "trkpt", "wpt"]) { + const els = doc.querySelectorAll(tag); + if (els.length) { + els.forEach(pt => { + const lat = parseFloat(pt.getAttribute("lat")); + const lon = parseFloat(pt.getAttribute("lon")); + if (!isNaN(lat) && !isNaN(lon)) points.push({ lat, lon }); + }); + break; // first tag type that has points wins + } + } + let name = ""; + const nameEl = doc.querySelector("metadata > name") || doc.querySelector("rte > name") + || doc.querySelector("trk > name") || doc.querySelector("name"); + if (nameEl) name = (nameEl.textContent || "").trim(); + return { name, points }; +}; + 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"); diff --git a/frontend/js/main.js b/frontend/js/main.js index c6b57aa..f8cc840 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2373,6 +2373,7 @@ async function renderGpsScreen(container) {
Нарисовать маршрут
Скачать карту
Импорт тренировки
+
Сохранённые маршруты
Восстановить точки из файлов
Пересчитать высоту/калории
Очистить кэш карт
@@ -2400,6 +2401,8 @@ async function renderGpsScreen(container) { } else { let type = container.querySelector("#gps-type").value; localStorage.setItem("baldav_gps_type", type); + // A fresh start shows the selected route by default (overriding a prior toggle-off). + if (localStorage.getItem("baldav_active_route")) localStorage.setItem("baldav_route_shown", "1"); let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; // Get active bike for bike sessions — store bike data in recording state if (type === "bike") { @@ -2457,6 +2460,11 @@ async function renderGpsScreen(container) { // Download map tiles — region picker container.querySelector("#gps-draw-route").onclick = () => showDrawRouteScreen(container); + container.querySelector("#gps-routes").onclick = () => { + menu.classList.add("hidden"); + showRoutesScreen(container); + }; + container.querySelector("#gps-download-map").onclick = () => { menu.classList.add("hidden"); showMapDownloadModal(); @@ -2734,6 +2742,124 @@ async function fetchElevations(points) { return elevations.some(x => x != null) ? elevations : null; } +// Colour for the roads overlay by activity type (road_points.atype holds the full +// activity_type: "walk" / "bike" / "run"). Configurable in GPS settings. +function roadColor(atype) { + // get_road_points (Rust) returns atype as a single letter: "w" walk, "r" run, "b" bike/other. + let c = GPS.config; + if (atype === "w") return c.roadColorWalk || "#8B5E3C"; + if (atype === "r") return c.roadColorRun || "#e67e22"; + return c.roadColorBike || "#2266aa"; +} + +// Resolve once the Leaflet map has finished its initial layout, so layers added +// after this actually render (adding a polyline before the map is loaded/sized +// leaves it invisible until the next redraw). Awaits the real 'load' event — the +// single event→await bridge — then forces a size recalc. +async function whenMapReady(map) { + if (!map._loaded) await new Promise(res => map.once("load", res)); + map.invalidateSize(); +} + +// Draw the active saved route (localStorage baldav_active_route) as a colored line +// on a Leaflet map. Removes `prev` first. Returns the new layer, or null if none. +async function drawActiveRouteOverlay(map, prev) { + if (prev) { try { map.removeLayer(prev); } catch {} } + let id = localStorage.getItem("baldav_active_route"); + if (!id) return null; + let route = await DB.getRoute(id); + if (!route || !route.points || route.points.length < 2) return null; + return L.polyline(route.points.map(p => [p.lat, p.lon]), + { color: route.color || "#e53935", weight: 4, opacity: 0.85, interactive: false }).addTo(map); +} + +// Saved routes management page (import GPX, rename, color, delete, pick active). +// Replaces the GPS container like showDrawRouteScreen; back returns to the GPS screen. +async function showRoutesScreen(container) { + let active = localStorage.getItem("baldav_active_route") || ""; + + async function render() { + let routes = await DB.getRoutes(); + let rows = routes.map(r => ` +
+ + + + +
`).join(""); + + container.innerHTML = ` +
+
+ +

Сохранённые маршруты

+ +
+ + +
${routes.length ? rows : `
Нет маршрутов. Импортируйте GPX-файл — линию можно нарисовать заранее (напр. в Яндекс.Конструкторе карт).
`}
+
`; + + container.querySelector("#routes-back").onclick = () => renderScreen("gps"); + + container.querySelector("#route-none").onchange = () => { + active = ""; localStorage.removeItem("baldav_active_route"); + showToast("Маршрут отключён"); + }; + + container.querySelector("#routes-import").onclick = () => { + pickFile(".gpx", async (text, file) => { + let parsed; + try { parsed = GPS.parseRouteGPX(text); } catch (e) { showToast("Ошибка чтения GPX"); return; } + if (!parsed.points || parsed.points.length < 2) { showToast("В GPX нет линии маршрута"); return; } + let defName = (parsed.name || (file.name || "маршрут")).replace(/\.gpx$/i, "").trim(); + let name = prompt("Название маршрута:", defName); + if (name == null) return; + name = name.trim() || defName; + await DB.addRoute(name, "#e53935", parsed.points); + showToast(`Импортирован: ${name} (${parsed.points.length} тчк)`); + render(); + }); + }; + + container.querySelectorAll(".route-row").forEach(row => { + let id = row.getAttribute("data-id"); + let curName = row.getAttribute("data-name"); + row.querySelector("input[type=radio]").onchange = () => { + active = id; + localStorage.setItem("baldav_active_route", id); + localStorage.setItem("baldav_route_shown", "1"); // selecting a route defaults its overlay ON + showToast("Маршрут выбран активным"); + }; + row.querySelector(".route-color").onchange = async (e) => { + await DB.setRouteColor(id, e.target.value); + row.querySelector(".route-swatch").style.background = e.target.value; + }; + row.querySelector(".route-rename").onclick = async () => { + let name = prompt("Новое название:", curName); + if (name == null || !name.trim()) return; + await DB.renameRoute(id, name.trim()); + render(); + }; + row.querySelector(".route-del").onclick = async () => { + if (!confirm(`Удалить маршрут «${curName}»?`)) return; + await DB.deleteRoute(id); + if (active === id) { active = ""; localStorage.removeItem("baldav_active_route"); } + render(); + }; + }); + } + + render(); +} + function showDrawRouteScreen(container) { let points = []; // {lat, lon} let markers = []; @@ -2892,7 +3018,7 @@ function showDrawRouteScreen(container) { }, 200); } -function renderRecordingInline(container) { +async function renderRecordingInline(container) { if (!GPS.recording.active) { renderScreen("gps"); return; } // Keep screen on while recording is visible @@ -3132,7 +3258,7 @@ function renderRecordingInline(container) { let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180); if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) { if (seg.length >= 2) { - let color = seg[0].atype === "w" ? "#8B5E3C" : "#2266aa"; + let color = roadColor(seg[0].atype); roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6 }).addTo(map)); } seg = []; @@ -3141,7 +3267,7 @@ function renderRecordingInline(container) { seg.push(pts[i]); } if (seg.length >= 2) { - let color = seg[0].atype === "w" ? "#8B5E3C" : "#2266aa"; + let color = roadColor(seg[0].atype); roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6 }).addTo(map)); } }; @@ -3149,6 +3275,28 @@ function renderRecordingInline(container) { // Auto-load roads if was enabled if (localStorage.getItem("baldav_show_roads") === "1") roadsBtn.click(); + // Saved-route overlay TOGGLE (like the roads toggle). Shows/hides the active + // route (chosen in "Сохранённые маршруты"). Handy to hide it if you rode off-plan + // but don't want to stop the workout. + let routeBtn = document.createElement("button"); + routeBtn.className = "rec-map-btn rec-route-toggle"; + routeBtn.textContent = "М"; + let routeLayer = null; + async function setRouteShown(shown) { + localStorage.setItem("baldav_route_shown", shown ? "1" : "0"); + if (shown) { + routeLayer = await drawActiveRouteOverlay(map, routeLayer); + try { borderLine.bringToFront(); trailLine.bringToFront(); } catch {} + } else if (routeLayer) { + map.removeLayer(routeLayer); routeLayer = null; + } + routeBtn.classList.toggle("active", !!routeLayer); + } + routeBtn.onclick = () => setRouteShown(!routeLayer); + btnWrap.appendChild(routeBtn); + // The default-ON draw is deferred to the END of this function (after all the + // critical wiring), where it awaits whenMapReady — so it can't delay setup. + function handleOrientation(e) { let raw; if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading @@ -3398,6 +3546,12 @@ function renderRecordingInline(container) { trailLine.addLatLng(ll); }; + // Finally: if a route is selected (and not toggled off), draw it once the map is + // ready. Done LAST so awaiting map-load never delays the compass/handlers/buttons. + if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") { + await whenMapReady(map); + await setRouteShown(true); + } } function setupLiveUpdates(container) { @@ -3568,6 +3722,9 @@ async function showSessionDetail(sessionId) { let rawPoints = await DB.getGpsPoints(sessionId); console.log("[SD] got", rawPoints.length, "raw points"); let points = GPS.filterNoise(rawPoints, session.activity_type); + // If this track has a saved smoothed (de-jittered) variant active, display it — + // rawPoints keeps the original lat/lon (for re-tuning / revert). + if (session.smoothed) points = points.map(p => (p.lat_smoothed != null ? { ...p, lat: p.lat_smoothed, lon: p.lon_smoothed } : p)); console.log("[SD] filtered to", points.length, "points"); // Use stored session stats if available (computed correctly at import time), // fallback to recomputing from points @@ -3747,6 +3904,98 @@ async function showSessionDetail(sessionId) { let CachedLayer = TileCache.createLayer(); new CachedLayer({ maxZoom: 19 }).addTo(map); + // Active saved-route overlay on the detail map + a toggle button (default ON when + // a route is selected). Shares baldav_route_shown with the recording screen. + let sdRouteLayer = null, sdRouteDiv = null; + async function sdSetRoute(shown) { + localStorage.setItem("baldav_route_shown", shown ? "1" : "0"); + if (shown) sdRouteLayer = await drawActiveRouteOverlay(map, sdRouteLayer); + else if (sdRouteLayer) { map.removeLayer(sdRouteLayer); sdRouteLayer = null; } + if (sdRouteDiv) sdRouteDiv.classList.toggle("active", !!sdRouteLayer); + } + if (localStorage.getItem("baldav_active_route")) { + let routeCtl = L.control({ position: "topleft" }); + routeCtl.onAdd = () => { + sdRouteDiv = L.DomUtil.create("div", "sd-map-fs-btn"); + sdRouteDiv.innerHTML = "М"; + sdRouteDiv.title = "Маршрут"; + sdRouteDiv.onclick = (e) => { e.stopPropagation(); sdSetRoute(!sdRouteLayer); }; + return sdRouteDiv; + }; + routeCtl.addTo(map); + // Initial draw is deferred to after the track is fitted (below), so it awaits + // a ready map and doesn't delay the track rendering. + } + + // --- De-jitter (Savitzky-Golay) tuning panel: preview + save/revert --- + let smParams = { W: session.smooth_window || 4, it: session.smooth_iters || 3 }; + let smPreview = null, smPanel = null, smBtnDiv = null; + let smSetActive = on => { if (smBtnDiv) smBtnDiv.classList.toggle("active", on); }; + let drawPreview = () => { + let sm = GPS.smoothTrackSG(rawPoints, smParams.W, smParams.it); + if (smPreview) map.removeLayer(smPreview); + smPreview = L.polyline(sm.map(p => [p.lat, p.lon]), { color: "#00e5ff", weight: 3, opacity: 0.95 }).addTo(map); + let d = 0; for (let i = 1; i < sm.length; i++) d += GPS.distance(sm[i-1].lat, sm[i-1].lon, sm[i].lat, sm[i].lon); + if (smPanel) smPanel.querySelector("#sm-stat").textContent = "Сглаж.: " + (d / 1000).toFixed(2) + " км"; + return sm; + }; + async function recomputeStatsFor(pts) { + let latestW = await DB.getLatestWeight(), profile = await DB.getFullProfile(); + let weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; + let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); + let CRR = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; + let st = GPS.computeStats(pts, weightKg, { + bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", + surfaceCrr: CRR[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(sessionId, session.end_time ?? session.start_time, + st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss); + } + let toggleSmoothPanel = () => { + if (smPanel) { smPanel.remove(); smPanel = null; if (smPreview) { map.removeLayer(smPreview); smPreview = null; } smSetActive(false); return; } + smSetActive(true); + smPanel = document.createElement("div"); + smPanel.className = "sd-smooth-panel"; + smPanel.innerHTML = `Сглаживание (SG) + + +
+ + ${session.smoothed ? '' : ''}`; + mapEl.parentElement.appendChild(smPanel); + drawPreview(); + smPanel.querySelector("#sm-w").oninput = e => { smParams.W = +e.target.value; smPanel.querySelector("#sm-wv").textContent = smParams.W; drawPreview(); }; + smPanel.querySelector("#sm-i").oninput = e => { smParams.it = +e.target.value; smPanel.querySelector("#sm-iv").textContent = smParams.it; drawPreview(); }; + smPanel.querySelector("#sm-save").onclick = async () => { + if (!confirm(`Сохранить сглаженный трек (окно ${smParams.W}, итераций ${smParams.it})? Оригинал сохранится, можно откатить.`)) return; + let btn = smPanel.querySelector("#sm-save"); btn.disabled = true; + let sm = GPS.smoothTrackSG(rawPoints, smParams.W, smParams.it); + await DB.saveSmoothedTrack(sessionId, sm, smParams.W, smParams.it, (i, n) => { btn.textContent = `Сохранение ${i}/${n}`; }); + await recomputeStatsFor(sm); + showToast("Сглаженный трек сохранён"); + showSessionDetail(sessionId); + }; + let rev = smPanel.querySelector("#sm-revert"); + if (rev) rev.onclick = async () => { + rev.disabled = true; + await DB.setSessionSmoothed(sessionId, false); + await recomputeStatsFor(rawPoints); + showToast("Возвращён оригинал"); + showSessionDetail(sessionId); + }; + }; + let smCtl = L.control({ position: "topleft" }); + smCtl.onAdd = () => { + smBtnDiv = L.DomUtil.create("div", "sd-map-fs-btn"); + smBtnDiv.innerHTML = "≈"; + smBtnDiv.title = "Сглаживание трека"; + smBtnDiv.classList.toggle("active", !!session.smoothed); + smBtnDiv.onclick = e => { e.stopPropagation(); toggleSmoothPanel(); }; + return smBtnDiv; + }; + smCtl.addTo(map); + // Sort by timestamp, build coords and draw in one pass // Keep original order from DB (seq from FTB import = XML order = clean track) let t0 = new Date(points[0].ts).getTime(); @@ -3824,6 +4073,13 @@ async function showSessionDetail(sessionId) { } mapEl._bounds = allCoords.length > 1 ? L.latLngBounds(allCoords).pad(0.1) : null; + // Draw the active route (default ON) now that the map is sized & fitted — after + // the track, awaiting a ready map so the line actually renders. + if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") { + await whenMapReady(map); + await sdSetRoute(true); + } + // Fullscreen toggle button on map // Fullscreen map toggle function toggleMapFs() { @@ -4962,6 +5218,18 @@ async function renderSettings(container) { +
+
Цвет дорог: вело
+ +
+
+
Цвет дорог: ходьба
+ +
+
+
Цвет дорог: бег
+ +
Fused-локация (Google Play)
@@ -5218,11 +5486,14 @@ async function renderSettings(container) { GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5; GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12; GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6; + GPS.config.roadColorBike = container.querySelector("#s-road-color-bike").value; + GPS.config.roadColorWalk = container.querySelector("#s-road-color-walk").value; + GPS.config.roadColorRun = container.querySelector("#s-road-color-run").value; let fusedEl = container.querySelector("#s-use-fused"); if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked; GPS.saveConfig(); }; - let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"]; + let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed", "s-road-color-bike", "s-road-color-walk", "s-road-color-run"]; for (let id of gpsInputs) { let el = container.querySelector("#" + id); if (el) { el.onchange = readGpsConfig; el.oninput = readGpsConfig; } @@ -6189,7 +6460,7 @@ async function showRoadsMap() { // Draw: bike = blue, walk = brown let allCoords = []; for (let s of segments) { - let color = s.atype === "w" ? "#8B5E3C" : "#2266aa"; + let color = roadColor(s.atype); L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map); allCoords.push(...s.coords); } @@ -6197,8 +6468,8 @@ async function showRoadsMap() { if (allCoords.length > 0) { map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); } - let bikeSegs = segments.filter(s => s.atype !== "w").length; let walkSegs = segments.filter(s => s.atype === "w").length; + let bikeSegs = segments.length - walkSegs; infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 149b72d..5675780 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -296,7 +296,7 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul }).map_err(|e| format!("query: {e}"))?; for row in rows { let (lat, lon, atype) = row.map_err(|e| format!("row: {e}"))?; - let t = if atype == "walk" { "w" } else { "b" }; + let t = if atype == "walk" { "w" } else if atype == "run" { "r" } else { "b" }; points.push(format!("[{:.7},{:.7},\"{}\"]", lat, lon, t)); } let has_more = offset + batch_size < total;