BRouter UX: gap-fill preview+reversible save; per-segment route planner

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.
This commit is contained in:
4aiman 2026-09-10 15:26:05 +03:00
commit cfe0212bbc
8 changed files with 496 additions and 107 deletions

View file

@ -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` не осталось; `<select>` оставлены). `orient-dbg` (сенсор-оверлей на карте) — только при `window.__BALDAV_DEBUG`. `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints` — сохранить высоты, `getRoutes.hasEle`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера |
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `config.gpxzKey` — ключ GPXZ (localStorage) |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints` — сохранить высоты, `getRoutes.hasEle`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled` |
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) |
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |

View file

@ -17,9 +17,9 @@
- **Ручные маршруты** — рисование трека на карте тапами, elevation из прошлых треков или open-meteo API.
- **Сохранённые маршруты** — импорт GPX-маршрутов, нарисованных заранее (например, в конструкторе Яндекс.Карт: формат `<rtept>`), выбор активного, наложение цветной линией поверх карты во время записи И на экране просмотра прошлого трека — для визуальной навигации. Управление на отдельной странице (импорт/переименование/цвет/удаление, один активный за раз), переключение линии кнопкой-тогглом. Цвета «проезженных дорог» настраиваются раздельно для вело/пеших/бега.
- **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон дискретными полосами по 1% (зелёный→жёлтый→красный→коричневый→чёрный, ≥10% чёрный) или гипсометрия по высоте (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека и во время записи; маршрут рисуется ПОД треком. Высоты (`<ele>` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM).
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, тап по линии — вставить точку в это место, тащить точку — двигать, тап по точке — удалить. Высоты подтягиваются онлайн после отрисовки (из предпросмотра). Тумблер **«🚲 По дорогам»** прокладывает линию между опорными точками по реальным дорогам (BRouter, профиль trekking) и сохраняет дорожную геометрию; выключен — прямые отрезки.
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, тащить точку — двигать, тап по точке — удалить, долгий тап по отрезку — вставить точку. Высоты подтягиваются онлайн после отрисовки (из предпросмотра). Роутинг **по-сегментный**: каждый отрезок между опорными точками отдельно либо по дорогам (BRouter, профиль trekking), либо прямой — **тап по отрезку** переключает именно его; по дорогам по умолчанию, где дороги/сегментов нет — молча прямая. Кнопка **«🚲 По дорогам»** задаёт режим сразу для всех отрезков. Сохраняется итоговая геометрия.
- **Оффлайн-роутинг (BRouter)** — движок BRouter встроен в приложение (Android): маршруты строятся по дорогам без сети, сегменты `.rd5` региона качаются с домашнего зеркала прямо в приложении (экран дорог → «Сегменты»). Используется в редакторе маршрутов («По дорогам») и для заполнения GPS-пропусков. На десктопе/дома движка нет — те же вызовы уходят на домашний BRouter-RouteServer по HTTP.
- **Заполнение GPS-пропусков по дороге** — на детали трека тумблер **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и рисует его голубым пунктиром поверх трека; если дорога в разы длиннее хорды — оставляет хорду. Оверлей, данные трека не меняет.
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Если дорога в разы длиннее хорды — пропуск не достраивается.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.

View file

@ -0,0 +1,132 @@
# План: BRouter на телефоне — понятный гэп-филл и планировщик маршрутов
## Context (зачем)
BRouter уже встроен и роутит, но две фичи на телефоне непонятны в использовании:
1. **Заполнение GPS-пропусков (кнопка 🧩 на детали трека).** Сейчас `GPS.fillGaps`
возвращает ВЕСЬ трек с дорисованными пропусками, а `GapCtl` рисует его целиком
(`main.js:4784`) — поэтому голубая линия ложится поверх всего трека, даже там, где GPS
был. Не видно, где именно BRouter достроил, непонятно, что это станет треком, и НЕТ
сохранения/отмены. Нужно: тап кнопки → предпросмотр ТОЛЬКО достроенных пропусков →
явная кнопка сохранить/отмена. Сохранение — **патч на месте, обратимо** (не новый трек).
2. **Планирование маршрута по точкам.** Оно уже есть: «Сохранённые маршруты» →
«Новый маршрут» → `showRouteEditor` с тумблером «🚲 По дорогам». Но пользователь его не
нашёл: в меню есть отдельный пункт «Нарисовать маршрут», который на деле рисует
ТРЕНИРОВКУ (сессию с калориями, `showDrawRouteScreen`), а пустой список маршрутов
советует импортировать GPX/рисовать в Яндексе. Плюс «По дорогам» — глобальный тумблер на
весь маршрут. Нужно: понятный вход + **по-сегментное** решение (по дорогам по умолчанию,
где дороги нет — прямая, ручной тумблер влияет на текущий отрезок).
Итог решений с пользователем: (1) патч на месте обратимо; (2) по-сегментный роутинг,
дорога по умолчанию, прямая где нет дороги, ручной тумблер на отрезок; (3) пункт меню
переименовать (функцию оставить).
## Часть A — Гэп-филл: предпросмотр только пропусков + обратимый патч на месте
### A1. `GPS.fillGaps` → богатый результат (`frontend/js/gps.js:1400`)
Сейчас возвращает плоский массив. Вернуть `{ points, gaps }`:
- `gaps`: `[{ aIdx, bIdx, routed:[{lat,lon}], addedM, chordM, status }]`,
где `status` = `"filled" | "skipped-long" | "skipped-noroute"` (сейчас пропуск молча
скипается — теперь причина известна для отчёта в UI).
- Достроенным точкам присваивать **интерполированные ts** по отрезку `[a.ts, b.ts]` (сейчас
всем ставится `a.ts`, что ломает порядок при чтении из БД) и флаг `estimated: true`.
- Обратная совместимость: единственный вызов — `GapCtl` (`main.js:4783`), правится тут же.
### A2. `GapCtl` — предпросмотр только пропусков + панель сохранить/отмена (`frontend/js/main.js:4772-4791`, `showSessionDetail`)
- По тапу: посчитать `fillGaps(points)`, нарисовать **только достроенные отрезки** (каждый
`routed` + его две реальные концевые точки) отдельным ярким стилем (голубая линия с
бело-чёрной оковкой, как маршрут в brouter-web) поверх исходного трека. Весь трек НЕ
перерисовывать.
- Пропуски со `status !== "filled"` пометить иначе (красный пунктир по хорде) — видно, что
их достроить не вышло.
- Показать панель-баннер: «Достроено N пропусков (+X.X км). M не удалось.» + подпись
«Так трек будет выглядеть после сохранения» + кнопки **[Сохранить]** и **[Отмена]**.
- **[Отмена]** — убрать оверлей и панель, ничего не менять.
- **[Сохранить]** — `DB.saveGapFill(sessionId, filledPoints)` → `GPS.recalculateSession(id,
{noDelete:true})` → перерисовать деталь. Если трек уже достроен — вместо этого показывать
**[Убрать достройку]** → `DB.revertGapFill(id)`.
- Найти в `showSessionDetail` имя переменной сессии/`sessionId` (рядом уже есть `points`).
### A3. БД: обратимое хранение (`frontend/js/db.js`)
- Миграции идемпотентным паттерном (как `db.js:1422-1433`):
`if (!gpsCols.includes("estimated")) ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0`;
`if (!gsCols.includes("gap_filled")) ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0`.
- `saveGapFill(sessionId, filledPoints)`: вставить только достроенные точки с `estimated=1`,
`seq = seq предыдущей реальной точки` (порядок держит `ORDER BY COALESCE(seq,999999), ts`
в `db.js:866` — совпадающий seq + интерполированный ts ставит их между концами), батч-
вставкой по образцу `importGpxSession`/`addGpsPointsBulk` (`db.js:751,874`). Выставить
`gps_sessions.gap_filled=1`. Оригинальные точки не трогаются.
- `revertGapFill(sessionId)`: `DELETE FROM gps_points WHERE session_id=? AND estimated=1`;
`gap_filled=0`. Затем пересчёт статы вызывающей стороной.
- Загрузчик `db.js:866` уже `SELECT *` — колонку `estimated` подхватит сам; убедиться, что
она мапится в `point.estimated`. `computeStats` уже трактует `.estimated`
(`gps.js:241`) — доп. правок в расчёте не нужно.
- Продуктовый `seed_meta.version` НЕ трогаем (это пользовательская БД, не products_seed).
### A4. Отрисовка трека и стиль (`frontend/js/main.js:~4912`, `frontend/css/styles.css`)
- В отрисовке трека детали пометить `estimated`-точки/отрезки отдельным стилем, чтобы после
сохранения было видно, какие участки достроены.
- CSS: стиль голубой линии достройки с бело-чёрной оковкой + стиль панели предпросмотра.
## Часть B — Планировщик: понятный вход + по-сегментный роутинг
### B1. Переименовать пункт меню (`frontend/js/main.js:2733`)
`«Нарисовать маршрут»` → `«Нарисовать тренировку»` (он сохраняет сессию через
`showDrawRouteScreen`, а не маршрут). Функцию оставить.
### B2. Подсказка в пустом списке маршрутов (`frontend/js/main.js:3261`)
Текст сейчас зовёт импортировать GPX/рисовать в Яндексе. Дописать, что «Новый маршрут» —
это планирование прямо в телефоне по дорогам (BRouter), тапами по карте.
### B3. `showRouteEditor` — по-сегментный роутинг (`frontend/js/main.js:3486-3649`)
Заменить глобальные `brouterMode` + единый `routeGeom` на модель по отрезкам:
- Состояние: `waypoints[]`, `legMode[i]` (`"road"` по умолчанию | `"straight"`),
`legGeom[i]` (кэш дорожной геометрии отрезка).
- Каждый отрезок между соседними точками роутить `BRouter.route([wp[i],wp[i+1]],
"trekking")` (debounce). Если отрезок не проложился (нет дороги/сегментов/сети) — этот
отрезок падает на **прямую линию** молча (без тост-спама; можно тонко пометить).
- **Тап по отрезку** переключает режим ИМЕННО этого отрезка (дорога ↔ прямая) — это и есть
«ручной тумблер на текущий отрезок». (Тап по точке — удалить, как сейчас; тап по карте —
добавить точку в конец; тап по линии сейчас вставляет точку — развести с «переключить
отрезок»: вставка по долгому тапу либо отдельная явная механика; уточнить при реализации.)
- Рисовать конкатенацию всех отрезков; сохранять итоговую геометрию (как сейчас,
`DB.addRoute`/`updateRoutePoints`). Переиспользовать `parseRouteGPX`, на детали —
`routeElevStats` (профиль высот).
- Глобальную кнопку «🚲 По дорогам» превратить в «все отрезки: по дорогам/прямые» (задаёт
дефолт для новых отрезков), по-сегментный тап её переопределяет. Подсказка внизу:
«тап карты — точка · тап отрезка — дорога/прямая · тап точки — удалить».
## Файлы
- `frontend/js/gps.js` — `fillGaps` (A1).
- `frontend/js/main.js` — `GapCtl`/`showSessionDetail` (A2, A4), меню (B1), `showRoutesScreen`
(B2), `showRouteEditor` (B3).
- `frontend/js/db.js` — миграции + `saveGapFill`/`revertGapFill` (A3).
- `frontend/css/styles.css` — стили достройки/панели (A4).
## Переиспользование (не изобретать заново)
- `DB.importGpxSession`/`addGpsPointsBulk` (`db.js:874,751`) — батч-вставка точек.
- `GPS.recalculateSession(id,{noDelete:true})` (`gps.js:1697`) — пересчёт статы без удаления.
- `computeStats` уже понимает `estimated` (`gps.js:241`); загрузчик точек `db.js:866`.
- `BRouter.route` (`router.js`), `GPS.parseRouteGPX` (`gps.js:1369`), `routeElevStats`.
- Образец обратимости — сглаживание (`gps_sessions.smoothed` + `lat_smoothed/lon_smoothed`,
`saveSmoothedTrack` `db.js:802`).
## Верификация (десктоп, Xvfb :99 + домашний RouteServer; потом на теле)
1. **Гэп-филл.** Открыть трек с пропуском (сессия `1782115144011`, два дропаута): 🧩 →
подсвечены ТОЛЬКО пропуски + панель «Достроено N (+X км), M не удалось» → **Сохранить** →
деталь показывает трек с помеченными достроенными участками, статистика пересчитана →
**Убрать достройку** возвращает исходный трек. Скрины на вирт. экране.
2. **Планировщик.** «Сохранённые маршруты» → «Новый маршрут» → 3 точки: отрезки легли по
дорогам; **тап по отрезку** переключает его на прямую и обратно; сохранить → деталь с
профилем высот. Скрин.
3. Меню: пункт называется «Нарисовать тренировку»; подсказка в пустом списке зовёт
планировать на телефоне.
4. Коммит перед тестом, затем на устройстве `./build.sh install -d` — гэп-филл и планировщик
встроенным движком офлайн (докачать сегмент региона, если ещё не докачан).
## Явно не делаем
- Не создаём новый трек-копию (выбран патч на месте).
- Не трогаем общий список городов-пресетов, координаты, `pois.json`, домен (privacy-скраб
был отдельной задачей).
- Не переписываем `showDrawRouteScreen` (только переименование пункта меню).

View file

@ -0,0 +1,44 @@
# Сессия 2026-09-10 — BRouter на телефоне: гэп-филл и планировщик
## Контекст
Пользователь пожаловался на UX двух BRouter-фич на телефоне:
1. Гэп-филл (🧩 на детали трека) рисовал ВЕСЬ трек целиком, было непонятно, что достроено,
и не было сохранения/отмены.
2. Планировщик маршрутов по точкам не находился: в меню пункт «Нарисовать маршрут» вёл на
рисование ТРЕНИРОВКИ, а сам планировщик спрятан в «Сохранённые маршруты → Новый маршрут».
Работал по утверждённому плану: `docs/plans/2026-09-10-brouter-ux-gapfill-planner.md`.
Решения с пользователем: гэп-филл — обратимый патч на месте; планировщик — по-сегментный
роутинг (дорога по умолчанию, прямая где нет дороги, тап по отрезку переключает); пункт меню
переименовать.
## Сделано
Часть A — гэп-филл:
- `GPS.fillGaps` (gps.js) теперь возвращает `{points, gaps}` со статусами
filled/skipped-long/skipped-noroute, интерполированными ts и флагом `estimated`.
- `GapCtl` (main.js, showSessionDetail): предпросмотр ТОЛЬКО достроенных пропусков (голубой с
бело-чёрной оковкой; недостроенные — красный пунктир) + панель «Достроено N (+X км), M не
удалось» с Сохранить/Отмена. Уже достроенный трек → кнопка «Убрать достройку».
- БД (db.js): миграции `gps_points.estimated`, `gps_sessions.gap_filled`;
`saveGapFill`/`revertGapFill` (обратимо, seq = seq предыдущей реальной точки, оригинал цел);
после сохранения/отката — `GPS.recalculateSession(id,{noDelete:true})`.
- Отрисовка детали помечает достроенные участки голубым (видно после сохранения). CSS `.gap-panel`.
Часть B — планировщик:
- Пункт меню «Нарисовать маршрут» → «Нарисовать тренировку» (он сохраняет сессию).
- Подсказка в пустом списке маршрутов зовёт планировать на телефоне («Новый маршрут»).
- `showRouteEditor` переписан на по-сегментную модель: `legMode[]`/`legGeom[]`, дорога по
умолчанию, тап по отрезку — дорога↔прямая, долгий тап — вставить точку, неудачный роутинг
отрезка молча падает на прямую. Кнопка «🚲 По дорогам» задаёт режим сразу всем отрезкам.
Доки: README (редактор, гэп-филл), PROJECT_MAP (db/gps методы, колонки).
## Проверка
- Синтаксис gps.js/db.js/main.js — OK (node --check). const в новых функциях — нет (правило проекта).
- ДЕСКТОП-ТЕСТ на Xvfb со скринами — СЛЕДУЮЩИЙ шаг (ещё не гонял на момент записи).
- На устройстве встроенным движком — после `./build.sh install -d`.
## Дальше
- Прогнать на Xvfb: гэп-филл на треке с дропаутом (сессия 1782115144011) — предпросмотр только
пропусков, Сохранить, Убрать; планировщик — 3 точки, тап по отрезку переключает дорога/прямая.
- Затем сборка и проверка на теле офлайн.

View file

@ -382,6 +382,28 @@ select {
padding-bottom: 8px;
}
/* Gap-fill preview panel (track detail): floats above the bottom, dismissible. */
.gap-panel {
position: fixed;
left: 8px;
right: 8px;
bottom: calc(16px + var(--safe-area-bottom));
background: var(--surface);
color: var(--on-bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
z-index: 100000;
box-shadow: 0 2px 14px rgba(0,0,0,0.55);
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
}
.gap-panel-hint { color: var(--muted); font-size: 12px; }
.gap-panel-btns { display: flex; gap: 8px; }
.gap-panel-btns button { flex: 1; padding: 8px 12px; }
.nav-btn {
flex: 1;
background: none;

View file

@ -779,6 +779,37 @@ async function addGpsPointsBulk(sessionId, points, startSeq) {
return inserted;
}
// Gap-fill (BRouter): persist the routed dropout points as estimated=1, reversibly.
// `points` is the full fillGaps result array (real + estimated interleaved, ordered).
// Each estimated point gets seq = the preceding real point's seq, so the existing
// ORDER BY COALESCE(seq,999999), ts places it inside the gap; ts is already interpolated.
// Original points are untouched → revertGapFill removes only estimated=1.
async function saveGapFill(sessionId, points) {
let lastSeq = 0, toInsert = [];
for (let p of points) {
if (p.estimated) toInsert.push({ ts: p.ts, lat: p.lat, lon: p.lon, seq: lastSeq });
else if (p.seq != null) lastSeq = p.seq;
}
if (!toInsert.length) return 0;
let BATCH = 120; // 5 params/row → under SQLite bind-var limit
for (let i = 0; i < toInsert.length; i += BATCH) {
let chunk = toInsert.slice(i, i + BATCH);
let rowSql = [], vals = [], ph = 1;
for (let p of chunk) {
rowSql.push(`(?${ph},?${ph+1},?${ph+2},?${ph+3},?${ph+4},1)`);
vals.push(sessionId, p.ts, p.lat, p.lon, p.seq); ph += 5;
}
await execute(`INSERT INTO gps_points (session_id, ts, lat, lon, seq, estimated) VALUES ${rowSql.join(",")}`, vals);
}
await execute("UPDATE gps_sessions SET gap_filled=1 WHERE id=?1", [sessionId]);
return toInsert.length;
}
async function revertGapFill(sessionId) {
await execute("DELETE FROM gps_points WHERE session_id=?1 AND estimated=1", [sessionId]);
await execute("UPDATE gps_sessions SET gap_filled=0 WHERE id=?1", [sessionId]);
}
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) {
// Guard against NaN — SQLite may reject or corrupt NaN values
const safe = v => (v != null && !isNaN(v)) ? v : null;
@ -1213,6 +1244,8 @@ window.DB.getGpsSession = getGpsSession;
window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.importGpxSession = importGpxSession;
window.DB.saveGapFill = saveGapFill;
window.DB.revertGapFill = revertGapFill;
async function findNearestAltitude(lat, lon, radiusM) {
const dLat = radiusM / 111000;
@ -1431,6 +1464,7 @@ async function runMigrations() {
if (!gpsCols.includes("accuracy_m")) await execute("ALTER TABLE gps_points ADD COLUMN accuracy_m REAL");
if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL");
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
// --- Cooking methods table ---
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
@ -1548,6 +1582,7 @@ async function runMigrations() {
if (!gsCols.includes("smooth_iters")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_iters INTEGER");
if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses
if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate)
if (!gsCols.includes("gap_filled")) await execute("ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0"); // BRouter gap-fill applied (reversible)
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);

View file

@ -1397,30 +1397,51 @@ GPS.parseRouteGPX = function(xmlString) {
// the SHORTEST road path between the pre/post-gap points and splice the routed points in
// (flagged estimated). Falls back to keeping the chord if the road path is > ~3x the chord
// (the graph detoured, e.g. across a field). Async (BRouter). Returns a NEW points array.
// Returns { points, gaps }:
// points — a NEW full track array with routed points spliced into filled gaps (each
// flagged estimated:true, with an interpolated ts across the gap so DB ordering by ts
// is correct and they don't collide on a.ts).
// gaps — one entry per DETECTED gap: { a, b, routed:[{lat,lon}], status, addedM, chordM }
// status: "filled" | "skipped-long" (road > maxRatio×chord) | "skipped-noroute".
// Lets the UI draw ONLY the gap patches (not the whole track) and report what failed.
GPS.fillGaps = async function(points, opts) {
opts = opts || {};
const gapThr = opts.gapThresholdSec ?? (GPS.config.gapThresholdSec ?? 30);
const ratio = opts.maxRatio ?? 3;
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
const out = [];
let gapThr = opts.gapThresholdSec ?? (GPS.config.gapThresholdSec ?? 30);
let ratio = opts.maxRatio ?? 3;
let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let out = [];
let gaps = [];
for (let i = 0; i < sorted.length; i++) {
out.push(sorted[i]);
const a = sorted[i], b = sorted[i + 1];
let a = sorted[i], b = sorted[i + 1];
if (!b) continue;
const dt = (new Date(b.ts) - new Date(a.ts)) / 1000;
const chord = GPS.distance(a.lat, a.lon, b.lat, b.lon);
let dt = (new Date(b.ts) - new Date(a.ts)) / 1000;
let chord = GPS.distance(a.lat, a.lon, b.lat, b.lon);
if (dt <= gapThr || chord < 60) continue; // not a gap worth filling
let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime();
let routed = [], status = "skipped-noroute", addedM = 0;
try {
const gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
const rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
if (rp.length < 3) continue;
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 > chord * ratio) continue; // road detoured too far — keep the chord
for (let k = 1; k < rp.length - 1; k++) out.push({ lat: rp[k].lat, lon: rp[k].lon, ts: a.ts, estimated: true });
} catch (e) { /* keep the chord for this gap */ }
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest");
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
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);
if (rlen > chord * ratio) {
status = "skipped-long"; // road detoured too far — keep the chord
} else {
status = "filled"; addedM = Math.max(0, rlen - chord);
for (let k = 1; k < rp.length - 1; k++) {
let frac = k / (rp.length - 1);
let ts = new Date(ta + (tb - ta) * frac).toISOString();
out.push({ lat: rp[k].lat, lon: rp[k].lon, ts, estimated: true });
routed.push({ lat: rp[k].lat, lon: rp[k].lon });
}
}
}
} 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 });
}
return out;
return { points: out, gaps };
};
GPS.importGPX = async function(xmlString, weightKg) {

View file

@ -2730,7 +2730,7 @@ async function renderGpsScreen(container) {
<div class="gps-menu-wrap">
<button id="gps-menu-btn" class="gps-menu-btn">&#x22EE;</button>
<div id="gps-menu" class="gps-menu hidden">
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
<div class="gps-menu-item" id="gps-draw-route">Нарисовать тренировку</div>
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
<div class="gps-menu-item" id="gps-routes">Сохранённые маршруты</div>
@ -3258,7 +3258,7 @@ async function showRoutesScreen(container) {
<input type="radio" name="active-route" id="route-none" value="" ${!active ? "checked" : ""} style="width:18px;height:18px;margin-right:10px">
<span>— Без маршрута —</span>
</label>
<div id="routes-list">${routes.length ? rows : `<div class="prod-hint">Нет маршрутов. Импортируйте GPX-файл — линию можно нарисовать заранее (напр. в Яндекс.Конструкторе карт).</div>`}</div>
<div id="routes-list">${routes.length ? rows : `<div class="prod-hint">Нет маршрутов. Нажмите «Новый маршрут» — тапайте по карте опорные точки, линия проложится по дорогам (BRouter) прямо в телефоне. Или импортируйте готовый GPX.</div>`}</div>
</div>`;
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 = `
<div class="draw-screen">
@ -3503,8 +3508,8 @@ async function showRouteEditor(container, routeId) {
<div class="rec-stat"><span id="re-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
</div>
<div class="rec-bottom">
<span class="prod-hint" style="flex:1;align-self:center;line-height:1.2">тап карты — точка · тап линии — вставить · тащи — двигать · тап точки — удалить</span>
<button id="re-brouter" class="btn-secondary" style="padding:8px 12px" title="Прокладывать по дорогам (BRouter)">🚲 По дорогам</button>
<span class="prod-hint" style="flex:1;align-self:center;line-height:1.2">тап карты — точка · тап отрезка — дорога/прямая · долгий тап — вставить · тап точки — удалить</span>
<button id="re-brouter" class="btn-secondary active" style="padding:8px 12px" title="Все отрезки: по дорогам / прямые">🚲 По дорогам</button>
<button id="re-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
<button id="re-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
<button id="re-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
@ -3525,116 +3530,154 @@ async function showRouteEditor(container, routeId) {
html: `<div style="width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 2px #000"></div>`,
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(`<div class="gap-panel-txt">Трек достроен по дорогам (голубые участки).<br><span class="gap-panel-hint">Можно убрать и вернуть исходный трек.</span></div>
<div class="gap-panel-btns"><button class="btn-secondary" id="gap-revert" style="color:#cf6679">Убрать достройку</button><button class="btn-secondary" id="gap-close">Закрыть</button></div>`);
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(`<div class="gap-panel-txt">Достроено пропусков: <b>${s.filledN}</b> (+${km} км).${s.skippedN ? " Не удалось: <b>" + s.skippedN + "</b> (дорога слишком длинная или нет данных)." : ""}<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 () => {
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];