From 3e2a6e47f75b94c3823438c2f4149c2d5723118f Mon Sep 17 00:00:00 2001 From: 4aiman Date: Tue, 29 Sep 2026 23:19:37 +0300 Subject: [PATCH] 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. --- .../2026-09-29_progress-and-zoom.md | 44 +++++++++++ frontend/css/styles.css | 8 ++ frontend/js/gps.js | 7 ++ frontend/js/main.js | 73 ++++++++++++++----- src-tauri/src/lib.rs | 9 ++- 5 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 docs/session-logs/2026-09-29_progress-and-zoom.md diff --git a/docs/session-logs/2026-09-29_progress-and-zoom.md b/docs/session-logs/2026-09-29_progress-and-zoom.md new file mode 100644 index 0000000..a0108d7 --- /dev/null +++ b/docs/session-logs/2026-09-29_progress-and-zoom.md @@ -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 отдельно.) diff --git a/frontend/css/styles.css b/frontend/css/styles.css index ff5e03c..4cfb813 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index d8f80f3..b9ca8e1 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -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; }; diff --git a/frontend/js/main.js b/frontend/js/main.js index d682463..e6b874c 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3cc30ea..7cab0af 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 Result