diff --git a/docs/session-logs/2026-09-30_gapfill-ridden-router.md b/docs/session-logs/2026-09-30_gapfill-ridden-router.md new file mode 100644 index 0000000..aeb7d2a --- /dev/null +++ b/docs/session-logs/2026-09-30_gapfill-ridden-router.md @@ -0,0 +1,30 @@ +# Сессия 2026-09-30 - достройка пропусков предпочитает проеханные дороги + +## Проблема (замечание пользователя) +Достройка отказывалась мостить разрыв там, где реально проезжал (есть два проезда), с +сообщением "больше нужного"/"нет данных". Причина: `GPS.fillGaps` звал только публичный BRouter +"shortest" по OSM и ничего не знал о собственных треках пользователя. В проекте уже есть router +по проеханным дорогам (`rideRoute`, `get_ride_graph`), которым пользуется редактор маршрутов, но +достройка к нему не была подключена. Отсюда: где ехал по тропе/грунту (нет в OSM) - публичный +BRouter либо в объезд > 2x, либо noroute. + +## Фикс +- `GPS.fillGaps(points, opts)` (gps.js): добавлен `opts.routeFn(a,b) -> {points, ridden}`; дефолт - + публичный BRouter. Для `ridden:true` (путь по проеханным дорогам) НЕ применяется гвард "объезд + > 2x" - проеханный путь это факт, а не крюк (`rideRoute` и так ограничивает себя 2.5x). Каждому + разрыву в `gaps[]` пишется `ridden`. +- Место вызова (main.js, деталь трека): `routeFn` = `rideRoute` сначала, публичный BRouter + fallback. Так: есть моя дорога -> по ней; нет -> публичное; фэйл (в объезд / нет данных) + остаётся, но с понятным сообщением. +- Сообщение (drawGapPatches + панель): скипы считаются раздельно (skipped-long / skipped-noroute) + и словами: "Не достроено: N (нет твоей проеханной дороги; K — публичный маршрут уводит в объезд, + L — нет дорожных данных)." + +## Оговорка +Если участок проезжен ТОЛЬКО в этой поездке (где сам разрыв - точек нет), в ride-графе его нет, +идёт fallback на публичный BRouter (как раньше). Ожидаемо: не ехал по другим - нельзя достроить. + +## Проверка +Синтаксис ок. Собрать + телефон: на воскресном 60-км нажать 🧩, убедиться, что разрывы по +проеханным дорогам теперь достраиваются (голубым по реальной дороге), а сообщение по недостроенным +- понятное. Отдельно: где не ехал - остаётся честный отказ. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index b9ca8e1..f44db62 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -1598,6 +1598,13 @@ GPS.fillGaps = async function(points, opts) { let minChord = opts.minChordM ?? 150; // and only when the endpoints are genuinely far apart let ratio = opts.maxRatio ?? 2; // road > 2× chord = big detour, likely wrong → keep straight let minGain = opts.minGainM ?? 40; // road ≈ chord = nothing to gain → keep straight + // Router for one gap: the caller can prefer already-ridden roads (returns {points, ridden:true} + // when the bridge runs over the user's own tracks); default is the public BRouter shortest. + // A ridden path is what actually happened, so it is NOT subject to the "detour too long" guard. + let routeFn = opts.routeFn || (async (a, b) => { + let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); + return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false }; + }); let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); let out = []; let gaps = []; @@ -1609,16 +1616,17 @@ GPS.fillGaps = async function(points, opts) { let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon); if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime(); - let routed = [], status = "skipped-noroute", addedM = 0; + let routed = [], status = "skipped-noroute", addedM = 0, ridden = false; try { - let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); - let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; + let r = await routeFn(a, b); + let rp = (r && r.points) || []; + ridden = !!(r && r.ridden); if (rp.length >= 3) { 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); addedM = Math.max(0, rlen - chord); - if (rlen > chord * ratio) { - status = "skipped-long"; // road detoured too far — keep the chord + if (!ridden && rlen > chord * ratio) { + status = "skipped-long"; // PUBLIC route detoured too far — keep the chord (a ridden path is trusted as-is) } else if (addedM < minGain) { status = "skipped-nogain"; // road ≈ straight — nothing to bridge } else { @@ -1632,7 +1640,7 @@ GPS.fillGaps = async function(points, opts) { } } } catch (e) { status = "skipped-noroute"; } - gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord }); + gaps.push({ a: { lat: a.lat, lon: a.lon }, b: { lat: b.lat, lon: b.lon }, routed, status, addedM, chordM: chord, ridden }); } return { points: out, gaps }; }; diff --git a/frontend/js/main.js b/frontend/js/main.js index e6b874c..6a93b88 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5465,7 +5465,7 @@ async function showSessionDetail(sessionId) { if (gapBtn) gapBtn.classList.remove("active"); } function drawGapPatches(gaps) { - let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0; + let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0, skippedLong = 0, skippedNoroute = 0; for (let g of gaps) { if (g.status === "filled") { filledN++; addedM += g.addedM; @@ -5477,12 +5477,13 @@ async function showSessionDetail(sessionId) { // a real gap we couldn't bridge — mark it red so the user knows it's unfilled. // "skipped-nogain" (road ≈ straight) is fine as-is → not drawn, not counted. skippedN++; + if (g.status === "skipped-long") skippedLong++; else skippedNoroute++; L.polyline([[g.a.lat, g.a.lon], [g.b.lat, g.b.lon]], { color: "#e53935", weight: 3, opacity: 0.9, dashArray: "6,6" }).addTo(group); } } group.addTo(map); gapLayer = group; - return { filledN, skippedN, addedM }; + return { filledN, skippedN, addedM, skippedLong, skippedNoroute }; } function panel(html) { let el = document.createElement("div"); @@ -5525,13 +5526,27 @@ async function showSessionDetail(sessionId) { // Compute preview. b.disabled = true; b.classList.add("active"); try { - let res = await GPS.fillGaps(points); + // Prefer bridging a dropout over roads YOU actually rode (ride-graph); fall back to + // the public BRouter only where you've never ridden. A ridden bridge is trusted (no + // "detour too long" rejection); public routing keeps that guard. + let res = await GPS.fillGaps(points, { + routeFn: async (a, b) => { + try { let r = await rideRoute(a.lat, a.lon, b.lat, b.lon); if (r && r.length >= 2) return { points: r, ridden: true }; } catch {} + try { let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false }; } catch { return { points: [], ridden: false }; } + } + }); if (!res.gaps.length) { showToast("Пропусков для достройки нет"); b.classList.remove("active"); b.disabled = false; return; } gapCache = res; let s = drawGapPatches(res.gaps); if (!s.filledN && !s.skippedN) { showToast("Пропусков для достройки нет"); clearGapPreview(); b.disabled = false; return; } let km = (s.addedM / 1000).toFixed(2); - gapPanel = panel(`
Достроено пропусков: ${s.filledN} (+${km} км).${s.skippedN ? " Не удалось: " + s.skippedN + " (дорога слишком длинная или нет данных)." : ""}
Так трек будет выглядеть после сохранения.
+ // Clear reason for the ones we couldn't bridge: they aren't on roads you've ridden, + // and the public router either detours far (skipped-long) or has no data (noroute). + let why = []; + if (s.skippedLong) why.push(`${s.skippedLong} — публичный маршрут уводит в объезд`); + if (s.skippedNoroute) why.push(`${s.skippedNoroute} — нет дорожных данных`); + let skippedTxt = s.skippedN ? ` Не достроено: ${s.skippedN} (нет твоей проеханной дороги${why.length ? "; " + why.join(", ") : ""}).` : ""; + gapPanel = panel(`
Достроено пропусков: ${s.filledN} (+${km} км).${skippedTxt}
Так трек будет выглядеть после сохранения.
`); gapPanel.querySelector("#gap-cancel").onclick = () => clearGapPreview(); gapPanel.querySelector("#gap-save").onclick = async () => {