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 mirror.invalid: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:
parent
542bb5c376
commit
8e720ecd17
4 changed files with 126 additions and 23 deletions
|
|
@ -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 mirror.invalid:17777). Проверено:
|
||||
RouteServer на 3 точки trekking отдал 83 trkpt (3594м); `parseRouteGPX` читает trkpt. ОСТАЛОСЬ:
|
||||
сквозной прогон в UI (тап точек → дорожная линия) — на десктопе/телефоне.
|
||||
|
||||
## Stage 4 — Идея 1: заполнение GPS-пропусков по дороге
|
||||
**Goal:** GPS-разрыв заполняется дорожным путём (shortest), с фолбэком на хорду.
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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/";
|
||||
|
|
|
|||
|
|
@ -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://mirror.invalid: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 ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue