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

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

View file

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