diff --git a/frontend/js/db.js b/frontend/js/db.js index 364f7a0..eda3781 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -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; diff --git a/frontend/js/main.js b/frontend/js/main.js index 2c9e41e..b012414 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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) {
Цвет дорог: бег
+
+
Толщина дорог по проездам
Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)
+ +
Fused-локация (Google Play)
@@ -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) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2145bd..08cd2b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -265,59 +265,72 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result= ?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))