BRouter: offline route planning by waypoints + segment download UI

Stage 3 — planning: showRouteEditor gets a "🚲 По дорогам" toggle. Waypoints
stay editable markers; with it on, the line between them (and the saved
geometry) follows roads via BRouter (brouterMode/routeGeom, 450ms debounce on
edits). BRouter.route (router.js) now: Android -> embedded engine, otherwise
(desktop / at home) -> the PC RouteServer over HTTP (http_request bypasses the
WebView CSP; LAN 192.168.1.18:17777 / WAN 77.66.192.45:17777). Lets planning be
debugged on the desktop.

Stage 2 — segment UI: a "Сегменты" button on the roads screen opens
showBrouterSegments — region tiles (E35_N45/E40_N45), "есть" status, a
Download/Update button with percent progress via BRouter.downloadTileAndWait.

Verified: RouteServer returns 83 trkpt for a 3-waypoint trekking route (3594 m),
parseRouteGPX reads trkpt; both files compile into the apk. End-to-end UI (tap
waypoints -> routed line; in-app segment download) pending desktop/phone run.
This commit is contained in:
4aiman 2026-09-09 17:33:11 +03:00
commit 8e3c9fd4b6
4 changed files with 126 additions and 23 deletions

View file

@ -53,7 +53,9 @@ app data dir; профиль trekking (идея 2) и shortest (идея 1); л
listTiles отдаёт оба. Прежний стоп на 7922 байтах был из-за того, что BalDav не входил в
сплит-туннель устройства (НЕ сеть/код), — исправлено на устройстве. Загрузчик сделан устойчивым к
рывковой связи: HTTP Range-докачка (продолжает с места обрыва), readTimeout 60с, до 30 попыток.
ОСТАЛОСЬ: UI-экран выбора/загрузки тайлов по региону.
UI СДЕЛАН (экран «Сегменты» на карте дорог → модалка `showBrouterSegments`: тайлы региона
E35_N45/E40_N45, статус «есть», кнопка Скачать/Обновить с прогрессом через
BRouter.downloadTileAndWait). Компилируется; загрузку с зеркала на устройстве проверить при телефоне.
ЗЕРКАЛО (по решению пользователя, 2026-09-09): данные качаются НЕ с brouter.de напрямую, а со
своего зеркала. Источники в загрузчике по порядку: LAN `http://192.168.1.1:88/brouter/segments4`
@ -74,7 +76,13 @@ app data dir; профиль trekking (идея 2) и shortest (идея 1); л
- Фолбэк на прямую линию, если движок/сегмент недоступны.
**Success:** тап точек даёт маршрут по дорогам с профилем высоты; сохраняется как маршрут.
**Tests:** спланировать знакомый маршрут офлайн, сверить с онлайн-BRouter (та же геометрия).
**Status:** not started
**Status:** СДЕЛАНО (компилируется; backend роутинга проверен). В `showRouteEditor` кнопка-тумблер
«🚲 По дорогам»: waypoints остаются редактируемыми маркерами, линия между ними прокладывается
BRouter'ом (`brouterMode`/`routeGeom`, debounce 450мс), сохраняется дорожная геометрия. Роутинг —
`BRouter.route` (router.js): Android → встроенный движок, иначе (десктоп/дом) → PC-RouteServer по
HTTP (`http_request`, мимо CSP; хосты LAN 192.168.1.18:17777 / WAN 77.66.192.45:17777). Проверено:
RouteServer на 3 точки trekking отдал 83 trkpt (3594м); `parseRouteGPX` читает trkpt. ОСТАЛОСЬ:
сквозной прогон в UI (тап точек → дорожная линия) — на десктопе/телефоне.
## Stage 4 — Идея 1: заполнение GPS-пропусков по дороге
**Goal:** GPS-разрыв заполняется дорожным путём (shortest), с фолбэком на хорду.

View file

@ -1169,6 +1169,7 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
.poi-row { display: block; font-size: 13px; color: var(--on-bg); padding: 2px 0; cursor: pointer; white-space: nowrap; }
.poi-head { font-weight: 600; border-bottom: 1px solid var(--border); margin-bottom: 4px; padding-bottom: 4px; }
.poi-btn.active { background: rgba(255,255,255,0.5); color: #1a1a1a; }
#re-brouter.active { background: #4caf50; color: #fff; }
/* Always-on compass needle: red half = North, grey = South. Rotates to point to real north.
Top-RIGHT (zoom controls are top-left), below the status-bar safe area. */
/* Position/direction puck (SVG divIcon) — no default leaflet-div-icon box. */

View file

@ -3490,6 +3490,10 @@ async function showRouteEditor(container, routeId) {
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
let markers = [];
let polyline = null;
// BRouter "along roads" mode: waypoints stay in `points`; when on, the line between
// them (and the saved geometry) follows roads via the offline engine. routeGeom = the
// dense routed [{lat,lon}] (null = straight through waypoints).
let brouterMode = false, routeGeom = null, routeTimer = null;
container.innerHTML = `
<div class="draw-screen">
@ -3500,6 +3504,7 @@ async function showRouteEditor(container, routeId) {
</div>
<div class="rec-bottom">
<span class="prod-hint" style="flex:1;align-self:center;line-height:1.2">тап карты — точка · тап линии — вставить · тащи — двигать · тап точки — удалить</span>
<button id="re-brouter" class="btn-secondary" style="padding:8px 12px" title="Прокладывать по дорогам (BRouter)">🚲 По дорогам</button>
<button id="re-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
<button id="re-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
<button id="re-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
@ -3520,14 +3525,15 @@ async function showRouteEditor(container, routeId) {
html: `<div style="width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 2px #000"></div>`,
iconSize: [16, 16], iconAnchor: [8, 8],
});
function currentPts() { return (brouterMode && routeGeom) ? routeGeom : points; }
function updateStats() {
let dist = 0;
for (let i = 1; i < points.length; i++) dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
let g = currentPts(), dist = 0;
for (let i = 1; i < g.length; i++) dist += GPS.distance(g[i-1].lat, g[i-1].lon, g[i].lat, g[i].lon);
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
function updatePolyline() {
let latlngs = points.map(p => [p.lat, p.lon]);
let latlngs = currentPts().map(p => [p.lat, p.lon]);
if (polyline) map.removeLayer(polyline);
if (latlngs.length >= 2) {
polyline = L.polyline(latlngs, { color, weight: 4, interactive: true }).addTo(map);
@ -3536,6 +3542,19 @@ async function showRouteEditor(container, routeId) {
polyline.on("click", (e) => { L.DomEvent.stop(e); insertAt(e.latlng); });
}
}
// Re-route the waypoints along roads (debounced). On success set routeGeom + redraw.
async function doBrouter() {
if (!brouterMode || points.length < 2) { routeGeom = null; updatePolyline(); updateStats(); return; }
try {
let gpx = await BRouter.route(points.map(p => [p.lon, p.lat]), "trekking");
let rp = GPS.parseRouteGPX(gpx);
routeGeom = (rp && rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon })) : null;
} catch (e) { routeGeom = null; showToast("BRouter: " + (e.message || e)); }
changed();
}
function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(doBrouter, 450); }
// A waypoint changed: straight placeholder now, then re-route (if on) shortly after.
function changed() { routeGeom = null; updatePolyline(); updateStats(); if (brouterMode && points.length >= 2) scheduleRoute(); }
// Draggable + deletable point marker. Markers stay parallel to `points` (same
// index) — drag updates points[idx], click (delete) splices both. Returns the
// marker; caller pushes (end) or splices (mid-insert) into `markers`.
@ -3543,15 +3562,15 @@ async function showRouteEditor(container, routeId) {
let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon });
m.on("drag", () => {
let idx = markers.indexOf(m);
if (idx >= 0) { points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; updatePolyline(); }
if (idx >= 0) { points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; routeGeom = null; updatePolyline(); }
});
m.on("dragend", updateStats);
m.on("dragend", changed);
m.on("click", (e) => {
L.DomEvent.stop(e); // don't fall through to a map-click (which would add a point)
let idx = markers.indexOf(m);
if (idx < 0) return;
points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m);
updatePolyline(); updateStats();
changed();
});
m.addTo(map);
return m;
@ -3574,12 +3593,12 @@ async function showRouteEditor(container, routeId) {
let np = { lat: latlng.lat, lon: latlng.lng };
points.splice(best + 1, 0, np);
markers.splice(best + 1, 0, makeMarker(np));
updatePolyline(); updateStats();
changed();
}
if (points.length) {
points.forEach(p => markers.push(makeMarker(p)));
updatePolyline(); updateStats();
changed();
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
@ -3589,30 +3608,39 @@ async function showRouteEditor(container, routeId) {
map.on("click", e => {
let p = { lat: e.latlng.lat, lon: e.latlng.lng };
points.push(p); markers.push(makeMarker(p));
updatePolyline(); updateStats();
changed();
});
document.querySelector("#re-undo").onclick = () => {
if (!points.length) return;
points.pop();
let m = markers.pop(); if (m) map.removeLayer(m);
updatePolyline(); updateStats();
changed();
};
document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container);
// "Along roads" toggle: route the waypoints via BRouter and draw/save the road path.
document.querySelector("#re-brouter").onclick = () => {
brouterMode = !brouterMode;
document.querySelector("#re-brouter").classList.toggle("active", brouterMode);
changed();
};
document.querySelector("#re-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let btn = document.querySelector("#re-save");
btn.disabled = true; btn.textContent = "Сохранение...";
// Save the routed geometry when "along roads" is on, else the raw waypoints.
let savePts = (brouterMode && routeGeom && routeGeom.length >= 2) ? routeGeom : points;
if (editing) {
await DB.updateRoutePoints(routeId, points);
await DB.updateRoutePoints(routeId, savePts);
showToast("Маршрут обновлён — загрузите высоты заново");
showRouteDetail(container, routeId);
} else {
let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить");
if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; }
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", points);
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", savePts);
showToast("Маршрут создан");
showRouteDetail(container, id);
}
@ -7340,6 +7368,41 @@ function formatDate(iso) {
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
// BRouter road-segment (.rd5) download manager. Tiles for offline routing are pulled
// from the mirror (LAN -> WAN -> brouter.de) by BRouter.downloadTileAndWait.
async function showBrouterSegments() {
let region = [
{ name: "E35_N45", label: "E35_N45 — Ростов, запад (35–40°E, 45–50°N)" },
{ name: "E40_N45", label: "E40_N45 — Новочеркасск, восток (40–45°E)" },
];
let haveSet = new Set();
try { (await BRouter.listTiles()).forEach(t => haveSet.add(t.name)); } catch (e) {}
let rows = region.map(t => {
let dl = haveSet.has(t.name);
return `<div class="bseg-row" data-tile="${t.name}" style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)">
<span style="flex:1;font-size:13px">${t.label}</span>
<span class="bseg-status" style="font-size:12px;color:var(--muted);min-width:70px;text-align:right">${dl ? "✓ есть" : ""}</span>
<button class="btn-secondary bseg-dl" data-tile="${t.name}" style="padding:6px 12px;font-size:12px">${dl ? "Обновить" : "Скачать"}</button>
</div>`;
}).join("");
showModal(`<h3>Сегменты дорог (BRouter)</h3>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Дорожные тайлы для оффлайн-роутинга. Качаются с зеркала (LAN→WAN→brouter.de), ~14–24 МБ каждый. Нужны один раз.</div>
${rows}`);
document.querySelectorAll(".bseg-dl").forEach(btn => {
btn.onclick = async () => {
let tile = btn.dataset.tile, row = btn.closest(".bseg-row"), st = row.querySelector(".bseg-status");
btn.disabled = true;
try {
await BRouter.downloadTileAndWait(tile, (b, tot) => {
st.textContent = tot ? Math.round(100 * b / tot) + "%" : (Math.round(b / 1024 / 1024) + " МБ");
});
st.textContent = "✓ есть"; btn.textContent = "Обновить";
} catch (e) { st.textContent = "ошибка"; showToast("Сегмент: " + (e.message || e)); }
btn.disabled = false;
};
});
}
async function showRoadsMap() {
let container = document.querySelector(".screen-content");
container.innerHTML = `
@ -7348,12 +7411,14 @@ async function showRoadsMap() {
<button id="roads-back" class="btn-secondary" style="padding:6px 12px;">Назад</button>
<span style="flex:1;font-weight:600;">Дороги</span>
<span id="roads-info" style="font-size:12px;color:var(--muted);"></span>
<button id="roads-segments" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Сегменты</button>
<button id="roads-rebuild" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Перестроить</button>
</div>
<div id="roads-map" style="flex:1;"></div>
</div>`;
document.querySelector("#roads-back").onclick = () => renderScreen("settings");
document.querySelector("#roads-segments").onclick = () => showBrouterSegments();
let mapEl = document.querySelector("#roads-map");
L.Icon.Default.imagePath = "lib/";

View file

@ -5,17 +5,46 @@
// profile — "trekking" (planning) | "shortest" (gap fill); default "trekking"
// Returns a GPX string (parse with parseRouteGPX) or throws with the engine's error.
// Needs the region .rd5 segments present on device (files/brouter/segments4).
// RouteServer fallback hosts (the PC BRouter server, reachable at home). Used when the
// embedded engine isn't available (desktop) or errors — lets planning work on the desktop
// too. http_request is Rust-native (ureq), so it's not subject to the WebView CSP.
window.BRouter = {
_servers: ["http://192.168.1.18:17777", "http://77.66.192.45:17777"],
async _routeServer(pts, profile) {
let lonlats = pts.map(p => p[0] + "," + p[1]).join("|");
for (let base of this._servers) {
try {
let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx";
let gpx = await window.__TAURI__.core.invoke("http_request", { url, method: "GET" });
if (gpx && gpx.indexOf("<gpx") >= 0) return gpx;
} catch (e) { /* try next server */ }
}
return null;
},
async route(points, profile) {
let lonlats = JSON.stringify((points || []).map(p => [p[0], p[1]]));
let raw = await window.__TAURI__.core.invoke("brouter_route", {
lonlats,
profile: profile || "trekking",
});
let res;
try { res = JSON.parse(raw); } catch (e) { throw new Error("brouter: bad result: " + raw); }
if (!res.ok) throw new Error("brouter: " + (res.error || "unknown error"));
return res.gpx;
let pts = (points || []).map(p => [p[0], p[1]]);
profile = profile || "trekking";
let isAndroid = /Android/i.test(navigator.userAgent || "");
// Desktop has no embedded engine (that's Kotlin/Android) — the native command would
// just time out; go straight to the PC RouteServer. Android uses the engine, and
// falls back to the RouteServer (reachable at home) only if the engine errors.
if (!isAndroid) {
let gpx = await this._routeServer(pts, profile);
if (gpx) return gpx;
throw new Error("brouter: RouteServer unreachable");
}
try {
let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile });
let res = JSON.parse(raw);
if (res.ok) return res.gpx;
throw new Error(res.error || "engine error");
} catch (e) {
let gpx = await this._routeServer(pts, profile);
if (gpx) return gpx;
throw new Error("brouter: " + (e.message || e));
}
},
// --- Segment (.rd5) delivery (Stage 2) — Kotlin downloads into files/brouter/segments4 ---