diff --git a/README.md b/README.md index 14714de..caf5b78 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами. - **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам. - **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей. +- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё». - **Похвастаться (шэр-картинка)** — в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. Модалка предпросмотра с переключателем и кнопками **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. - **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог. diff --git a/docs/plans/2026-09-14-ridden-roads-walk.md b/docs/plans/2026-09-14-ridden-roads-walk.md new file mode 100644 index 0000000..dbf9e6b --- /dev/null +++ b/docs/plans/2026-09-14-ridden-roads-walk.md @@ -0,0 +1,37 @@ +# План: детект ходьбы (b) + предпочтение проезженных дорог (a) + +## Context +Маршруты из BRouter не совпадают с реальностью (роутит по несуществующим дорогам). Нужно: +(a) при построении предпочитать уже проезженные дороги, к публичным данным (BRouter) обращаться +только там, где проезженного нет; (b) в вело-треке детектить пешие куски по скорости, красить +их отдельно и не считать «дорогой» (по пашне не проедешь). Решения (согласовано): обе сразу; +детект ходьбы по скорости (универсально) + порог по передаче; окраска + исключение из дорог + +опционально стат. + +## Стадия B - детект ходьбы (сделано) +- `GPS.detectWalk(points, opts)` (gps.js): sm-сглаженная скорость ниже порога дольше + `walkMinRunSec` = ходьба. Порог = 30-rpm по передаче (односкоростной) или `walkDetectKmh`. +- Персист `gps_points.walk` (`DB.setWalkFlags`); считается в `recalculateSession` и + `recomputeAllStats`. +- Rust `rebuild_roads`: walk-point -> atype 'walk' (CASE в SQL) -> в оверлее цвет ходьбы, + не вело-дорога. +- Деталь трека: пешие runs красятся `roadColorWalk`; стат «пешком: N мин / X км». + +## Стадия A - роутер по проезженным дорогам (план) +- Rust `get_ride_graph(min_speed)`: читает gps_points (исключая `walk=1`), снапит узлы на сетку + ~20м, рёбра между последовательными точками сессии (вес = расстояние), возвращает компактный + граф JSON {nodes:[{id,lat,lon}], edges:[[a,b,dist]]}. Кэш в JS (перестраивать по запросу). +- JS: индекс ближайшего узла (grid hash), Dijkstra между узлами у концов ноги. +- Планировщик `showRouteEditor.routeDirty`: для дорожной ноги сначала пробовать ридден-граф + (если оба конца в ~60м от узла и путь не длиннее ~2.5× прямой) - использовать его; иначе + BRouter (+nogos). Ридден-путь отличать визуально (или пометкой источника). + +## Файлы +- `frontend/js/gps.js` (detectWalk, хуки), `frontend/js/db.js` (walk column, setWalkFlags), + `frontend/js/main.js` (окраска, стат; Стадия A - роутер + интеграция в планировщик), + `src-tauri/src/lib.rs` (rebuild_roads walk; Стадия A - get_ride_graph). + +## Верификация +- B: `detectWalk` харнесс (98% на пешем треке vs 1% на нормальном); визуал окраски на теле. +- A: на десктопе - планировщик по знакомому месту идёт по проезженному, а не по фейковой дороге; + где не ездил - BRouter. Затем тело (встроенный движок), пересборка APK. diff --git a/docs/session-logs/2026-09-14_ridden-roads-walk.md b/docs/session-logs/2026-09-14_ridden-roads-walk.md new file mode 100644 index 0000000..6b1a129 --- /dev/null +++ b/docs/session-logs/2026-09-14_ridden-roads-walk.md @@ -0,0 +1,30 @@ +# Сессия 2026-09-14 - детект ходьбы + предпочтение проезженных дорог + +## Контекст +Маршруты из BRouter не совпадают с землёй. Две задачи (согласовано - обе сразу): +(a) при построении предпочитать уже проезженные дороги, к публичным данным - только в пробелах; +(b) в вело-треке детектить пешие куски (по скорости), красить их и не строить по ним +(по пашне не проедешь). + +План: `docs/plans/2026-09-14-ridden-roads-walk.md`. Детект ходьбы - по скорости (универсально), +порог уточняется по передаче; окраска на треке + исключение из дорог + опционально стат. + +## Сделано - Стадия B (детект ходьбы: цвет + исключение из дорог) +- `GPS.detectWalk(points, opts)` (gps.js): contiguous runs со сглаженной скоростью ниже порога + (`walkDetectKmh` 8 км/ч, или скорость 30-rpm по передаче для односкоростного) дольше + `walkMinRunSec` (60с) = ходьба. Возвращает булев массив по точкам. +- Персист: колонка `gps_points.walk`; `DB.setWalkFlags`; считается и сохраняется в + `recalculateSession` и в `recomputeAllStats` (backfill по «Пересчитать всё»). +- Rust `rebuild_roads`: пеший point -> atype 'walk' (в SQL CASE), значит в оверлее «дорог» + красится цветом ходьбы и не считается вело-дорогой. +- Деталь трека: пешие участки красятся `roadColorWalk` поверх трека; в статах «пешком: N мин / X км». + +## Проверка B +- Синтаксис OK. `detectWalk` харнессом на реальных треках: почти-пеший (session 1782752407585, + 4018м) - 98% точек пешие («пешком 60 мин, 3.89 км»); нормальный 16км - 1% (не пеший). Различает. +- Визуал окраски + Rust rebuild_roads с walk - на десктопе/теле (нужна пересборка для движка; + на десктопе rebuild_roads Rust работает). + +## Дальше - Стадия A (роутер по проезженным) +- Rust `get_ride_graph` (узлы на сетке ~20м, рёбра из последовательных не-walk точек сессий, + исключая walk=1) -> JS Dijkstra; планировщик: сначала ридден-граф, потом BRouter в пробелах. diff --git a/frontend/js/db.js b/frontend/js/db.js index 38aee58..f04263e 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -810,6 +810,18 @@ async function revertGapFill(sessionId) { await execute("UPDATE gps_sessions SET gap_filled=0 WHERE id=?1", [sessionId]); } +// Persist the per-point "walking" flag (walkIds = point ids in walking runs). Used by the +// track colour, the ridden-roads overlay (rebuild_roads) and the ride-graph router. +async function setWalkFlags(sessionId, walkIds) { + await execute("UPDATE gps_points SET walk=0 WHERE session_id=?1", [sessionId]); + let CHUNK = 200; + for (let i = 0; i < walkIds.length; i += CHUNK) { + let ch = walkIds.slice(i, i + CHUNK); + let ph = ch.map((_, k) => "?" + (k + 1)).join(","); + await execute(`UPDATE gps_points SET walk=1 WHERE id IN (${ph})`, ch); + } +} + async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) { // Guard against NaN — SQLite may reject or corrupt NaN values const safe = v => (v != null && !isNaN(v)) ? v : null; @@ -1280,6 +1292,7 @@ window.DB.deleteGpsSession = deleteGpsSession; window.DB.importGpxSession = importGpxSession; window.DB.saveGapFill = saveGapFill; window.DB.revertGapFill = revertGapFill; +window.DB.setWalkFlags = setWalkFlags; async function findNearestAltitude(lat, lon, radiusM) { const dLat = radiusM / 111000; @@ -1502,6 +1515,7 @@ async function runMigrations() { 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"); if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible + if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads) // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( diff --git a/frontend/js/gps.js b/frontend/js/gps.js index b8f0bc9..d1705ff 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -17,6 +17,8 @@ GPS.config = { altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS) walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч + walkDetectKmh: 8, // в вело-треке устойчивая скорость ниже = ведёшь/идёшь (не едешь) + walkMinRunSec: 60, // минимум длины пешего куска, чтобы засчитать (не притормозил у столба) bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов) walkAvgSpeedKmh: 5, // средняя скорость ходьбы runAvgSpeedKmh: 10, // средняя скорость бега @@ -50,6 +52,45 @@ try { } } catch {} +// Detect "walking" runs inside a ride (pushing the bike / on foot across a field): contiguous +// runs where the smoothed speed stays below a threshold for at least walkMinRunSec. The +// threshold is the single-speed push speed when gear data is given (30-rpm cadence), else +// config.walkDetectKmh — universal by speed, refined by gear. Points are assumed time-ordered +// (as read from DB). Returns a boolean array parallel to `points` (true = walking). +GPS.detectWalk = function(points, opts) { + opts = opts || {}; + let n = points ? points.length : 0; + let out = new Array(n).fill(false); + if (n < 3) return out; + let thrMs; + let hasGear = opts.chainringTeeth && opts.cogTeeth && opts.wheelDiameterInch; + if (hasGear && (opts.gearCount == null || opts.gearCount <= 1)) { + let circ = Math.PI * opts.wheelDiameterInch * 0.0254, ratio = opts.chainringTeeth / opts.cogTeeth; + thrMs = (30 * circ * ratio) / 60; + } else { + thrMs = (GPS.config.walkDetectKmh ?? 8) / 3.6; + } + let minRun = opts.walkMinRunSec ?? (GPS.config.walkMinRunSec ?? 60); + let v = new Array(n).fill(0); + for (let i = 1; i < n; i++) { + let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); + let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000; + let sp = points[i].speed_ms; + v[i] = (sp != null && sp >= 0) ? sp : (dt > 0 ? d / dt : 0); + } + let sm = v.map((_, i) => { let s = 0, c = 0; for (let j = Math.max(0, i-2); j <= Math.min(n-1, i+2); j++) { s += v[j]; c++; } return c ? s / c : 0; }); + let i = 1; + while (i < n) { + if (sm[i] > 0.15 && sm[i] < thrMs) { // > 0.15 m/s excludes standing still + let j = i, dur = 0; + while (j < n && sm[j] > 0.15 && sm[j] < thrMs) { dur += Math.max(0, (new Date(points[j].ts) - new Date(points[j-1].ts)) / 1000); j++; } + if (dur >= minRun) for (let k = i - 1; k < j; k++) out[k] = true; + i = j; + } else i++; + } + return out; +}; + // One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes // "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives. // Only touch the value if it's still the old default; respect a user-set threshold. @@ -1733,6 +1774,16 @@ GPS.recalculateSession = async function(sessionId, opts) { const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v }); const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); + // Persist the per-point "walking" flag (pushed bike / on foot inside a ride) for the roads + // overlay and the ridden-graph router. Not for walk/run sessions (all on foot already). + if (session.activity_type !== "walk" && session.activity_type !== "run") { + try { + const wf = GPS.detectWalk(sorted); + const ids = sorted.filter((p, i) => wf[i] && p.id != null).map(p => p.id); + await DB.setWalkFlags(sessionId, ids); + } catch (e) { console.warn("[walk] persist failed", e); } + } + // Manual routes: skip glitch filtering (points are user-placed, not GPS). // Just fix timestamps and recompute stats. if (session.source === "manual") { @@ -1959,6 +2010,14 @@ GPS.recomputeAllStats = async function(onProgress) { }); await DB.finishGpsSession(sid, session.end_time ?? session.start_time, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses); + // Persist the per-point "walking" flag too (roads overlay + ridden-graph router). + if (session.activity_type !== "walk" && session.activity_type !== "run") { + try { + const sw = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); + const wf = GPS.detectWalk(sw); + await DB.setWalkFlags(sid, sw.filter((p, k) => wf[k] && p.id != null).map(p => p.id)); + } catch (e) { console.warn("[walk] persist failed", sid, e); } + } updated++; } catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; } } diff --git a/frontend/js/main.js b/frontend/js/main.js index 998226a..35b75a0 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5067,6 +5067,7 @@ async function showSessionDetail(sessionId) {
${stats.avgSpeed} км/чСредняя
${stats.maxSpeed} км/чМакс.
+
@@ -5437,6 +5438,24 @@ async function showSessionDetail(sessionId) { flush(); } + // Colour "walking" runs inside a ride (pushed bike / on foot across a field) so they stand + // apart from riding; also report the pushed total. Walk/run sessions are all on foot already. + if (session.activity_type !== "walk" && session.activity_type !== "run") { + let walk = GPS.detectWalk(points); + let run = [], flush = () => { if (run.length >= 2) L.polyline(run, { color: GPS.config.roadColorWalk || "#8B5E3C", weight: 6, opacity: 0.95 }).addTo(map); run = []; }; + let wSec = 0, wDist = 0; + for (let i = 0; i < points.length; i++) { + if (walk[i]) { + if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]); + run.push([points[i].lat, points[i].lon]); + if (i > 0) { wSec += Math.max(0, (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000); wDist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); } + } else if (run.length) { run.push([points[i].lat, points[i].lon]); flush(); } + } + flush(); + let ws = document.querySelector("#sd-walk-stat"); + if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км"; + } + // Invisible but tappable hit-targets on top of track for (let i = 0; i < points.length; i++) { let p = points[i]; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2602a5..a643b4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -264,8 +264,11 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result= ?1 ORDER BY p.session_id, p.seq"