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:
parent
2551394cd3
commit
cfe0212bbc
8 changed files with 496 additions and 107 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
|
||||
|
|
|
|||
132
docs/plans/2026-09-10-brouter-ux-gapfill-planner.md
Normal file
132
docs/plans/2026-09-10-brouter-ux-gapfill-planner.md
Normal 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` (только переименование пункта меню).
|
||||
44
docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md
Normal file
44
docs/session-logs/2026-09-10_brouter-ux-gapfill-planner.md
Normal 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 точки, тап по отрезку переключает дорога/прямая.
|
||||
- Затем сборка и проверка на теле офлайн.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -2730,7 +2730,7 @@ async function renderGpsScreen(container) {
|
|||
<div class="gps-menu-wrap">
|
||||
<button id="gps-menu-btn" class="gps-menu-btn">⋮</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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue