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:
parent
fd178ad97d
commit
3e2a6e47f7
5 changed files with 120 additions and 21 deletions
44
docs/session-logs/2026-09-29_progress-and-zoom.md
Normal file
44
docs/session-logs/2026-09-29_progress-and-zoom.md
Normal 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 отдельно.)
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue