Roads overlay: one polyline per segment + viewport culling (fix jank)
The per-pass thickness split fragmented each segment into a run per weight change, turning a ~1.4k-segment network into ~84k polylines and janking the map. Draw one polyline per segment at its median-pass width instead (verified on device: 1430 vs 84083). Add buildRoadSegments (segment + bbox) and cullRoadsInto (draw only segments meeting the padded view, redraw on moveend/zoomend); the recording overlay now renders on a canvas pane instead of SVG, and showRoadsMap culls too. Thickness is now per-segment (median) rather than per-point — a deliberate simplification.
This commit is contained in:
parent
c7dd588f37
commit
4637846839
5 changed files with 117 additions and 68 deletions
36
docs/session-logs/2026-10-23_roads-poi-viewport-culling.md
Normal file
36
docs/session-logs/2026-10-23_roads-poi-viewport-culling.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# 2026-10-23 - Производительность: отсечение по видимой области для дорог и POI
|
||||
|
||||
## Контекст
|
||||
Жалоба: дороги (BalDav) и POI (brouter-web) тормозят WebView. Задача - рисовать только то,
|
||||
что в кадре, не плодить огромную отрисовку.
|
||||
|
||||
## Сделано
|
||||
|
||||
### Дороги (BalDav, frontend/js/main.js) - в этом коммите
|
||||
- Настоящая причина лагов - правка толщины по проездам (прошлый коммит): drawRoadSegment дробил
|
||||
каждый сегмент на run'ы по изменению веса -> 1430 сегментов превращались в ~84083 полилинии.
|
||||
Исправлено: один сегмент = одна линия, толщина по МЕДИАННОМУ числу проездов сегмента. Проверено
|
||||
на устройстве (0.1.515): 1430 вместо 84083.
|
||||
- Добавлены помощники buildRoadSegments (сегменты + bbox) и cullRoadsInto (отсечение по
|
||||
getBounds().pad), debounce. Экран записи переведён с SVG на canvas-пан; showRoadsMap и запись
|
||||
рисуют только видимые сегменты, перерисовка на moveend/zoomend.
|
||||
- Упрощение (объявлено): толщина теперь на уровне сегмента (медиана), не по каждой точке.
|
||||
|
||||
### POI (brouter-web, tools/brouter-web-staging + /var/www/brouter-web) - вне git приложения
|
||||
- Отсечение по видимой области + порог зума на категорию (магазины z14, еда/туалеты/аптеки/
|
||||
интересное z13, вода/АЗС/навесы z12, веломаг z11), предохранитель 800. Перекультивация на move.
|
||||
- Проверено в браузере: магазины 17313 -> z15:192, z16:33; пан меняет счёт.
|
||||
|
||||
## Методическая ошибка (для истории)
|
||||
Сначала гонял отсечение дорог по устройству через CDP и сделал вывод «не работает». Это артефакт:
|
||||
WebView был в visibilityState=hidden (экран гас), а скрытая страница тормозит setTimeout, на
|
||||
котором висит перерисовка. В реальном использовании (visible) идёт. Проверять надо в том же
|
||||
состоянии экземпляра.
|
||||
|
||||
## Проверка
|
||||
- Устройство 0.1.515: showRoadsMap -> 1430 полилиний (было 84083). Пан/зум плавность - проверять
|
||||
руками на visible-экране.
|
||||
- brouter-web: CDP, счётчики маркеров по зумам, пан.
|
||||
|
||||
## Дальше
|
||||
- Если на записи ещё лагает - можно убрать debounce (рисовать синхронно, слоёв теперь мало).
|
||||
|
|
@ -3336,9 +3336,11 @@ function roadWeight(passes, base) {
|
|||
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 }.
|
||||
// Draw one road segment (array of {lat,lon,passes}) as ONE polyline, its width from the
|
||||
// segment's MEDIAN pass count. Earlier this split each segment into a run per weight change,
|
||||
// which exploded a 1.4k-segment network into ~84k polylines and janked the map; one width per
|
||||
// segment keeps it at one polyline per segment while still reading thicker on often-ridden
|
||||
// routes. `extra`: { pane, renderer, base, opacity, smoothFactor }.
|
||||
function drawRoadSegment(map, seg, color, out, extra) {
|
||||
if (!seg || seg.length < 2) return;
|
||||
extra = extra || {};
|
||||
|
|
@ -3347,19 +3349,55 @@ function drawRoadSegment(map, seg, color, out, extra) {
|
|||
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 weight = base;
|
||||
if (roadsThickOn()) {
|
||||
let ps = seg.map(p => p.passes || 1).sort((a, b) => a - b);
|
||||
weight = roadWeight(ps[ps.length >> 1], base); // median pass → one width per segment
|
||||
}
|
||||
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]);
|
||||
out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight }).addTo(map));
|
||||
}
|
||||
|
||||
function debounce(fn, ms) { let t; return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); }; }
|
||||
|
||||
// Split flat road points into segments (break on >100 m gap or activity-type change) and
|
||||
// precompute each segment's bbox [minLat,minLon,maxLat,maxLon] for viewport culling.
|
||||
function buildRoadSegments(pts) {
|
||||
let segs = [], seg = [];
|
||||
const push = () => {
|
||||
if (seg.length >= 2) {
|
||||
let minLa = Infinity, maxLa = -Infinity, minLo = Infinity, maxLo = -Infinity;
|
||||
for (let p of seg) {
|
||||
if (p.lat < minLa) minLa = p.lat; if (p.lat > maxLa) maxLa = p.lat;
|
||||
if (p.lon < minLo) minLo = p.lon; if (p.lon > maxLo) maxLo = p.lon;
|
||||
}
|
||||
segs.push({ pts: seg, atype: seg[0].atype, b: [minLa, minLo, maxLa, maxLo] });
|
||||
}
|
||||
seg = [];
|
||||
};
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
if (seg.length > 0) {
|
||||
let prev = seg[seg.length - 1];
|
||||
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) push();
|
||||
}
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
push();
|
||||
return segs;
|
||||
}
|
||||
|
||||
// Viewport culling: clear `out` layers, then draw ONLY segments whose bbox meets the padded
|
||||
// view. Keeps the drawn set to a screenful instead of the whole (possibly huge) road network.
|
||||
function cullRoadsInto(map, segments, out, pad, extra) {
|
||||
out.forEach(l => map.removeLayer(l)); out.length = 0;
|
||||
let v = map.getBounds().pad(pad);
|
||||
let S = v.getSouth(), N = v.getNorth(), W = v.getWest(), E = v.getEast();
|
||||
for (let s of segments) {
|
||||
let bb = s.b; // [minLat,minLon,maxLat,maxLon]
|
||||
if (bb[2] < S || bb[0] > N || bb[1] > E || bb[3] < W) continue; // bbox fully outside view
|
||||
drawRoadSegment(map, s.pts, roadColor(s.atype), out, extra);
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
// Resolve once the Leaflet map has finished its initial layout, so layers added
|
||||
|
|
@ -4877,38 +4915,34 @@ async function renderRecordingInline(container) {
|
|||
};
|
||||
btnWrap.appendChild(followBtn);
|
||||
|
||||
// Roads toggle button
|
||||
// Roads toggle button. Roads are viewport-culled onto a canvas pane (only the segments in
|
||||
// view are drawn, redrawn on pan/zoom) so a huge road network doesn't jank the WebView.
|
||||
let roadsBtn = document.createElement("button");
|
||||
roadsBtn.className = "rec-map-btn rec-roads-toggle";
|
||||
roadsBtn.textContent = "R";
|
||||
let roadLayers = [];
|
||||
let roadSegs = null, roadsOn = false, roadsRenderer = null;
|
||||
let redrawRoads = () => { if (roadsOn && roadSegs) cullRoadsInto(map, roadSegs, roadLayers, 0.2, { pane: "roadsPane", renderer: roadsRenderer }); };
|
||||
let roadsMove = debounce(redrawRoads, 120);
|
||||
roadsBtn.onclick = async () => {
|
||||
if (roadLayers.length > 0) {
|
||||
roadLayers.forEach(l => map.removeLayer(l));
|
||||
roadLayers = [];
|
||||
if (roadsOn) {
|
||||
roadsOn = false;
|
||||
map.off("moveend zoomend", roadsMove);
|
||||
roadLayers.forEach(l => map.removeLayer(l)); roadLayers.length = 0;
|
||||
roadsBtn.classList.remove("active");
|
||||
localStorage.setItem("baldav_show_roads", "0");
|
||||
return;
|
||||
}
|
||||
roadsOn = true;
|
||||
roadsBtn.classList.add("active");
|
||||
localStorage.setItem("baldav_show_roads", "1");
|
||||
// Roads live in their own pane BELOW route/track: tiles(200)<roads(240)<route(250)<track(400).
|
||||
if (!map.getPane("roadsPane")) { map.createPane("roadsPane", map._rotatePane || undefined); map.getPane("roadsPane").style.zIndex = 240; }
|
||||
let pts = await DB.getRoadPoints();
|
||||
let seg = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
if (seg.length > 0) {
|
||||
let prev = seg[seg.length - 1];
|
||||
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) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
|
||||
seg = [];
|
||||
}
|
||||
}
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
if (seg.length >= 2) drawRoadSegment(map, seg, roadColor(seg[0].atype), roadLayers, { pane: "roadsPane" });
|
||||
if (!roadsRenderer) roadsRenderer = L.canvas({ pane: "roadsPane", padding: 0.2 });
|
||||
if (!roadSegs) { let pts = await DB.getRoadPoints(); roadSegs = buildRoadSegments(pts); }
|
||||
if (!roadsOn) return; // toggled off while loading
|
||||
redrawRoads();
|
||||
map.on("moveend zoomend", roadsMove);
|
||||
};
|
||||
btnWrap.appendChild(roadsBtn);
|
||||
// Auto-load roads if was enabled
|
||||
|
|
@ -8854,42 +8888,21 @@ async function showRoadsMap() {
|
|||
return;
|
||||
}
|
||||
|
||||
// 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.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({ pts: seg, atype: segType });
|
||||
seg = [];
|
||||
segType = newType;
|
||||
}
|
||||
}
|
||||
seg.push(pts[i]);
|
||||
}
|
||||
if (seg.length >= 2) segments.push({ pts: seg, atype: segType });
|
||||
// Split into segments (with bbox) for viewport culling; thickness follows pass count.
|
||||
let segments = buildRoadSegments(pts);
|
||||
|
||||
// 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.
|
||||
// Roads in their own canvas pane so the dark-map CSS filter (body.dark-map .leaflet-roadsPane)
|
||||
// can invert/shift road colours like the tiles. Only segments in view are drawn, redrawn on move.
|
||||
if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; }
|
||||
let roadsRenderer = L.canvas({ pane: "roadsPane" });
|
||||
let roadsRenderer = L.canvas({ pane: "roadsPane", padding: 0.2 });
|
||||
let drawn = [];
|
||||
let allCoords = [];
|
||||
for (let s of segments) {
|
||||
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]);
|
||||
}
|
||||
let drawExtra = { renderer: roadsRenderer, base: 4, opacity: 1, smoothFactor: 2.5 };
|
||||
let redraw = () => cullRoadsInto(map, segments, drawn, 0.2, drawExtra);
|
||||
map.on("moveend zoomend", debounce(redraw, 120));
|
||||
|
||||
if (pts.length > 0) map.fitBounds(L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.05), { animate: false });
|
||||
redraw();
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
|
||||
}
|
||||
let walkSegs = segments.filter(s => s.atype === "w").length;
|
||||
let bikeSegs = segments.length - walkSegs;
|
||||
infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`;
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.513"
|
||||
version = "0.1.515"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.513"
|
||||
version = "0.1.515"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.513",
|
||||
"version": "0.1.515",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue