diff --git a/docs/session-logs/2026-10-23_roads-poi-viewport-culling.md b/docs/session-logs/2026-10-23_roads-poi-viewport-culling.md new file mode 100644 index 0000000..5810d80 --- /dev/null +++ b/docs/session-logs/2026-10-23_roads-poi-viewport-culling.md @@ -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 (рисовать синхронно, слоёв теперь мало). diff --git a/frontend/js/main.js b/frontend/js/main.js index d6591b6..3e41af5 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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) 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})`; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 72e4f31..f42ed4e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.513" +version = "0.1.515" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c245a1a..202a156 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.513" +version = "0.1.515" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4c43581..bb6f7a2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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"