Saved routes overlay + per-activity road colors + reversible track de-jitter

Routes:
- routes table + CRUD helpers (db.js), GPX rtept/trkpt/wpt parser (gps.js)
- "Saved routes" screen (import/rename/recolor/delete, one active at a time)
- Route overlay on both the recording map and the past-track detail map,
  toggled by an "M" button; default ON at Start when a route is selected
- Draw after real map-load via async whenMapReady(), no setTimeout

Road colors:
- per-activity configurable colors (walk/bike/run); fix walk roads (atype "w")
- get_road_points maps run -> "r" (lib.rs)

De-jitter (Savitzky-Golay):
- GPS.smoothTrackSG (quadratic LS per window, iterated, endpoints anchored)
- reversible storage: lat_smoothed/lon_smoothed + session.smoothed flag
- detail-screen tuning panel (window/iters sliders, live preview,
  save smoothed / revert original, kcal recomputed on new points)
- accuracy_m column per point for later accuracy-gated recompute

Active map-button state restyled to inverted (dark glyph on translucent
white) for OLED; detail map safe-area handled in fullscreen.
This commit is contained in:
4aiman 2026-07-31 22:11:55 +03:00
commit 9d09b4c4a4
5 changed files with 454 additions and 12 deletions

View file

@ -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);

View file

@ -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}`); };

View file

@ -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 <rtept> (Yandex Map Constructor), <trkpt> or <wpt> — 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");

View file

@ -2373,6 +2373,7 @@ async function renderGpsScreen(container) {
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
<div class="gps-menu-item" id="gps-routes">Сохранённые маршруты</div>
<div class="gps-menu-item" id="gps-recover">Восстановить точки из файлов</div>
<div class="gps-menu-item" id="gps-recalc-stats">Пересчитать высоту/калории</div>
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
@ -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 => `
<div class="settings-item route-row" data-id="${r.id}" data-name="${esc(r.name)}" style="gap:8px">
<label style="display:flex;align-items:center;gap:10px;flex:1;cursor:pointer;min-width:0">
<input type="radio" name="active-route" value="${r.id}" ${String(r.id) === active ? "checked" : ""} style="width:18px;height:18px;flex:none">
<span class="route-swatch" style="width:16px;height:16px;border-radius:3px;background:${esc(r.color)};flex:none"></span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.name)} <span class="prod-hint">${r.pts} тчк</span></span>
</label>
<input type="color" class="route-color" value="${esc(r.color)}" style="width:30px;height:26px;border:none;background:none;padding:0;flex:none">
<button class="route-rename btn-secondary" style="padding:4px 8px;flex:none">✎</button>
<button class="route-del btn-secondary" style="padding:4px 8px;color:#cf6679;flex:none">✕</button>
</div>`).join("");
container.innerHTML = `
<div class="routes-screen" style="padding:12px">
<div class="day-header">
<button class="day-nav" id="routes-back">←</button>
<h2>Сохранённые маршруты</h2>
<span style="width:36px"></span>
</div>
<button id="routes-import" class="btn-primary" style="width:100%;margin:8px 0 14px">Импорт GPX</button>
<label class="settings-item" style="cursor:pointer">
<input type="radio" name="active-route" id="route-none" value="" ${!active ? "checked" : ""} style="width:18px;height:18px;margin-right:10px">
<span>— Без маршрута —</span>
</label>
<div id="routes-list">${routes.length ? rows : `<div class="prod-hint">Нет маршрутов. Импортируйте GPX-файл — линию можно нарисовать заранее (напр. в Яндекс.Конструкторе карт).</div>`}</div>
</div>`;
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 = `<b>Сглаживание (SG)</b>
<label>Окно ±: <span id="sm-wv">${smParams.W}</span></label><input id="sm-w" type="range" min="1" max="12" value="${smParams.W}">
<label>Итераций: <span id="sm-iv">${smParams.it}</span></label><input id="sm-i" type="range" min="1" max="8" value="${smParams.it}">
<div id="sm-stat" class="sm-stat"></div>
<button id="sm-save" class="btn-primary" style="width:100%;margin-top:6px">Сохранить сглаженный</button>
${session.smoothed ? '<button id="sm-revert" class="btn-secondary" style="width:100%;margin-top:6px">Вернуть оригинал</button>' : ''}`;
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) {
</div>
<input id="s-walk-min-speed" type="number" step="0.1" value="${(GPS.config.walkMinSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
</div>
<div class="settings-item">
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: вело</div></div>
<input id="s-road-color-bike" type="color" value="${GPS.config.roadColorBike || '#2266aa'}" style="width:40px;height:28px;border:none;background:none;padding:0">
</div>
<div class="settings-item">
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: ходьба</div></div>
<input id="s-road-color-walk" type="color" value="${GPS.config.roadColorWalk || '#8B5E3C'}" style="width:40px;height:28px;border:none;background:none;padding:0">
</div>
<div class="settings-item">
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: бег</div></div>
<input id="s-road-color-run" type="color" value="${GPS.config.roadColorRun || '#e67e22'}" style="width:40px;height:28px;border:none;background:none;padding:0">
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Fused-локация (Google Play)</div>
@ -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})`;
}

View file

@ -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;