From e40f6e25b73949c783ddd655a283ed21571c43f7 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Sat, 12 Sep 2026 23:05:58 +0300 Subject: [PATCH] Reroute: rejoin the active route past a blockage from the current position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2. Recording-map button ↪ builds a detour from the current GPS position back ONTO the active route past the blockage: reference = nearest nogo on the route (else current pos), target = a route point ~1.5km further (steps further on failure to the route end), avoiding nogos and 180° turns (bearing helper). Drawn as a transient magenta navigation overlay with the rejoin point; long-press the button clears it. Does not modify the saved route or interrupt recording. --- README.md | 1 + docs/session-logs/2026-09-12_nogo-reroute.md | 19 ++++- frontend/js/main.js | 86 ++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ebb34a..432174a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - **Оффлайн-роутинг (BRouter)** — движок BRouter встроен в приложение (Android): маршруты строятся по дорогам без сети, сегменты `.rd5` региона качаются с домашнего зеркала прямо в приложении (экран дорог → «Сегменты»). Используется в редакторе маршрутов («По дорогам») и для заполнения GPS-пропусков. На десктопе/дома движка нет — те же вызовы уходят на домашний BRouter-RouteServer по HTTP. - **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами. - **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам. +- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей. - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. - **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог. - **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки. diff --git a/docs/session-logs/2026-09-12_nogo-reroute.md b/docs/session-logs/2026-09-12_nogo-reroute.md index f0038ef..7edbaa7 100644 --- a/docs/session-logs/2026-09-12_nogo-reroute.md +++ b/docs/session-logs/2026-09-12_nogo-reroute.md @@ -29,5 +29,22 @@ - ДЕСКТОП-ТЕСТ планировщика (nogo обходится визуально) — следующий шаг. - Встроенный движок (офлайн) — после `./build.sh android -d -i`, на теле. +## Проверка Стадии 1 (десктоп Xvfb + RouteServer) +- Планировщик: 2 точки → дорожная нога; ⛔ → тап по линии ставит красный круг → нога + перестроилась в обход (nogo в БД: 47.4238,40.087,25м). Тестовые nogo/маршруты убраны. +- RouteServer `&nogos`: 2873м → 3329м. Коммит `1c326ab`. + +## Сделано — Стадия 2 (объезд с возвратом на маршрут) +- `rejoinReroute(cur, bearing, routePts, nogos)` (main.js): опорная точка затыка = ближайший + nogo на маршруте (≤150м), иначе текущая позиция; цель = точка маршрута на +1.5 км вперёд, + шаг +1.5 км до конца при неудаче; помощник по курсу (~40 м) чтобы не разворачиваться; принять, + если длина ≤ max(500, 3× прямой). Не меняет сохранённый маршрут. +- Карта записи: кнопка «↪» — строит объезд от `lastMovingPoint` к точке возврата, рисует ярким + оверлеем (magenta с бело-чёрной оковкой) + метка возврата; долгий тап убирает. + +## Проверка Стадии 2 +- Синтаксис ок. Алгоритм `rejoinReroute` проверить харнессом против RouteServer (десктоп). +- Полный прогон на ходу + встроенный движок офлайн — на теле после `./build.sh android -d -i`. + ## Дальше -- Стадия 2: объезд с возвратом на маршрут на карте записи (`rejoinReroute` + кнопка «объехать»). +- Сборка/установка на тело — по явной просьбе. Реальная поездка: пометить непроезжее, «↪». diff --git a/frontend/js/main.js b/frontend/js/main.js index e98f2d9..b80a6ee 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3196,6 +3196,56 @@ async function getNogosForRouter() { return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 })); } +// Move a point `d` metres along bearing `brDeg` (for a "start forward, don't U-turn" helper). +function _offsetLatLon(lat, lon, brDeg, d) { + let R = 6378137, br = brDeg * Math.PI / 180, la1 = lat * Math.PI / 180, lo1 = lon * Math.PI / 180; + let la2 = Math.asin(Math.sin(la1) * Math.cos(d / R) + Math.cos(la1) * Math.sin(d / R) * Math.cos(br)); + let lo2 = lo1 + Math.atan2(Math.sin(br) * Math.sin(d / R) * Math.cos(la1), Math.cos(d / R) - Math.sin(la1) * Math.sin(la2)); + return { lat: la2 * 180 / Math.PI, lon: lo2 * 180 / Math.PI }; +} + +// On-the-fly detour: from the current position, build a connector back ONTO the saved route +// PAST the blockage, avoiding nogos, preferring to start in the travel direction (no 180 if a +// bearing is given). Does NOT modify the saved route. Blockage reference = nearest nogo that +// sits on the route (<=150 m), else the current position's nearest route point. Target = a +// route point ~1.5 km further along; on failure step further (up to the route end). +// Returns { points:[{lat,lon}], target:{lat,lon} } or null. +async function rejoinReroute(cur, bearingDeg, routePts, nogos) { + if (!cur || !routePts || routePts.length < 2) return null; + let cum = [0]; + for (let i = 1; i < routePts.length; i++) cum[i] = cum[i-1] + GPS.distance(routePts[i-1].lat, routePts[i-1].lon, routePts[i].lat, routePts[i].lon); + let nearestRouteDist = (pt) => { let d = Infinity; for (let p of routePts) { let dd = GPS.distance(pt.lat, pt.lon, p.lat, p.lon); if (dd < d) d = dd; } return d; }; + // reference point of the blockage + let ref = cur; + if (nogos && nogos.length) { + let best = null, bestD = Infinity; + for (let n of nogos) if (nearestRouteDist(n) <= 150) { let dc = GPS.distance(cur.lat, cur.lon, n.lat, n.lon); if (dc < bestD) { bestD = dc; best = n; } } + if (best) ref = best; + } + let refIdx = 0, refD = Infinity; + for (let i = 0; i < routePts.length; i++) { let d = GPS.distance(ref.lat, ref.lon, routePts[i].lat, routePts[i].lon); if (d < refD) { refD = d; refIdx = i; } } + let helper = (bearingDeg != null && !isNaN(bearingDeg)) ? _offsetLatLon(cur.lat, cur.lon, bearingDeg, 40) : null; + let skip = 1500; + for (let attempt = 0; attempt < 8; attempt++) { + let want = cum[refIdx] + skip, ti = refIdx; + while (ti < routePts.length - 1 && cum[ti] < want) ti++; + let target = routePts[ti]; + let straight = GPS.distance(cur.lat, cur.lon, target.lat, target.lon); + let wps = helper ? [[cur.lon, cur.lat], [helper.lon, helper.lat], [target.lon, target.lat]] : [[cur.lon, cur.lat], [target.lon, target.lat]]; + try { + let gpx = await BRouter.route(wps, "trekking", nogos); + let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; + if (rp.length >= 2) { + let rlen = 0; for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon); + if (rlen <= Math.max(500, straight * 3)) return { points: rp.map(p => ({ lat: p.lat, lon: p.lon })), target: { lat: target.lat, lon: target.lon } }; + } + } catch (e) { /* try a further rejoin point */ } + if (ti >= routePts.length - 1) break; + skip += 1500; + } + return null; +} + // Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries // kept — one colour per segment): green -> yellow -> red -> brown -> black. // <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude. @@ -4323,6 +4373,42 @@ async function renderRecordingInline(container) { }; btnWrap.appendChild(recNogoBtn); + // Reroute back onto the active route past a blockage (nogo), from the current position, + // avoiding nogos and 180° turns. Transient navigation overlay — does NOT touch the saved + // route or the recording. Tap again to rebuild from the new position; long-press to clear. + let rerouteLayer = null; + let rerouteBtn = document.createElement("button"); + rerouteBtn.className = "rec-map-btn"; + rerouteBtn.textContent = "↪"; + rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом"; + rerouteBtn.onclick = async () => { + let rid = localStorage.getItem("baldav_active_route"); + if (!rid) { showToast("Нет активного маршрута"); return; } + let cur = GPS.recording.lastMovingPoint; + if (!cur) { showToast("Нет позиции"); return; } + rerouteBtn.disabled = true; rerouteBtn.classList.add("active"); + try { + let route = await DB.getRoute(rid); + if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); return; } + let ng = await getNogosForRouter(); + let bearing = (typeof smoothedHeading === "number") ? smoothedHeading : null; + let res = await rejoinReroute(cur, bearing, route.points, ng); + if (!res) { showToast("Объезд не найден"); rerouteBtn.classList.remove("active"); return; } + if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; } + let ll = res.points.map(p => [p.lat, p.lon]); + let g = L.layerGroup(); + L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(g); + L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(g); + L.polyline(ll, { color: "#ff00ff", weight: 3, opacity: 1 }).addTo(g); + L.circleMarker([res.target.lat, res.target.lon], { radius: 7, color: "#ff00ff", fillColor: "#fff", fillOpacity: 1, weight: 3 }).addTo(g); + g.addTo(map); rerouteLayer = g; + showToast("Объезд построен"); + } catch (e) { showToast("Объезд: " + (e.message || e)); } + finally { rerouteBtn.disabled = false; } + }; + rerouteBtn.oncontextmenu = (e) => { e.preventDefault(); if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; rerouteBtn.classList.remove("active"); showToast("Объезд убран"); } }; + btnWrap.appendChild(rerouteBtn); + POI.attach(map); // POI overlay + category filter on the recording map // The default-ON draw is deferred to the END of this function (after all the // critical wiring), where it awaits whenMapReady — so it can't delay setup.