Roads overlay: glue repeated passes, thicken line by ride count

rebuild_roads now counts how many times each ~2m cell was ridden
(by cell entry, not per point, so a slow crawl stays one pass) and
stores it in road_points.passes. get_road_points returns it; the
overlay draws each stretch at base width x min(3, 1 + 0.2*(passes-1)),
split into runs of equal thickness. Optional via the "Толщина дорог
по проездам" setting (default on). Both the recording-screen roads
toggle and the Дороги screen honour it.
This commit is contained in:
4aiman 2026-10-22 00:49:18 +03:00
commit 76efc9238b
3 changed files with 108 additions and 52 deletions

View file

@ -265,59 +265,72 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
db.execute("DROP TABLE IF EXISTS road_points", []).ok();
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT)", [])
// `passes` = how many distinct times this ~2 m stretch was ridden (used for line thickness).
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT, passes INTEGER NOT NULL DEFAULT 1)", [])
.map_err(|e| format!("create: {e}"))?;
db.execute("CREATE INDEX idx_road_lat_lon ON road_points(lat, lon)", [])
.map_err(|e| format!("index: {e}"))?;
// 2m in degrees: lat ~0.000018, lon ~0.000027 at 47N
// ~2 m cell in degrees at 47N: lat ~0.000018, lon ~0.000027.
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.
// activity — so pushed-across-a-field bits don't become a rideable road. session_id is
// read too, so a cell re-entered in a later session counts as another pass.
let mut read_stmt = db.prepare(
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END, p.session_id
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"
).map_err(|e| format!("prepare read: {e}"))?;
let points: Vec<(f64, f64, String)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default()))
let points: Vec<(f64, f64, String, i64)> = read_stmt.query_map(rusqlite::params![min_speed_ms], |row| {
Ok((row.get::<_, f64>(0)?, row.get::<_, f64>(1)?, row.get::<_, String>(2).unwrap_or_default(), row.get::<_, i64>(3).unwrap_or(-1)))
}).map_err(|e| format!("query: {e}"))?
.filter_map(|r| r.ok())
.collect();
drop(read_stmt);
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
let total = points.len();
let _ = app.emit("roads_progress", (0u64, total as u64)); // start
let mut inserted = 0u32;
for (i, (lat, lon, atype)) in points.iter().enumerate() {
// Progress heartbeat every ~2000 points (JS fills the Rebuild button by %).
if i % 2000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM road_points WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4)",
rusqlite::params![lat - d_lat, lat + d_lat, lon - d_lon, lon + d_lon],
|r| r.get(0),
).unwrap_or(false);
if !exists {
db.execute(
"INSERT INTO road_points (lat, lon, atype) VALUES (?1, ?2, ?3)",
rusqlite::params![lat, lon, atype],
).map_err(|e| format!("insert: {e}"))?;
inserted += 1;
// Glue + count passes in memory: one cell per ~2 m grid square, first-seen geometry kept,
// `passes` incremented once per ENTRY into the cell (not per point, so a slow crawl that
// drops many points in one cell still counts as a single pass). Leaving and re-entering
// the cell — or a new session — is a fresh entry. Much faster than a per-point SQL EXISTS.
use std::collections::HashMap;
let mut index: HashMap<(i64, i64), usize> = HashMap::new();
let mut cells: Vec<(f64, f64, String, u32)> = Vec::new(); // lat, lon, atype, passes
let mut prev_key: Option<(i64, i64)> = None;
let mut prev_session: i64 = i64::MIN;
for (i, (lat, lon, atype, session)) in points.iter().enumerate() {
if i % 20000 == 0 { let _ = app.emit("roads_progress", (i as u64, total as u64)); }
let key = ((lat / d_lat).floor() as i64, (lon / d_lon).floor() as i64);
let entry = prev_key != Some(key) || *session != prev_session;
match index.get(&key) {
Some(&idx) => { if entry { cells[idx].3 += 1; } }
None => { index.insert(key, cells.len()); cells.push((*lat, *lon, atype.clone(), 1)); }
}
prev_key = Some(key);
prev_session = *session;
}
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
{
let mut ins = db.prepare("INSERT INTO road_points (lat, lon, atype, passes) VALUES (?1, ?2, ?3, ?4)")
.map_err(|e| format!("prepare insert: {e}"))?;
for (lat, lon, atype, passes) in cells.iter() {
ins.execute(rusqlite::params![lat, lon, atype, passes])
.map_err(|e| format!("insert: {e}"))?;
}
}
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
let inserted = cells.len();
let _ = app.emit("roads_progress", (total as u64, total as u64)); // done
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", total, inserted))
}
// Read road_points in batches — bypasses Tauri SQL plugin's ~28K row limit
@ -337,20 +350,28 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul
let total: u32 = db.query_row(
"SELECT count(*) FROM road_points", [], |r| r.get(0)
).unwrap_or(0);
let mut stmt = db.prepare(
"SELECT lat, lon, COALESCE(atype,'bike') FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
).map_err(|e| format!("prepare: {e}"))?;
// `passes` column exists only after a rebuild on this build; tolerate older DBs (passes=1).
let has_passes: bool = db.query_row(
"SELECT COUNT(*) FROM pragma_table_info('road_points') WHERE name='passes'", [], |r| r.get::<_, i64>(0)
).map(|n| n > 0).unwrap_or(false);
let sql = if has_passes {
"SELECT lat, lon, COALESCE(atype,'bike'), COALESCE(passes,1) FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
} else {
"SELECT lat, lon, COALESCE(atype,'bike'), 1 FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
};
let mut stmt = db.prepare(sql).map_err(|e| format!("prepare: {e}"))?;
let mut points = Vec::new();
let rows = stmt.query_map(rusqlite::params![batch_size, offset], |row| {
let lat: f64 = row.get(0)?;
let lon: f64 = row.get(1)?;
let atype: String = row.get(2)?;
Ok((lat, lon, atype))
let passes: i64 = row.get(3)?;
Ok((lat, lon, atype, passes))
}).map_err(|e| format!("query: {e}"))?;
for row in rows {
let (lat, lon, atype) = row.map_err(|e| format!("row: {e}"))?;
let (lat, lon, atype, passes) = row.map_err(|e| format!("row: {e}"))?;
let t = if atype == "walk" { "w" } else if atype == "run" { "r" } else { "b" };
points.push(format!("[{:.7},{:.7},\"{}\"]", lat, lon, t));
points.push(format!("[{:.7},{:.7},\"{}\",{}]", lat, lon, t, passes));
}
let has_more = offset + batch_size < total;
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))