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:
4aiman 2026-10-23 01:11:15 +03:00
commit 4637846839
5 changed files with 117 additions and 68 deletions

View 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 (рисовать синхронно, слоёв теперь мало).

View file

@ -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
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.513"
version = "0.1.515"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.513"
version = "0.1.515"
edition = "2021"
[lib]

View file

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