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:
parent
3e2a6e47f7
commit
93908e3365
3 changed files with 63 additions and 10 deletions
30
docs/session-logs/2026-09-30_gapfill-ridden-router.md
Normal file
30
docs/session-logs/2026-09-30_gapfill-ridden-router.md
Normal 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-км нажать 🧩, убедиться, что разрывы по
|
||||
проеханным дорогам теперь достраиваются (голубым по реальной дороге), а сообщение по недостроенным
|
||||
- понятное. Отдельно: где не ехал - остаётся честный отказ.
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue