Ridden-roads-preferred router: plan over your own tracks first

Planner now tries a route over roads you have actually ridden before
falling back to BRouter public data:
- Rust get_ride_graph(min_speed): nodes snapped to ~20m grid (walk=1
  points excluded), edges between consecutive same-session points with
  weight = node-to-node distance (consecutive-point distance loses
  intra-cell travel and makes Dijkstra underestimate).
- JS loadRideGraph cache, grid-indexed nearest-node, Dijkstra, rideRoute
  (snap <=60m, drop if >2.5x straight line).
- routeDirty: rideRoute first, BRouter(+nogos) in the gaps. Ridden leg
  has no elevation -> thin line; BRouter -> casing (visually distinct).
- formatDistance now rounds metres (was 966.145...m).
This commit is contained in:
4aiman 2026-09-14 12:35:39 +03:00
commit a30c742bc6
4 changed files with 166 additions and 6 deletions

View file

@ -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` на десктопе не заполнен (не пересчитывал) - граф пока включает все точки; на реальном
устройстве после «Пересчитать всё» пешие исключатся.

View file

@ -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) + " м";
};
// ---------------------------------------------------------------------------

View file

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

View file

@ -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<String, String> {
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::<Vec<_>>().join(",");
let edges_json = edges.iter().map(|((a, b), d)| format!("[{},{},{:.1}]", a, b, d)).collect::<Vec<_>>().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");
}