From cfe0212bbc40d753a390873b9eb187d6d8f0a5e5 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 10 Sep 2026 15:26:05 +0300 Subject: [PATCH 1/2] BRouter UX: gap-fill preview+reversible save; per-segment route planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap-fill (track detail): GPS.fillGaps now returns {points,gaps} with per-gap status; the 🧩 control previews ONLY the filled dropouts (cyan casing; unfilled = red dashed) with a Save/Cancel panel. Save is a reversible in-place patch (estimated points added, original untouched; DB.saveGapFill/revertGapFill + estimated/gap_filled columns), then recalculateSession. Revert removes them. Route planner: renamed menu 'Нарисовать маршрут' → 'Нарисовать тренировку' (it makes a workout session, not a route); routes empty-state now points at the on-phone planner. showRouteEditor rewritten per-leg: each leg road (BRouter) or straight independently, road by default, tap a leg to toggle it, long-press to insert, silent straight fallback where no road; the global button sets all legs. --- PROJECT_MAP.md | 4 +- README.md | 4 +- .../2026-09-10-brouter-ux-gapfill-planner.md | 132 ++++++++ .../2026-09-10_brouter-ux-gapfill-planner.md | 44 +++ frontend/css/styles.css | 22 ++ frontend/js/db.js | 35 ++ frontend/js/gps.js | 53 ++- frontend/js/main.js | 309 +++++++++++++----- 8 files changed, 496 insertions(+), 107 deletions(-) create mode 100644 docs/plans/2026-09-10-brouter-ux-gapfill-planner.md create mode 100644 docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 4dd7750..89beeee 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -22,8 +22,8 @@ | Файл | Строк | Назначение | |------|-------|------------| | `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; ` — Без маршрута — -
${routes.length ? rows : `
Нет маршрутов. Импортируйте GPX-файл — линию можно нарисовать заранее (напр. в Яндекс.Конструкторе карт).
`}
+
${routes.length ? rows : `
Нет маршрутов. Нажмите «Новый маршрут» — тапайте по карте опорные точки, линия проложится по дорогам (BRouter) прямо в телефоне. Или импортируйте готовый GPX.
`}
`; container.querySelector("#routes-back").onclick = () => renderScreen("gps"); @@ -3489,11 +3489,16 @@ async function showRouteEditor(container, routeId) { let points = (editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : []; 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; + // Per-leg road routing: each leg between consecutive waypoints is independently + // "road" (BRouter geometry cached in legGeom[i]) or "straight". Default = road; tap a + // leg to toggle just that leg; a road leg that can't be routed falls back to straight + // (so leading to where there is no road still draws a straight line, silently). + let legLines = []; // L.polyline per leg (parallel to legs) + let legMode = []; // "road" | "straight", length = points.length - 1 + let legGeom = []; // routed [{lat,lon}] per road leg, or null + let defaultMode = "road"; // applied to new legs and by the global toggle + let routeTimer = null; + let dirty = new Set(); // leg indices awaiting a (debounced) route container.innerHTML = `
@@ -3503,8 +3508,8 @@ async function showRouteEditor(container, routeId) {
0Точек
- тап карты — точка · тап линии — вставить · тащи — двигать · тап точки — удалить - + тап карты — точка · тап отрезка — дорога/прямая · долгий тап — вставить · тап точки — удалить + @@ -3525,116 +3530,154 @@ async function showRouteEditor(container, routeId) { html: `
`, iconSize: [16, 16], iconAnchor: [8, 8], }); - function currentPts() { return (brouterMode && routeGeom) ? routeGeom : points; } + + function normalizeLegs() { + let need = Math.max(0, points.length - 1); + while (legMode.length < need) { legMode.push(defaultMode); legGeom.push(null); } + legMode.length = need; legGeom.length = need; + } + // Geometry drawn/saved for a leg: routed pts for a routed road leg, else the straight + // chord (also the fallback when a road leg couldn't be routed). + function legPts(i) { + if (legMode[i] === "road" && legGeom[i] && legGeom[i].length >= 2) return legGeom[i]; + return [points[i], points[i + 1]]; + } + // Full dense geometry = concat of every leg (shared junction point not duplicated). + function fullGeom() { + if (points.length < 2) return points.map(p => ({ lat: p.lat, lon: p.lon })); + let out = [{ lat: points[0].lat, lon: points[0].lon }]; + for (let i = 0; i < points.length - 1; i++) { + let lp = legPts(i); + for (let k = 1; k < lp.length; k++) out.push({ lat: lp[k].lat, lon: lp[k].lon }); + } + return out; + } function updateStats() { - let g = currentPts(), dist = 0; + let g = fullGeom(), 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 = 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); - // Tap the LINE to insert a point at that spot — a distinct target from - // markers/empty-map, so it doesn't clash with add-at-end / drag / delete. - polyline.on("click", (e) => { L.DomEvent.stop(e); insertAt(e.latlng); }); + function drawLegs() { + legLines.forEach(l => map.removeLayer(l)); legLines = []; + for (let i = 0; i < points.length - 1; i++) { + let routed = legMode[i] === "road" && legGeom[i] && legGeom[i].length >= 2; + let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]), { + color, weight: 4, interactive: true, + dashArray: legMode[i] === "straight" ? "6,8" : null, + opacity: legMode[i] === "straight" ? 0.9 : (routed ? 1 : 0.5), + }).addTo(map); + (function (idx) { + line.on("click", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // tap → road/straight + line.on("contextmenu", (e) => { L.DomEvent.stop(e); insertInLeg(idx, e.latlng); }); // long-press → insert + })(i); + legLines.push(line); } } - // 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) || {}).points || []; - routeGeom = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon })) : null; - } catch (e) { routeGeom = null; showToast("BRouter: " + (e.message || e)); } - // Draw the routed geometry directly. NOT changed() — that nulls routeGeom and - // re-schedules doBrouter, looping forever and never showing the road line. - updatePolyline(); updateStats(); + function markDirty(i) { if (i >= 0 && i < points.length - 1) dirty.add(i); } + function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(routeDirty, 450); } + async function routeDirty() { + let idxs = [...dirty]; dirty.clear(); + for (let i of idxs) { + if (i >= points.length - 1 || legMode[i] !== "road") continue; + try { + let gpx = await BRouter.route([[points[i].lon, points[i].lat], [points[i+1].lon, points[i+1].lat]], "trekking"); + let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; + legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon })) : null; + } catch (e) { legGeom[i] = null; } // silent fallback to straight (no road / no segments) + } + drawLegs(); updateStats(); } - 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`. + function toggleLeg(i) { + if (i < 0 || i >= points.length - 1) return; + legMode[i] = (legMode[i] === "road") ? "straight" : "road"; + if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); scheduleRoute(); } + drawLegs(); updateStats(); + } + function afterEdit() { normalizeLegs(); drawLegs(); updateStats(); scheduleRoute(); } + function makeMarker(p) { 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 }; routeGeom = null; updatePolyline(); } + let idx = markers.indexOf(m); if (idx < 0) return; + points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; + if (idx - 1 >= 0) legGeom[idx - 1] = null; + if (idx < points.length - 1) legGeom[idx] = null; + drawLegs(); 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); - changed(); + m.on("dragend", () => { + let idx = markers.indexOf(m); if (idx < 0) return; + markDirty(idx - 1); markDirty(idx); scheduleRoute(); }); + m.on("click", (e) => { L.DomEvent.stop(e); deletePoint(m); }); m.addTo(map); return m; } - // Insert a point where the line was tapped: pick the nearest segment [i,i+1] - // (planar lat/lon distance — fine at city scale) and splice at i+1. - function insertAt(latlng) { - if (points.length < 2) return; - let best = -1, bestD = Infinity; - for (let i = 0; i < points.length - 1; i++) { - let a = points[i], b = points[i + 1]; - let dx = b.lon - a.lon, dy = b.lat - a.lat; - let t = (dx || dy) ? ((latlng.lng - a.lon) * dx + (latlng.lat - a.lat) * dy) / (dx * dx + dy * dy) : 0; - t = Math.max(0, Math.min(1, t)); - let cx = a.lon + t * dx, cy = a.lat + t * dy; - let d = Math.hypot(latlng.lng - cx, latlng.lat - cy); - if (d < bestD) { bestD = d; best = i; } + function addPoint(latlng) { + points.push({ lat: latlng.lat, lon: latlng.lng }); + markers.push(makeMarker(points[points.length - 1])); + if (points.length >= 2) { let li = points.length - 2; legMode[li] = defaultMode; legGeom[li] = null; markDirty(li); } + afterEdit(); + } + function insertInLeg(legIdx, latlng) { + if (legIdx < 0 || legIdx >= points.length - 1) return; + let mode = legMode[legIdx] ?? defaultMode; + points.splice(legIdx + 1, 0, { lat: latlng.lat, lon: latlng.lng }); + markers.splice(legIdx + 1, 0, makeMarker(points[legIdx + 1])); + legMode.splice(legIdx, 1, mode, mode); // one leg → two, same mode + legGeom.splice(legIdx, 1, null, null); + markDirty(legIdx); markDirty(legIdx + 1); + afterEdit(); + } + function deletePoint(m) { + let idx = markers.indexOf(m); if (idx < 0) return; + points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m); + if (legMode.length) { + if (idx === 0) { legMode.splice(0, 1); legGeom.splice(0, 1); } + else if (idx > points.length - 1) { legMode.splice(idx - 1, 1); legGeom.splice(idx - 1, 1); } + else { let mode = legMode[idx - 1] ?? defaultMode; legMode.splice(idx - 1, 2, mode); legGeom.splice(idx - 1, 2, null); markDirty(idx - 1); } } - if (best < 0) return; - let np = { lat: latlng.lat, lon: latlng.lng }; - points.splice(best + 1, 0, np); - markers.splice(best + 1, 0, makeMarker(np)); - changed(); + afterEdit(); } + // Existing route: load its saved dense points as waypoints, legs straight (reproduce + // the saved shape as-is); legs added later default to road. if (points.length) { points.forEach(p => markers.push(makeMarker(p))); - changed(); + for (let i = 0; i < points.length - 1; i++) { legMode[i] = "straight"; legGeom[i] = null; } + normalizeLegs(); drawLegs(); updateStats(); 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), () => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 }); } - map.on("click", e => { - let p = { lat: e.latlng.lat, lon: e.latlng.lng }; - points.push(p); markers.push(makeMarker(p)); - changed(); - }); + map.on("click", e => addPoint(e.latlng)); document.querySelector("#re-undo").onclick = () => { if (!points.length) return; - points.pop(); - let m = markers.pop(); if (m) map.removeLayer(m); - changed(); + let m = markers.pop(); points.pop(); if (m) map.removeLayer(m); + if (legMode.length) { legMode.pop(); legGeom.pop(); } + afterEdit(); }; 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(); + // Global toggle: set ALL legs (and the default for new ones) to road or straight. + let brBtn = document.querySelector("#re-brouter"); + brBtn.onclick = () => { + defaultMode = (defaultMode === "road") ? "straight" : "road"; + brBtn.classList.toggle("active", defaultMode === "road"); + for (let i = 0; i < legMode.length; i++) { legMode[i] = defaultMode; legGeom[i] = null; if (defaultMode === "road") markDirty(i); } + drawLegs(); updateStats(); if (defaultMode === "road") scheduleRoute(); }; 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; + let savePts = fullGeom(); // concat of all legs (road geometry where routed, else straight) + if (savePts.length < 2) { showToast("Маршрут пуст"); btn.disabled = false; btn.textContent = "Сохранить"; return; } if (editing) { await DB.updateRoutePoints(routeId, savePts); showToast("Маршрут обновлён — загрузите высоты заново"); @@ -4767,22 +4810,98 @@ async function showSessionDetail(sessionId) { addDarkMapControl(map); // dark-tiles toggle on the track view POI.attach(map); // POI overlay + category filter - // Gap-fill overlay: route GPS dropouts along roads (BRouter shortest) and draw the - // filled path as a cyan dashed overlay over the track. Non-destructive (no data change). - let gapFillLayer = null; + // Gap-fill: route GPS dropouts along roads (BRouter) and PREVIEW only the filled gaps + // (not the whole track); then save (reversible, in-place) or discard. If the track is + // already gap-filled, the button offers to remove the fill. + let gapLayer = null; // L.layerGroup of the preview patches + let gapPanel = null; // bottom preview panel (Save/Cancel) + let gapCache = null; // { points } from fillGaps, kept for save + let gapBtn = null; + function clearGapPreview() { + if (gapLayer) { map.removeLayer(gapLayer); gapLayer = null; } + if (gapPanel) { gapPanel.remove(); gapPanel = null; } + gapCache = null; + if (gapBtn) gapBtn.classList.remove("active"); + } + function drawGapPatches(gaps) { + let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0; + for (let g of gaps) { + if (g.status === "filled") { + filledN++; addedM += g.addedM; + let ll = [[g.a.lat, g.a.lon]].concat(g.routed.map(p => [p.lat, p.lon]), [[g.b.lat, g.b.lon]]); + L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(group); // casing + L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(group); + L.polyline(ll, { color: "#00e5ff", weight: 3, opacity: 1 }).addTo(group); // core + } else { + skippedN++; + 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 }; + } + function panel(html) { + let el = document.createElement("div"); + el.className = "gap-panel"; + el.innerHTML = html; + document.body.appendChild(el); + return el; + } let GapCtl = L.Control.extend({ options: { position: "topright" }, onAdd: function () { let b = L.DomUtil.create("button", "rec-map-btn"); - b.textContent = "🧩"; b.title = "Достроить пропуски по дорогам"; b.style.position = "static"; + gapBtn = b; + b.textContent = "🧩"; + b.title = session.gap_filled ? "Убрать достройку пропусков" : "Достроить пропуски по дорогам"; + b.style.position = "static"; + if (session.gap_filled) b.classList.add("active"); L.DomEvent.disableClickPropagation(b); b.onclick = async () => { - if (gapFillLayer) { map.removeLayer(gapFillLayer); gapFillLayer = null; b.classList.remove("active"); return; } + if (gapPanel) { clearGapPreview(); return; } // toggle panel off + + // Already filled → offer revert. + if (session.gap_filled) { + b.classList.add("active"); + gapPanel = panel(`
Трек достроен по дорогам (голубые участки).
Можно убрать и вернуть исходный трек.
+
`); + gapPanel.querySelector("#gap-close").onclick = () => clearGapPreview(); + gapPanel.querySelector("#gap-revert").onclick = async () => { + let rb = gapPanel.querySelector("#gap-revert"); rb.disabled = true; rb.textContent = "Убираю..."; + try { + await DB.revertGapFill(sessionId); + await GPS.recalculateSession(sessionId, { noDelete: true }); + showToast("Достройка убрана"); + clearGapPreview(); showSessionDetail(sessionId); + } catch (e) { showToast("Откат: " + (e.message || e)); rb.disabled = false; rb.textContent = "Убрать достройку"; } + }; + return; + } + + // Compute preview. b.disabled = true; b.classList.add("active"); try { - let filled = await GPS.fillGaps(points); - gapFillLayer = L.polyline(filled.map(p => [p.lat, p.lon]), { color: "#00e5ff", weight: 3, opacity: 0.9, dashArray: "6,6" }).addTo(map); - } catch (e) { showToast("Пропуски: " + (e.message || e)); b.classList.remove("active"); } + let res = await GPS.fillGaps(points); + 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 + " (дорога слишком длинная или нет данных)." : ""}
Так трек будет выглядеть после сохранения.
+
`); + gapPanel.querySelector("#gap-cancel").onclick = () => clearGapPreview(); + gapPanel.querySelector("#gap-save").onclick = async () => { + if (!s.filledN) { showToast("Нечего сохранять"); return; } + let sv = gapPanel.querySelector("#gap-save"); sv.disabled = true; sv.textContent = "Сохранение..."; + try { + await DB.saveGapFill(sessionId, gapCache.points); + await GPS.recalculateSession(sessionId, { noDelete: true }); + showToast("Пропуски достроены"); + clearGapPreview(); showSessionDetail(sessionId); + } catch (e) { showToast("Сохранение: " + (e.message || e)); sv.disabled = false; sv.textContent = "Сохранить"; } + }; + } catch (e) { showToast("Пропуски: " + (e.message || e)); clearGapPreview(); } b.disabled = false; }; return b; @@ -4957,6 +5076,22 @@ async function showSessionDetail(sessionId) { L.polyline(gl, { color: GPS.config.trackColor || "#bb86fc", weight: 3, opacity: 0.5, dashArray: "8, 8" }).addTo(map); } + // Mark BRouter gap-fill runs (estimated points) in cyan so saved dropout-fills stay + // visible — each run bracketed by the adjacent real points so it connects to the track. + { + let run = []; + let flush = () => { if (run.length >= 2) L.polyline(run, { color: "#00e5ff", weight: 4, opacity: 0.95 }).addTo(map); run = []; }; + for (let i = 0; i < points.length; i++) { + if (points[i].estimated) { + if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]); + run.push([points[i].lat, points[i].lon]); + } else if (run.length) { + run.push([points[i].lat, points[i].lon]); flush(); + } + } + flush(); + } + // Invisible but tappable hit-targets on top of track for (let i = 0; i < points.length; i++) { let p = points[i]; From ceee0a48eb5274bc17d43f68c8b3fe7e240ffffd Mon Sep 17 00:00:00 2001 From: 4aiman Date: Thu, 10 Sep 2026 15:51:34 +0300 Subject: [PATCH 2/2] gap-fill: reload detail via reopenDetail (in-place), not a stacked screen Found in desktop testing: showSessionDetail() from the save/revert handlers pushed a second detail overlay whose map rendered blank. reopenDetail() collapses the stack and re-renders in place (the pattern recalc/refine already use). Session log updated with the desktop verification results. --- .../2026-09-10_brouter-ux-gapfill-planner.md | 30 ++++++++++++++----- frontend/js/main.js | 4 +-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md b/docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md index ccc8775..3f234db 100644 --- a/docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md +++ b/docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md @@ -33,12 +33,28 @@ Доки: README (редактор, гэп-филл), PROJECT_MAP (db/gps методы, колонки). -## Проверка -- Синтаксис gps.js/db.js/main.js — OK (node --check). const в новых функциях — нет (правило проекта). -- ДЕСКТОП-ТЕСТ на Xvfb со скринами — СЛЕДУЮЩИЙ шаг (ещё не гонял на момент записи). -- На устройстве встроенным движком — после `./build.sh install -d`. +## Проверка (десктоп, Xvfb :99 + домашний RouteServer) +- Синтаксис gps.js/db.js/main.js — OK (node --check). const в новых функциях — нет. +- Гэп-филл на сессии 1782115144011 (4 дропаута): 🧩 → предпросмотр ТОЛЬКО пропусков + (голубой с оковкой) + панель «Достроено 4 (+1.75 км)» + Сохранить/Отмена. Сохранить → + 88 estimated-точек в БД, gap_filled=1, петля замкнута по дорогам, деталь перерисовалась. + «Убрать достройку» → estimated=0, gap_filled=0, исходный трек вернулся, карта отрисовалась. +- БАГ, найденный тестом: `showSessionDetail(id)` в обработчиках ЗАТАЛКИВАЛ второй экран поверх + → у него пустая карта. Исправлено на `reopenDetail(id)` (схлопывает стек, рендер на месте) — + отдельным коммитом. Проверено: после save/revert карта рисуется. +- Планировщик: 3 точки по региону → обе ноги легли ПО ДОРОГАМ (2.26 км). Глобальная кнопка + «По дорогам» переключает все ноги на прямые (пунктир, 2.87 км) и обратно — рисование/пересчёт/ + состояние кнопки ОК. По-сегментный тап по линии слепой мышью на headless не попал в 4px-линию + (каждый промах добавлял точку — у Leaflet-вектора bubblingMouseEvents=false, т.е. попадание по + линии точку бы не добавило); биндинг корректный, на тач-экране допуск больше — подтвердить на теле. +- Реальную стату десктоп-сессии, сдвинутую пересчётом при тесте, восстановил из старой копии БД. + +## Замечание к разбору (на будущее) +Дистанция сессии «плавает» при `recalculateSession` (сохранённая 16.24 → достройка 15.58 → +откат 18.25) — это поведение текущего `computeStats` (та же кнопка «Пересчитать» даёт то же), +но достройка ожидаемо должна УВЕЛИЧивать дистанцию. Стоит разобрать, как computeStats считает +дистанцию через (бывшие) гэпы, и сохранять исходные статы для точного отката. ## Дальше -- Прогнать на Xvfb: гэп-филл на треке с дропаутом (сессия 1782115144011) — предпросмотр только - пропусков, Сохранить, Убрать; планировщик — 3 точки, тап по отрезку переключает дорога/прямая. -- Затем сборка и проверка на теле офлайн. +- На теле встроенным движком офлайн: `./build.sh install -d`, докачать сегмент региона. + Проверить гэп-филл (предпросмотр/сохранить/убрать) и планировщик (тап по отрезку — тач). diff --git a/frontend/js/main.js b/frontend/js/main.js index 7002c14..abfb87f 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4873,7 +4873,7 @@ async function showSessionDetail(sessionId) { await DB.revertGapFill(sessionId); await GPS.recalculateSession(sessionId, { noDelete: true }); showToast("Достройка убрана"); - clearGapPreview(); showSessionDetail(sessionId); + clearGapPreview(); reopenDetail(sessionId); } catch (e) { showToast("Откат: " + (e.message || e)); rb.disabled = false; rb.textContent = "Убрать достройку"; } }; return; @@ -4898,7 +4898,7 @@ async function showSessionDetail(sessionId) { await DB.saveGapFill(sessionId, gapCache.points); await GPS.recalculateSession(sessionId, { noDelete: true }); showToast("Пропуски достроены"); - clearGapPreview(); showSessionDetail(sessionId); + clearGapPreview(); reopenDetail(sessionId); } catch (e) { showToast("Сохранение: " + (e.message || e)); sv.disabled = false; sv.textContent = "Сохранить"; } }; } catch (e) { showToast("Пропуски: " + (e.message || e)); clearGapPreview(); }