diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d5c3d59..b2d6bbc 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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), с фолбэком на хорду. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 4baebda..a844dae 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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. */ diff --git a/frontend/js/main.js b/frontend/js/main.js index 77c1494..e5ebb7c 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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 = `
@@ -3500,6 +3504,7 @@ async function showRouteEditor(container, routeId) {
тап карты — точка · тап линии — вставить · тащи — двигать · тап точки — удалить + @@ -3520,14 +3525,15 @@ async function showRouteEditor(container, routeId) { html: `
`, 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 `
+ ${t.label} + ${dl ? "✓ есть" : ""} + +
`; + }).join(""); + showModal(`

Сегменты дорог (BRouter)

+
Дорожные тайлы для оффлайн-роутинга. Качаются с зеркала (LAN→WAN→brouter.de), ~14–24 МБ каждый. Нужны один раз.
+ ${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() { Дороги +
`; document.querySelector("#roads-back").onclick = () => renderScreen("settings"); + document.querySelector("#roads-segments").onclick = () => showBrouterSegments(); let mapEl = document.querySelector("#roads-map"); L.Icon.Default.imagePath = "lib/"; diff --git a/frontend/js/router.js b/frontend/js/router.js index 5322e18..2a04dca 100644 --- a/frontend/js/router.js +++ b/frontend/js/router.js @@ -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("= 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 ---