diff --git a/docs/session-logs/2026-09-14_ridden-roads-walk.md b/docs/session-logs/2026-09-14_ridden-roads-walk.md index 6b1a129..f4162dc 100644 --- a/docs/session-logs/2026-09-14_ridden-roads-walk.md +++ b/docs/session-logs/2026-09-14_ridden-roads-walk.md @@ -25,6 +25,25 @@ - Визуал окраски + Rust rebuild_roads с walk - на десктопе/теле (нужна пересборка для движка; на десктопе rebuild_roads Rust работает). -## Дальше - Стадия A (роутер по проезженным) -- Rust `get_ride_graph` (узлы на сетке ~20м, рёбра из последовательных не-walk точек сессий, - исключая walk=1) -> JS Dijkstra; планировщик: сначала ридден-граф, потом BRouter в пробелах. +## Сделано - Стадия A (роутер по проезженным дорогам) +- Rust `get_ride_graph(min_speed)`: узлы на сетке ~20м (исключая walk=1), рёбра между + последовательными точками сессии (вес = расстояние между УЗЛАМИ - важно, иначе внутрикл. + путь теряется и Dijkstra недооценивает). Возвращает {nodes, edges}. +- JS: `loadRideGraph` (кэш), grid-индекс `_nearestRideNode`, `_rideDijkstra`, `rideRoute` - + путь по проезженному между концами ноги (снап ≤60м, отсев если > 2.5× прямой). +- Планировщик `routeDirty`: сначала `rideRoute`, если null - BRouter(+nogos). Ридден-нога + без высот -> тонкая линия; BRouter - оковка (визуально отличаются). + +## Проверка A (десктоп Xvfb + БД) +- Данные: 139851 ридден-точек -> 6722 узла, 8913 рёбер. Харнесс: между двумя он-трек точками + (1409м) находит путь по проезженному (89 узлов). Баг веса ребра (143м вместо 1717м) найден и + исправлен (вес = между узлами). +- В приложении (Новочеркасск): нога у кольца/Ермака легла ТОНКОЙ линией по проезженной дороге + (ридден), а не оковкой (BRouter). Клик мимо проезженного -> BRouter (корректно). Скрин. +- Побочно: `formatDistance` теперь округляет метры (было «966.145...м»). +- На теле (встроенный движок + реальный walk) - после пересборки APK. + +## Замечания на будущее +- Ридден-нога без высот (тонкая); при желании нести среднюю высоту узла для оковки. +- `walk` на десктопе не заполнен (не пересчитывал) - граф пока включает все точки; на реальном + устройстве после «Пересчитать всё» пешие исключатся. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index d1705ff..7b91e7e 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -1708,7 +1708,7 @@ GPS.formatDuration = function(seconds) { }; GPS.formatDistance = function(meters) { - return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : meters + " м"; + return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : Math.round(meters) + " м"; }; // --------------------------------------------------------------------------- diff --git a/frontend/js/main.js b/frontend/js/main.js index 35b75a0..210ab52 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3196,6 +3196,78 @@ async function getNogosForRouter() { return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 })); } +// --------------------------------------------------------------------------- +// Ridden-roads router: route on the user's own tracks first, BRouter for the rest +// --------------------------------------------------------------------------- + +let _rideGraph = null; // { nodes:[[lat,lon]], adj:Map, grid:Map, cellDeg } +async function loadRideGraph(force) { + if (_rideGraph && !force) return _rideGraph; + let raw = await window.__TAURI__.core.invoke("get_ride_graph", { minSpeedMs: 0.8 }); + let g = JSON.parse(raw); + let nodes = g.nodes || []; + let adj = new Map(); + for (let e of (g.edges || [])) { + let a = e[0], b = e[1], d = e[2]; + if (!adj.has(a)) adj.set(a, []); adj.get(a).push([b, d]); + if (!adj.has(b)) adj.set(b, []); adj.get(b).push([a, d]); + } + let cellDeg = 0.00045; // ~50 m nearest-node buckets + let grid = new Map(); + for (let i = 0; i < nodes.length; i++) { + let key = Math.round(nodes[i][0] / cellDeg) + "," + Math.round(nodes[i][1] / cellDeg); + if (!grid.has(key)) grid.set(key, []); + grid.get(key).push(i); + } + _rideGraph = { nodes, adj, grid, cellDeg }; + return _rideGraph; +} +function _nearestRideNode(g, lat, lon, maxM) { + let cx = Math.round(lat / g.cellDeg), cy = Math.round(lon / g.cellDeg), best = -1, bestD = Infinity; + for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) { + let arr = g.grid.get((cx + dx) + "," + (cy + dy)); if (!arr) continue; + for (let i of arr) { let d = GPS.distance(lat, lon, g.nodes[i][0], g.nodes[i][1]); if (d < bestD) { bestD = d; best = i; } } + } + return (best >= 0 && bestD <= maxM) ? { node: best, dist: bestD } : null; +} +function _rideDijkstra(g, src, dst, maxDist) { + let distMap = new Map([[src, 0]]), prev = new Map(), heap = [[0, src]], seen = new Set(); + while (heap.length) { + let mi = 0; for (let i = 1; i < heap.length; i++) if (heap[i][0] < heap[mi][0]) mi = i; + let popped = heap.splice(mi, 1)[0], d = popped[0], u = popped[1]; + if (seen.has(u)) continue; seen.add(u); + if (u === dst) break; + if (d > maxDist) return null; + let nb = g.adj.get(u); if (!nb) continue; + for (let vw of nb) { + let v = vw[0], w = vw[1]; if (seen.has(v)) continue; + let nd = d + w; + if (nd < (distMap.get(v) ?? Infinity)) { distMap.set(v, nd); prev.set(v, u); heap.push([nd, v]); } + } + } + if (!seen.has(dst)) return null; + let path = [], u = dst; + while (u != null) { path.push([g.nodes[u][0], g.nodes[u][1]]); u = prev.get(u); } + path.reverse(); + return { path, dist: distMap.get(dst) }; +} +// Route a→b on ridden roads. Returns [{lat,lon}] (with the exact waypoints at the ends) or null +// when either end isn't near ridden roads or the ridden path is too indirect (fall back to BRouter). +async function rideRoute(aLat, aLon, bLat, bLon, opts) { + opts = opts || {}; + let g = await loadRideGraph(); + if (!g || !g.nodes.length) return null; + let sn = _nearestRideNode(g, aLat, aLon, opts.snapM ?? 60); + let dn = _nearestRideNode(g, bLat, bLon, opts.snapM ?? 60); + if (!sn || !dn) return null; + if (sn.node === dn.node) return null; // same node → let BRouter handle a tiny leg + let straight = GPS.distance(aLat, aLon, bLat, bLon); + let cap = (opts.maxRatio ?? 2.5) * Math.max(300, straight); + let res = _rideDijkstra(g, sn.node, dn.node, cap + 2000); + if (!res || res.dist > cap) return null; + return [{ lat: aLat, lon: aLon }].concat(res.path.map(p => ({ lat: p[0], lon: p[1] })), [{ lat: bLat, lon: bLon }]); +} + // Move a point `d` metres along bearing `brDeg` (for a "start forward, don't U-turn" helper). function _offsetLatLon(lat, lon, brDeg, d) { let R = 6378137, br = brDeg * Math.PI / 180, la1 = lat * Math.PI / 180, lo1 = lon * Math.PI / 180; @@ -3792,8 +3864,13 @@ async function showRouteEditor(container, routeId) { let idxs = [...dirty]; dirty.clear(); for (let i of idxs) { if (i >= points.length - 1 || legMode[i] !== "road") continue; + let a = points[i], b = points[i + 1]; + // Prefer already-ridden roads; use BRouter (public OSM) only where there is no ridden path. + let ridden = null; + try { ridden = await rideRoute(a.lat, a.lon, b.lat, b.lon); } catch (e) { /* fall back */ } + if (ridden && ridden.length >= 2) { legGeom[i] = ridden.map(p => ({ lat: p.lat, lon: p.lon })); continue; } try { - let gpx = await BRouter.route([[points[i].lon, points[i].lat], [points[i+1].lon, points[i+1].lat]], "trekking", nogos); + let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "trekking", nogos); let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope } catch (e) { legGeom[i] = null; } // silent fallback to straight (no road / no segments) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a643b4b..9466747 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -341,6 +341,70 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more)) } +// Build a routable graph from the user's RIDDEN tracks (excludes walk=1 points), so the planner +// can prefer real roads before public OSM data. Points are snapped to a ~20 m grid (nodes); +// consecutive points of the same session make an edge (weight = metres). Returns compact JSON +// { "nodes": [[lat,lon],...], "edges": [[a,b,dist],...] } (node index = position in nodes). +#[tauri::command] +fn get_ride_graph(app: tauri::AppHandle, min_speed_ms: f64) -> Result { + use std::collections::HashMap; + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = { + let p1 = data_dir.join("../../baldav.db"); + let p2 = data_dir.join("baldav.db"); + let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db"); + if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 } + }; + let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?; + fn hav(la1: f64, lo1: f64, la2: f64, lo2: f64) -> f64 { + let r = 6371000.0_f64; + let dla = (la2 - la1).to_radians(); + let dlo = (lo2 - lo1).to_radians(); + let a = (dla / 2.0).sin().powi(2) + la1.to_radians().cos() * la2.to_radians().cos() * (dlo / 2.0).sin().powi(2); + r * 2.0 * a.sqrt().asin() + } + let d_lat = 0.00018_f64; // ~20 m + let d_lon = 0.00027_f64; + let mut stmt = db.prepare( + "SELECT lat, lon, session_id, seq FROM gps_points + WHERE COALESCE(walk,0)=0 AND speed_ms >= ?1 + ORDER BY session_id, COALESCE(seq,999999), ts" + ).map_err(|e| format!("prepare: {e}"))?; + let rows = stmt.query_map(rusqlite::params![min_speed_ms], |r| { + Ok((r.get::<_, f64>(0)?, r.get::<_, f64>(1)?, r.get::<_, i64>(2)?)) + }).map_err(|e| format!("query: {e}"))?; + + let mut node_idx: HashMap<(i64, i64), usize> = HashMap::new(); + let mut nodes: Vec<(f64, f64)> = Vec::new(); + let mut edges: HashMap<(usize, usize), f64> = HashMap::new(); + let mut prev: Option<(i64, usize, f64, f64)> = None; // (session_id, node, lat, lon) + for row in rows { + let (lat, lon, sid) = row.map_err(|e| format!("row: {e}"))?; + let c = ((lat / d_lat).round() as i64, (lon / d_lon).round() as i64); + let ni = match node_idx.get(&c) { + Some(&i) => i, + None => { let i = nodes.len(); nodes.push((lat, lon)); node_idx.insert(c, i); i } + }; + if let Some((psid, pn, _plat, _plon)) = prev { + if psid == sid && pn != ni { + // Edge weight = distance between the two NODES (not the consecutive points), + // else intra-cell travel is lost and the path is badly under-weighted. + let (nla, nlo) = nodes[pn]; + let d = hav(nla, nlo, lat, lon); + if d <= 200.0 { // skip absurd jumps (GPS gap) between consecutive points + let key = if pn < ni { (pn, ni) } else { (ni, pn) }; + let e = edges.entry(key).or_insert(f64::INFINITY); + if d < *e { *e = d; } + } + } + } + prev = Some((sid, ni, lat, lon)); + } + let nodes_json = nodes.iter().map(|(la, lo)| format!("[{:.6},{:.6}]", la, lo)).collect::>().join(","); + let edges_json = edges.iter().map(|((a, b), d)| format!("[{},{},{:.1}]", a, b, d)).collect::>().join(","); + Ok(format!("{{\"nodes\":[{}],\"edges\":[{}]}}", nodes_json, edges_json)) +} + #[derive(Serialize)] struct AppInfo { version: String, @@ -1024,7 +1088,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) + .invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }