Track detail de-jitter: fix overlay stacking, native dialogs, slow save

Fixes to the smoothing/routes UI reported on device:
- Blank detail after save/revert: showSessionDetail was called from inside the
  open detail modal, stacking a second overlay; querySelector("#sd-map") then
  hit the stale, already-initialized Leaflet map and threw, aborting the rest
  of the render (map + speed chart). New reopenDetail() closes the current
  detail (and its fullscreen-map child) and reopens fresh, like recalc/refine.
- Native confirm()/prompt() replaced with in-app appConfirm/appPrompt dialogs
  (winStack + history integrated, hardware back cancels). Used for smoothed
  save, route delete, route rename.
- Slow smoothed save: one UPDATE per point (4000+ IPC round-trips) replaced
  with a chunked CASE update — 18 statements for 4366 points, params kept
  under SQLite's 999 limit. Verified value-for-value on a copy DB.
- Closing the fullscreen map now also closes the smoothing panel (it is a
  fixed element that otherwise lingered over the collapsed map).
- "≈" button active state now reflects panel-open only, not session.smoothed
  (it looked permanently pressed after a save).

Docs: README + PROJECT_MAP updated for saved routes, de-jitter, accuracy_m,
routes table and new gps_points/gps_sessions columns. Version 0.1.393.
This commit is contained in:
4aiman 2026-07-31 23:51:20 +03:00
commit bf3c0e2534
8 changed files with 173 additions and 23 deletions

View file

@ -9,7 +9,7 @@
| Строк кода | ~22000 |
| Файлов исходников | 50+ |
| Язык | JS 70%, Rust 6%, Kotlin 12%, CSS 12% |
| Таблиц в БД | 20 |
| Таблиц в БД | 21 |
| Экранов | 7 основных + 15 модалок |
| IPC команд (Tauri) | 14 |
| Библиотеки | Leaflet, Chart.js, Hammer.js, JSZip |
@ -21,9 +21,9 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `js/main.js` | ~5800 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги |
| `js/db.js` | ~1500 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync |
| `js/gps.js` | ~1600 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg |
| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев) |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера |
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, DOMParser) |
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
@ -100,8 +100,9 @@
| `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m |
| `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы |
| `supplement_log` | Приём добавок | date, supplement_id, dose_g |
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id |
| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw |
| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер) |
| `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw |
| `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json, created_at |
| `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active |
| `rdi_standards` | Стандарты РСП (RU/EU/US) | code, name |
| `rdi_values` | Нормы нутриентов | standard_id, nutrient_key, value, norm_type (rdi/aup) |
@ -129,14 +130,14 @@
### Экраны
- **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы)
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, вело/пешие)
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М»), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали)
- **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10)
- **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг, OTA обновления, импорт тайлов, карта дорог
### Модалки
- Еда: showFoodModal, showEditFoodModal, showFoodFromRecipeModal
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, пересчёт)
- Тренировки: showSessionDetail (карта, графики скорости/высоты, пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
## IPC команды (Rust → JS)

View file

@ -15,6 +15,8 @@
- **Графики** — скорость, высота, ЧСС тренировки с зумом и pan. Тренд веса. Донат макронутриентов. Баланс ккал (приход/расход).
- **OTA обновления** — проверка BUILD_NUMBER на роутере (LAN) или VPS, скачивание через браузер.
- **Ручные маршруты** — рисование трека на карте тапами, elevation из прошлых треков или open-meteo API.
- **Сохранённые маршруты** — импорт GPX-маршрутов, нарисованных заранее (например, в конструкторе Яндекс.Карт: формат `<rtept>`), выбор активного, наложение цветной линией поверх карты во время записи И на экране просмотра прошлого трека — для визуальной навигации. Управление на отдельной странице (импорт/переименование/цвет/удаление, один активный за раз), переключение линии кнопкой-тогглом. Цвета «проезженных дорог» настраиваются раздельно для вело/пеших/бега.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД для будущего пересчёта с порогом точности.
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
- **Вес, сон, добавки** — логирование с историей.
@ -32,7 +34,7 @@
./build.sh -d android # debug APK
./build.sh android # release APK
./build.sh -i android -d # debug + установка на устройство
./build.sh -u android # release + upload на VPS (OTA)
./build.sh -u android # release + upload на роутер (LAN) и VPS (OTA)
./build.sh -d -i -u android # debug + install + upload
```

View file

@ -628,6 +628,47 @@ select {
flex-shrink: 0;
}
/* In-app confirm / prompt dialog — centered card over a dimmed backdrop,
above full-screen modals (z-index 500) and fullscreen maps (9999). */
.confirm-overlay {
background: rgba(0, 0, 0, 0.6);
align-items: center;
padding: 0 24px;
z-index: 10000;
}
.confirm-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 18px 16px;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.confirm-msg {
font-size: 15px;
line-height: 1.4;
margin-bottom: 16px;
color: var(--on-bg);
}
.confirm-input {
width: 100%;
padding: 10px;
margin-bottom: 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--on-bg);
font-size: 15px;
}
.confirm-btns {
display: flex;
gap: 10px;
}
.confirm-btns button {
flex: 1;
}
.btn-primary, .btn-secondary {
flex: 1;
padding: 12px;

View file

@ -762,11 +762,28 @@ async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGa
// 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 {} }
// One UPDATE per point over IPC is thousands of round-trips (slow, ~1 pt/ms).
// Batch into a single CASE-per-chunk UPDATE — ~15 statements instead of 4000+.
// Chunk keeps bound params under SQLite's 999 limit (250 pts × 3 params = 750).
let pts = smoothedPoints.filter(p => p.id != null);
let CHUNK = 250;
for (let start = 0; start < pts.length; start += CHUNK) {
let chunk = pts.slice(start, start + CHUNK);
let latWhen = [], lonWhen = [], ids = [], params = [];
for (let p of chunk) {
params.push(p.id); let idi = params.length;
params.push(p.lat); let lati = params.length;
params.push(p.lon); let loni = params.length;
latWhen.push(`WHEN ?${idi} THEN ?${lati}`);
lonWhen.push(`WHEN ?${idi} THEN ?${loni}`);
ids.push(`?${idi}`);
}
await execute(
`UPDATE gps_points SET lat_smoothed=CASE id ${latWhen.join(" ")} END, ` +
`lon_smoothed=CASE id ${lonWhen.join(" ")} END WHERE id IN (${ids.join(",")})`,
params
);
if (onProgress) { try { onProgress(Math.min(start + CHUNK, pts.length), pts.length); } catch {} }
}
await execute("UPDATE gps_sessions SET smoothed=1, smooth_window=?1, smooth_iters=?2 WHERE id=?3", [window, iters, sessionId]);
}

View file

@ -1019,6 +1019,91 @@ function showModal(html, onSubmit, onMount) {
if (onMount) onMount(overlay);
}
// In-app confirm dialog (replaces the ugly native confirm()). Integrated with the
// winStack/history so the hardware back button cancels it. Returns Promise<bool>.
function appConfirm(message, okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
<div class="confirm-box">
<div class="confirm-msg">${message}</div>
<div class="confirm-btns">
<button class="btn-secondary" id="cf-cancel">Отмена</button>
<button class="btn-primary" id="cf-ok">${okText}</button>
</div>
</div>`;
document.body.appendChild(ov);
let settled = false;
let base = winStack.length;
winPush("confirm", () => { ov.remove(); if (!settled) { settled = true; resolve(false); } });
let finish = (val) => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve(val);
};
ov.querySelector("#cf-cancel").onclick = () => finish(false);
ov.querySelector("#cf-ok").onclick = () => finish(true);
});
}
// In-app text prompt (replaces native prompt()). Returns Promise<string|null>.
function appPrompt(message, defaultValue = "", okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
<div class="confirm-box">
<div class="confirm-msg">${message}</div>
<input class="confirm-input" id="cf-input" type="text" value="${esc(defaultValue)}" />
<div class="confirm-btns">
<button class="btn-secondary" id="cf-cancel">Отмена</button>
<button class="btn-primary" id="cf-ok">${okText}</button>
</div>
</div>`;
document.body.appendChild(ov);
let input = ov.querySelector("#cf-input");
let settled = false;
let base = winStack.length;
winPush("prompt", () => { ov.remove(); if (!settled) { settled = true; resolve(null); } });
let finish = (val) => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve(val);
};
ov.querySelector("#cf-cancel").onclick = () => finish(null);
ov.querySelector("#cf-ok").onclick = () => finish(input.value);
input.focus();
input.select();
});
}
// Close the currently-open track-detail modal (plus its fullscreen-map child, if any),
// balance the history stack, then reopen it fresh. Mirrors the recalc/refine pattern
// but pops down to the detail modal so it also works while the map is fullscreen —
// re-rendering in place instead of stacking a second overlay (which broke the map/charts).
function reopenDetail(sessionId) {
let count = 0;
while (winStack.length > 0) {
let top = winStack.pop();
count++;
try { top.close(); } catch {}
if (top.type === "modal") break;
}
if (count > 0) { _skipPops = count; history.go(-count); }
showSessionDetail(sessionId);
}
function showWeightModal() {
showModal(`
<h3>Вес</h3>
@ -2843,13 +2928,13 @@ async function showRoutesScreen(container) {
row.querySelector(".route-swatch").style.background = e.target.value;
};
row.querySelector(".route-rename").onclick = async () => {
let name = prompt("Новое название:", curName);
let name = await appPrompt("Новое название:", curName, "Сохранить");
if (name == null || !name.trim()) return;
await DB.renameRoute(id, name.trim());
render();
};
row.querySelector(".route-del").onclick = async () => {
if (!confirm(`Удалить маршрут «${curName}»?`)) return;
if (!(await appConfirm(`Удалить маршрут «${curName}»?`, "Удалить"))) return;
await DB.deleteRoute(id);
if (active === id) { active = ""; localStorage.removeItem("baldav_active_route"); }
render();
@ -3968,13 +4053,13 @@ async function showSessionDetail(sessionId) {
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;
if (!(await appConfirm(`Сохранить сглаженный трек (окно ${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);
reopenDetail(sessionId);
};
let rev = smPanel.querySelector("#sm-revert");
if (rev) rev.onclick = async () => {
@ -3982,7 +4067,7 @@ async function showSessionDetail(sessionId) {
await DB.setSessionSmoothed(sessionId, false);
await recomputeStatsFor(rawPoints);
showToast("Возвращён оригинал");
showSessionDetail(sessionId);
reopenDetail(sessionId);
};
};
let smCtl = L.control({ position: "topleft" });
@ -3990,7 +4075,8 @@ async function showSessionDetail(sessionId) {
smBtnDiv = L.DomUtil.create("div", "sd-map-fs-btn");
smBtnDiv.innerHTML = "≈";
smBtnDiv.title = "Сглаживание трека";
smBtnDiv.classList.toggle("active", !!session.smoothed);
// "active" reflects the PANEL-OPEN state only (set by smSetActive), not whether
// the track is smoothed — otherwise it looks permanently pressed after a save.
smBtnDiv.onclick = e => { e.stopPropagation(); toggleSmoothPanel(); };
return smBtnDiv;
};
@ -4100,6 +4186,9 @@ async function showSessionDetail(sessionId) {
fsDiv.innerHTML = "&#x26F6;";
map.dragging.disable();
map.scrollWheelZoom.disable();
// Closing the map also closes the smoothing overlay panel (it's a fixed
// element tied to the map, otherwise it lingers over the collapsed map).
if (smPanel) toggleSmoothPanel();
map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds);
});
winStack[winStack.length - 1].overlay = parentOverlay;

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.384"
version = "0.1.393"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.384"
version = "0.1.393"
edition = "2021"
[lib]

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.384",
"version": "0.1.393",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"