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:
parent
c564c419db
commit
a30c742bc6
4 changed files with 166 additions and 6 deletions
|
|
@ -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` на десктопе не заполнен (не пересчитывал) - граф пока включает все точки; на реальном
|
||||
устройстве после «Пересчитать всё» пешие исключатся.
|
||||
|
|
|
|||
|
|
@ -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) + " м";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue