Walk detection in rides: colour it, keep it out of the ridden roads

Stage B of the ridden-roads work. GPS.detectWalk() flags contiguous low-speed
runs (< walkDetectKmh, or the single-speed 30-rpm push speed, for >= walkMinRunSec)
as walking inside a bike/other ride. Persisted per point (gps_points.walk, via
DB.setWalkFlags in recalculateSession + recomputeAllStats). rebuild_roads maps a
walk point to atype 'walk' so it colours as walking and is not treated as a
rideable road. Track detail colours walk runs and shows 'пешком: N мин / X км'.
Verified detectWalk on real tracks: 98% on a mostly-pushed 4km ride vs 1% on a
normal 16km ride.
This commit is contained in:
4aiman 2026-09-14 12:14:04 +03:00
commit c564c419db
7 changed files with 164 additions and 1 deletions

View file

@ -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). На картах записи, деталей трека и дорог.

View file

@ -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.

View file

@ -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 в пробелах.

View file

@ -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 (

View file

@ -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++; }
}

View file

@ -5067,6 +5067,7 @@ async function showSessionDetail(sessionId) {
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
</div>
<div id="sd-walk-stat" class="prod-hint" style="text-align:center;margin-bottom:6px"></div>
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
</div>
@ -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];

View file

@ -264,8 +264,11 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
let d_lat: f64 = 0.000018;
let d_lon: f64 = 0.000027;
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
// activity — so pushed-across-a-field bits don't become a rideable road.
let mut read_stmt = db.prepare(
"SELECT p.lat, p.lon, s.activity_type FROM gps_points p
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END
FROM gps_points p
JOIN gps_sessions s ON s.id = p.session_id
WHERE p.speed_ms >= ?1
ORDER BY p.session_id, p.seq"