Gap-fill: bridge dropouts over your own ridden roads first, public BRouter only as fallback

fillGaps used the public BRouter shortest only, so a dropout where you rode
a trail/track absent from OSM was left unbridged (detour >2x or noroute),
even though your own track went straight through. Add opts.routeFn: the
detail view now tries rideRoute (the ride-graph over your tracks) first and
falls back to public BRouter only where you've never ridden. A ridden bridge
is trusted as-is (no detour-too-long rejection); public routing keeps that
guard. Clearer 'not bridged' message split by reason (public detour vs no
road data).
This commit is contained in:
4aiman 2026-09-30 00:23:28 +03:00
commit 93908e3365
3 changed files with 63 additions and 10 deletions

View file

@ -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-км нажать 🧩, убедиться, что разрывы по
проеханным дорогам теперь достраиваются (голубым по реальной дороге), а сообщение по недостроенным
- понятное. Отдельно: где не ехал - остаётся честный отказ.

View file

@ -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 };
};

View file

@ -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(`<div class="gap-panel-txt">Достроено пропусков: <b>${s.filledN}</b> (+${km} км).${s.skippedN ? " Не удалось: <b>" + s.skippedN + "</b> (дорога слишком длинная или нет данных)." : ""}<br><span class="gap-panel-hint">Так трек будет выглядеть после сохранения.</span></div>
// 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 ? ` Не достроено: <b>${s.skippedN}</b> (нет твоей проеханной дороги${why.length ? "; " + why.join(", ") : ""}).` : "";
gapPanel = panel(`<div class="gap-panel-txt">Достроено пропусков: <b>${s.filledN}</b> (+${km} км).${skippedTxt}<br><span class="gap-panel-hint">Так трек будет выглядеть после сохранения.</span></div>
<div class="gap-panel-btns"><button class="btn-primary" id="gap-save">Сохранить</button><button class="btn-secondary" id="gap-cancel">Отмена</button></div>`);
gapPanel.querySelector("#gap-cancel").onclick = () => clearGapPreview();
gapPanel.querySelector("#gap-save").onclick = async () => {