Progress on recompute/refine/roads-rebuild + remember map zoom

- .btn-progress fills a button's bg by --pct (setBtnProgress). Elevation
  refine drives it from fetchElevations network batches (0-80%) then save
  (85-100%); recompute from recalculateSession phase milestones; roads
  rebuild from a new Rust roads_progress event (rebuild_roads emits
  [current,total] every ~2000 points - first Rust->JS event in the app,
  adds use tauri::Emitter) so it no longer just hangs silently.
- recalc/refine now reopen the detail via reopenDetail (awaits the
  popstate) instead of the manual pop+history.back+render race.
- Track detail remembers its map view per session (_sdMapView, moveend) and
  restores it on reopen/return instead of re-fitting the whole track; the
  recording map persists its zoom in localStorage across screen switches.
This commit is contained in:
4aiman 2026-09-29 23:19:37 +03:00
commit 3e2a6e47f7
5 changed files with 120 additions and 21 deletions

View file

@ -0,0 +1,44 @@
# Сессия 2026-09-29 (3) - прогресс на кнопках, память масштаба карты
Продолжение дня (см. `2026-09-29_startup-slowness.md`). По списку задач пользователя.
## Что значат сообщения достройки (объяснение, без правок)
`GPS.fillGaps` (gps.js) чинит только реальные потери сигнала: разрыв > 60с И хорда > 150м.
Для такого разрыва спрашивает у BRouter дорожный путь между концами:
- "больше нужного" = `skipped-long`: дорога есть, но путь длиннее прямой более чем в 2 раза
(крюк). Значит ехал не по дороге (тропа/поле/грунт, которого нет в графе) - оставляем прямую.
- "нет данных" = `skipped-noroute`: BRouter не проложил вообще - нет дорожных данных на участке
(не скачан .rd5-сегмент региона / пусто). Мостить нечем.
Это не баг, а честный отказ достраивать заведомо кривым путём.
## Прогресс на долгих действиях (заливка фона кнопки по %)
- CSS `.btn-progress` (styles.css): фон кнопки заливается слева по `--pct` (ставится из JS через
CSS-переменную, не инлайн-стиль). Хелпер `setBtnProgress(el, pct)` (main.js).
- "Уточнить высоты" (`#sd-refine`): реальный % от сетевых батчей `fetchElevations(pts, onProgress)`
(0-80%), затем сохранение+пересчёт (85-100%). Сеть - api.gpxz.io / open-elevation /
open-meteo / opentopodata.
- "Пересчитать" (`#sd-recalc`): `GPS.recalculateSession(id, {onProgress})` шлёт честные вехи по
фазам (5 старт / 40 фильтр / 70 стата / 100 запись).
- "Перестроить" дороги (`#roads-rebuild`): Rust `rebuild_roads` эмитит `roads_progress`
[current,total] каждые ~2000 точек (в проекте раньше событий из Rust не было вообще - добавлен
`use tauri::Emitter`), JS слушает и заливает кнопку + пишет "Построение: cur / tot". Убирает
"просто подвисает".
## Гонка reopenDetail - распространил на recalc/refine
`#sd-recalc` и `#sd-refine` раньше делали тот же ручной `winStack.pop + history.back +
showSessionDetail` (гонка с пустой картой/графиками). Переведены на `reopenDetail` (ждёт
popstate) - фикс той же гонки.
## Память масштаба карты
- Деталь трека: `_sdMapView[sessionId]` хранит {центр, зум}, обновляется на moveend/zoomend; при
рендере, если вид сохранён - `setView` вместо `fitBounds`. Так после пересчёта/уточнения (reopen)
и при возврате к треку масштаб сохраняется, а не сбрасывается на весь трек. Первый показ - как
раньше, fitBounds.
- Карта записи (`renderRecordingInline`): зум хранится в localStorage `baldav_rec_zoom`
(сохраняется на zoomend, восстанавливается при создании карты вместо жёсткого 15). Так возврат
на экран трекинга держит масштаб.
## Проверка
- Синтаксис JS - ок. Сборка debug + телефон. Проверить: заливка кнопок идёт по %, дороги больше
не "висят молча"; после "Пересчитать"/"Уточнить" карта на том же масштабе; вернулся на запись -
масштаб прежний. (Задача 1 - фикс гонки reopenDetail - проверена через CDP отдельно.)

View file

@ -714,6 +714,14 @@ select {
.full-width { width: 100%; }
/* Progress fill for long-running action buttons: the background fills left-to-right by
--pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps
the button's own background. Used by recompute / elevation-refine / roads-rebuild. */
.btn-progress {
background-image: linear-gradient(90deg, var(--primary) var(--pct, 0%), rgba(0,0,0,0) var(--pct, 0%));
transition: background-image 0.15s linear;
}
/* Products screen */
.products-screen, .stub-screen, .settings-screen {
padding: 16px;

View file

@ -1910,6 +1910,9 @@ GPS.exportGPX = async function(sessionId) {
// ---------------------------------------------------------------------------
GPS.recalculateSession = async function(sessionId, opts) {
opts = opts || {};
// Coarse progress by phase (load → filter → stats → write). Honest milestones, not a timer.
const onProg = (typeof opts.onProgress === "function") ? opts.onProgress : () => {};
onProg(5);
const session = await DB.getGpsSession(sessionId);
if (!session) return null;
const points = await DB.getGpsPoints(sessionId);
@ -1962,6 +1965,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
});
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
onProg(100);
return stats;
}
@ -2016,6 +2020,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
}
if (deleted) console.log(`[GPS] Deleted ${deleted} glitch points from session ${sessionId}`);
}
onProg(40);
// Interpolate gaps where accelerometer shows movement
// If gap > 30s but accel before/after > 2 → person was walking/riding, GPS lost signal
@ -2087,6 +2092,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
// Use cleaned sorted array, not original points
const sessPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
onProg(70);
const stats = GPS.computeStats(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
@ -2105,6 +2111,7 @@ GPS.recalculateSession = async function(sessionId, opts) {
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss, sessPauses
);
onProg(100);
return stats;
};

View file

@ -1173,6 +1173,15 @@ function appAlert(message, okText = "OK") {
// 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).
// Fill a button's background to `pct` (0-100) as a live progress bar; pct=null clears it.
// The % rides on a CSS custom property (.btn-progress reads --pct) so no inline style rule.
function setBtnProgress(el, pct) {
if (!el) return;
if (pct == null) { el.classList.remove("btn-progress"); el.style.removeProperty("--pct"); return; }
el.classList.add("btn-progress");
el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%");
}
async function reopenDetail(sessionId) {
let count = 0;
while (winStack.length > 0) {
@ -3201,7 +3210,7 @@ async function showAddGoalMarkerModal() {
// Draw manual route on map
// ---------------------------------------------------------------------------
async function fetchElevations(points) {
async function fetchElevations(points, onProgress) {
// Terrain elevation, tried in order until one answers a batch:
// 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m).
// 2. open-elevation.com (SRTM, reachable in RU).
@ -3253,6 +3262,7 @@ async function fetchElevations(points) {
} catch (e) { console.log("[opentopodata] batch fail:", e.message); }
}
if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j];
if (onProgress) try { onProgress(Math.min(i + 100, points.length), points.length); } catch {}
}
return elevations.some(x => x != null) ? elevations : null;
}
@ -4464,7 +4474,11 @@ async function renderRecordingInline(container) {
let lastPt = GPS.recording.lastMovingPoint;
let initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47;
let initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07;
let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], 15);
// Remember the recording map's zoom across screen switches (persist on zoom, restore here).
let recZoom = parseInt(localStorage.getItem("baldav_rec_zoom"), 10);
if (!(recZoom >= 1 && recZoom <= 19)) recZoom = 15;
let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], recZoom);
map.on("zoomend", () => { try { localStorage.setItem("baldav_rec_zoom", String(map.getZoom())); } catch {} });
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
@ -5219,6 +5233,9 @@ function showMapDownloadModal() {
// ---------------------------------------------------------------------------
let _sessionDetailRender = 0;
// Remember each track's last map view (centre+zoom) so a recompute/refine reopen — or coming
// back to a track you looked at — restores where you were instead of re-fitting the whole track.
let _sdMapView = {};
async function showSessionDetail(sessionId) {
let renderToken = ++_sessionDetailRender;
try {
@ -5421,6 +5438,8 @@ async function showSessionDetail(sessionId) {
// vectors into one viewport-bounded canvas instead of thousands of SVG paths.
let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
mapEl._leafletMap = map; // save ref for winStack collapse
// Remember the view for this track so a reopen (after recompute / refine) keeps the zoom.
map.on("moveend zoomend", () => { _sdMapView[sessionId] = { c: map.getCenter(), z: map.getZoom() }; });
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
@ -5831,7 +5850,10 @@ async function showSessionDetail(sessionId) {
// Ensure Leaflet has correct container size, then fit
map.invalidateSize();
if (allCoords.length > 1) {
let savedView = _sdMapView[sessionId];
if (savedView) {
map.setView(savedView.c, savedView.z); // reopen after recompute/return → keep the zoom
} else if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
} else if (points.length) {
// Degenerate track (a single usable point): fitBounds needs 2+ coords, so
@ -6176,23 +6198,21 @@ async function showSessionDetail(sessionId) {
if (recalcBtn) recalcBtn.onclick = async () => {
recalcBtn.textContent = "Пересчёт...";
recalcBtn.disabled = true;
let newStats = await GPS.recalculateSession(sessionId);
setBtnProgress(recalcBtn, 0);
let newStats = await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(recalcBtn, p) });
setBtnProgress(recalcBtn, null);
recalcBtn.textContent = "Пересчитать";
recalcBtn.disabled = false;
if (newStats) {
// Close modal and reopen with fresh data
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showSessionDetail(sessionId);
}
// reopenDetail awaits the popstate before re-rendering (no blank map/charts race).
if (newStats) reopenDetail(sessionId);
};
let refineBtn = document.querySelector("#sd-refine");
if (refineBtn) refineBtn.onclick = async () => {
let reset = (t) => { refineBtn.textContent = t; refineBtn.disabled = false; };
let reset = (t) => { setBtnProgress(refineBtn, null); refineBtn.textContent = t; refineBtn.disabled = false; };
refineBtn.disabled = true;
refineBtn.textContent = "Загрузка высот...";
refineBtn.textContent = "Высоты...";
setBtnProgress(refineBtn, 0);
try {
let pts = await DB.getGpsPoints(sessionId);
if (pts.length < 2) return reset("Уточнить высоты (онлайн)");
@ -6201,7 +6221,8 @@ async function showSessionDetail(sessionId) {
let sampleIdx = [];
for (let i = 0; i < pts.length; i += step) sampleIdx.push(i);
if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1);
let terr = await fetchElevations(sampleIdx.map(i => pts[i]));
// The network fetch is the slow part → drive the bar 0-80% by fetched batches.
let terr = await fetchElevations(sampleIdx.map(i => pts[i]), (done, total) => setBtnProgress(refineBtn, total ? done / total * 80 : 0));
if (!terr || !terr.some(x => x != null)) {
if (typeof showToast === "function") showToast("Сервис высот недоступен");
return reset("Уточнить высоты (онлайн)");
@ -6215,13 +6236,12 @@ async function showSessionDetail(sessionId) {
}
let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null);
refineBtn.textContent = "Сохранение...";
setBtnProgress(refineBtn, 85);
await DB.updatePointsSrtm(rows);
await GPS.recalculateSession(sessionId);
await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(refineBtn, 85 + p * 0.15) });
setBtnProgress(refineBtn, null);
if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам");
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showSessionDetail(sessionId);
reopenDetail(sessionId); // awaits popstate → no blank map/charts race
} catch (e) {
console.log("[refine] fail:", e.message);
if (typeof showToast === "function") showToast("Ошибка высот: " + e.message);
@ -8420,15 +8440,28 @@ async function showRoadsMap() {
let rebuildBtn = document.querySelector("#roads-rebuild");
async function doRebuild() {
rebuildBtn.disabled = true;
rebuildBtn.textContent = "...";
rebuildBtn.textContent = "Построение...";
setBtnProgress(rebuildBtn, 0);
infoEl.textContent = "Построение (может занять минуту)...";
// Rust emits roads_progress [current,total] as it scans points → fill the button by %.
let unlisten = null;
try {
try {
unlisten = await window.__TAURI__.event.listen("roads_progress", (ev) => {
let cur = ev.payload?.[0] ?? 0, tot = ev.payload?.[1] ?? 0;
if (tot > 0) { setBtnProgress(rebuildBtn, cur / tot * 100); infoEl.textContent = `Построение: ${cur} / ${tot}`; }
});
} catch {}
let count = await DB.rebuildRoads();
if (unlisten) unlisten();
setBtnProgress(rebuildBtn, null);
infoEl.textContent = `${count} точек`;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
showRoadsMap();
} catch (e) {
if (unlisten) unlisten();
setBtnProgress(rebuildBtn, null);
infoEl.textContent = "Ошибка: " + e;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;

View file

@ -2,6 +2,8 @@
use tauri_plugin_sql::{Migration, MigrationKind};
#[allow(unused_imports)]
use tauri::Manager;
#[allow(unused_imports)]
use tauri::Emitter; // app.emit(...) for progress events (Tauri v2)
use serde::Serialize;
#[allow(unused, dead_code)]
@ -284,8 +286,12 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
let total = points.len();
let _ = app.emit("roads_progress", (0u64, total as u64)); // start
let mut inserted = 0u32;
for (lat, lon, atype) in &points {
for (i, (lat, lon, atype)) in points.iter().enumerate() {
// Progress heartbeat every ~2000 points (JS fills the Rebuild button by %).
if i % 2000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM road_points WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4)",
rusqlite::params![lat - d_lat, lat + d_lat, lon - d_lon, lon + d_lon],
@ -302,6 +308,7 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
}
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
}