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:
parent
1cb6efbee3
commit
76efc9238b
3 changed files with 108 additions and 52 deletions
|
|
@ -1599,7 +1599,7 @@ async function getRoadPoints() {
|
|||
while (true) {
|
||||
const raw = await invoke("get_road_points", { offset, batchSize: BATCH });
|
||||
const resp = JSON.parse(raw);
|
||||
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b" });
|
||||
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b", passes: p[3] || 1 });
|
||||
console.log(`[getRoadPoints] batch offset=${offset} got=${resp.points.length} total_db=${resp.total}`);
|
||||
if (!resp.has_more) break;
|
||||
offset += BATCH;
|
||||
|
|
|
|||
|
|
@ -3318,6 +3318,40 @@ function roadColor(atype) {
|
|||
return c.roadColorBike || "#2266aa";
|
||||
}
|
||||
|
||||
// Line thickness by how often a stretch was ridden (road_points.passes from rebuild_roads):
|
||||
// base width x (1..3), +0.2 per extra pass, capped at 3x. Optional — disabled by the flag.
|
||||
function roadsThickOn() { return localStorage.getItem("baldav_roads_thick") !== "0"; }
|
||||
function roadWeight(passes, base) {
|
||||
base = base || 3;
|
||||
if (!roadsThickOn()) return base;
|
||||
return base * Math.min(3, 1 + 0.2 * ((passes || 1) - 1));
|
||||
}
|
||||
// Draw one road segment (array of {lat,lon,passes}), split into runs of equal thickness so
|
||||
// often-ridden parts read thicker. Each run bridges the boundary point so the line stays
|
||||
// continuous across a thickness change. `extra`: { pane, renderer, base, opacity, smoothFactor }.
|
||||
function drawRoadSegment(map, seg, color, out, extra) {
|
||||
if (!seg || seg.length < 2) return;
|
||||
extra = extra || {};
|
||||
let base = extra.base || 3;
|
||||
let opts = { color, opacity: extra.opacity != null ? extra.opacity : 0.6 };
|
||||
if (extra.pane) opts.pane = extra.pane;
|
||||
if (extra.renderer) opts.renderer = extra.renderer;
|
||||
if (extra.smoothFactor != null) opts.smoothFactor = extra.smoothFactor;
|
||||
if (!roadsThickOn()) {
|
||||
out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight: base }).addTo(map));
|
||||
return;
|
||||
}
|
||||
let run = [seg[0]];
|
||||
let runW = roadWeight(seg[0].passes, base);
|
||||
let flush = () => { if (run.length >= 2) out.push(L.polyline(run.map(p => [p.lat, p.lon]), { ...opts, weight: runW }).addTo(map)); };
|
||||
for (let i = 1; i < seg.length; i++) {
|
||||
let w = roadWeight(seg[i].passes, base);
|
||||
if (Math.abs(w - runW) > 0.01) { flush(); run = [seg[i - 1], seg[i]]; runW = w; }
|
||||
else run.push(seg[i]);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
// Resolve once the Leaflet map has finished its initial layout, so layers added
|
||||
// after this actually render (adding a polyline before the map is loaded/sized
|
||||
// leaves it invisible until the next redraw). Awaits the real 'load' event — the
|
||||
|
|
@ -4858,19 +4892,13 @@ async function renderRecordingInline(container) {
|
|||
let dlat = (pts[i].lat - prev.lat) * 111000;
|
||||
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) {
|
||||
if (seg.length >= 2) {
|
||||
let color = roadColor(seg[0].atype);
|
||||
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
|
||||
}
|
||||
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
|
||||
seg = [];
|
||||
}
|
||||
}
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
if (seg.length >= 2) {
|
||||
let color = roadColor(seg[0].atype);
|
||||
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
|
||||
}
|
||||
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
|
||||
};
|
||||
btnWrap.appendChild(roadsBtn);
|
||||
// Auto-load roads if was enabled
|
||||
|
|
@ -7327,6 +7355,10 @@ async function renderSettings(container) {
|
|||
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: бег</div></div>
|
||||
<input id="s-road-color-run" type="color" value="${GPS.config.roadColorRun || '#e67e22'}" style="width:40px;height:28px;border:none;background:none;padding:0">
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left"><div class="settings-item-title">Толщина дорог по проездам</div><div class="settings-item-desc">Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)</div></div>
|
||||
<input id="s-roads-thick" type="checkbox" ${localStorage.getItem("baldav_roads_thick") !== "0" ? "checked" : ""}>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Fused-локация (Google Play)</div>
|
||||
|
|
@ -7724,6 +7756,8 @@ async function renderSettings(container) {
|
|||
GPS.config.roadColorBike = container.querySelector("#s-road-color-bike").value;
|
||||
GPS.config.roadColorWalk = container.querySelector("#s-road-color-walk").value;
|
||||
GPS.config.roadColorRun = container.querySelector("#s-road-color-run").value;
|
||||
let roadsThickEl = container.querySelector("#s-roads-thick");
|
||||
if (roadsThickEl) localStorage.setItem("baldav_roads_thick", roadsThickEl.checked ? "1" : "0");
|
||||
let trackColorEl = container.querySelector("#s-track-color");
|
||||
if (trackColorEl) GPS.config.trackColor = trackColorEl.value;
|
||||
let fusedEl = container.querySelector("#s-use-fused");
|
||||
|
|
@ -8801,36 +8835,37 @@ async function showRoadsMap() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Split points into segments by gap (>100m) and activity type
|
||||
let segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
|
||||
// Split points into segments by gap (>100m) and activity type. Keep point objects (with
|
||||
// `passes`) so line thickness can follow how often each stretch was ridden.
|
||||
let segments = []; // { pts: [{lat,lon,passes}], atype: "b"|"w" }
|
||||
let seg = [];
|
||||
let segType = pts[0]?.atype || "b";
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
let newType = pts[i].atype || "b";
|
||||
if (seg.length > 0) {
|
||||
let prev = seg[seg.length - 1];
|
||||
let dlat = (pts[i].lat - prev[0]) * 111000;
|
||||
let dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
let dlat = (pts[i].lat - prev.lat) * 111000;
|
||||
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||||
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
|
||||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||||
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
|
||||
seg = [];
|
||||
segType = newType;
|
||||
}
|
||||
}
|
||||
seg.push([pts[i].lat, pts[i].lon]);
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||||
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
|
||||
|
||||
// Draw: bike = blue, walk = brown. Roads go in their own pane with a dedicated canvas
|
||||
// renderer so the dark-map CSS filter (body.dark-map .leaflet-roadsPane) can invert/shift
|
||||
// road colours the same way it does the tiles — keeps them visible on the dark map.
|
||||
// Draw: bike = blue, walk = brown, thickness by pass count. Roads go in their own pane with
|
||||
// a dedicated canvas renderer so the dark-map CSS filter (body.dark-map .leaflet-roadsPane)
|
||||
// can invert/shift road colours the same way it does the tiles — keeps them visible on dark.
|
||||
if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; }
|
||||
let roadsRenderer = L.canvas({ pane: "roadsPane" });
|
||||
let drawn = [];
|
||||
let allCoords = [];
|
||||
for (let s of segments) {
|
||||
let color = roadColor(s.atype);
|
||||
L.polyline(s.coords, { color, weight: 4, opacity: 1, smoothFactor: 2.5, renderer: roadsRenderer }).addTo(map); // px-space simplify: fewer points zoomed out
|
||||
allCoords.push(...s.coords);
|
||||
drawRoadSegment(map, s.pts, roadColor(s.atype), drawn, { renderer: roadsRenderer, base: 4, opacity: 1, smoothFactor: 2.5 });
|
||||
for (let p of s.pts) allCoords.push([p.lat, p.lon]);
|
||||
}
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue