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 МБ каждый. Нужны один раз.