Route editor + gap-fill: 6 fixes (casing/slope, controls, tap-insert, waypoints, precise fill)

- Editor shows the route with casing + slope/elevation colouring like the detail
  view (drawRoutePolyline over the routed geometry); day/night, slope/elev and
  locate controls added. Root cause of the plain thin line: ele was dropped when
  building leg geometry — now kept from BRouter, so casing/slope work here and in
  showRouteDetail. Straight legs (no ele) stay a thin line.
- Insert a point is now a TAP on the line (fat invisible hit target,
  pointer-events:stroke); long-press toggles road/straight.
- Routes store editable waypoints (routes.waypoints_json {waypoints,legMode})
  alongside the dense geometry, so re-editing shows the 5-6 points, not thousands.
- Bottom bar reworked (.re-bottom): hint on top, buttons in a row, cancel below.
- Gap-fill is conservative: only real long dropouts (dt>60s, chord>150m), skips
  big detours (>2x chord) and no-gain fills (road ~ straight).
This commit is contained in:
4aiman 2026-09-11 01:06:06 +03:00
commit e45c92f09a
8 changed files with 327 additions and 78 deletions

View file

@ -22,7 +22,7 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `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` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled` |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `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`) |

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), либо прямой; по дорогам по умолчанию, где дороги/сегментов нет — молча прямая; кнопка **«🚲 По дорогам»** задаёт режим сразу всем. Маршрут в редакторе рисуется **оковкой с раскраской уклон/высота** как в детали; тумблеры день/ночь, уклон/высота и кнопка «моё место» на карте. Сохраняются и **опорные точки** (5-6, для правки — при повторном открытии столько же), и плотная дорожная геометрия с высотами (для показа и профиля уклона).
- **Оффлайн-роутинг (BRouter)** — движок BRouter встроен в приложение (Android): маршруты строятся по дорогам без сети, сегменты `.rd5` региона качаются с домашнего зеркала прямо в приложении (экран дорог → «Сегменты»). Используется в редакторе маршрутов («По дорогам») и для заполнения GPS-пропусков. На десктопе/дома движка нет — те же вызовы уходят на домашний BRouter-RouteServer по HTTP.
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Если дорога в разы длиннее хорды — пропуск не достраивается.
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами.
- **Де-джиттер трека** — сглаживание дрожащего 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,96 @@
# План: редактор маршрутов и достройка GPS — 6 правок
## Context (зачем)
После первой версии редактора маршрутов и достройки пропусков пользователь на телефоне поймал
6 проблем. Их и чиним. Ключевые решения (согласованы):
- Вставка точки в редакторе: короткий **тап по линии = вставить точку**, долгий тап = переключить
отрезок дорога/прямая; зона попадания по линии — широкая.
- В редакторе показывать **оковку с раскраской уклон/высота** как в детали маршрута.
- Достройка должна мостить только реальные длинные потери GPS, не трогая нормальные участки.
Разбор трека 2026-09-08 (session 1788878075973, 14.67 км, 1648 точек): нормальный сэмплинг <5с,
но текущий порог dt>30с/хорда>60м даёт 15 «пропусков», из которых часть — обычная езда
(три по 30с/89м = 10 км/ч, где прямая уже верна) → отсюда объезды и «+0.00 км».
## Файлы
- `frontend/js/main.js` — `showRouteEditor` (проблемы 1,3,4,5,6), `drawRoutePolyline`.
- `frontend/js/gps.js` — `GPS.fillGaps` (проблема 2).
- `frontend/js/db.js` — миграция `routes.waypoints_json` + `addRoute`/`updateRoutePoints`/`getRoute`.
- `frontend/css/styles.css` — низ редактора `.re-bottom` (проблема 3), стиль оковки/попадания.
## Проблема 6 — построенный маршрут = тонкая красная линия (без оковки/уклона)
Причина: в `routeDirty` (main.js:3586) высоты `ele` отбрасываются (`rp.map(p=>({lat,lon}))`).
BRouter trekking отдаёт `<ele>` (проверено: 81 тег), `parseRouteGPX` их читает.
- Сохранять высоту: `legGeom[i] = rp.map(p => ({lat, lon, ele: p.ele}))`; в `fullGeom()` тоже
переносить `ele`. Тогда `routeElevStats(pts).hasEle=true` и `drawRoutePolyline` (main.js:3196)
рисует оковку+уклон и в редакторе, и в `showRouteDetail`, и в оверлее активного маршрута.
- Убрать в save-хэндлере сообщение «загрузите высоты заново» (высоты уже есть).
## Проблема 1 — в редакторе нет тумблеров уклон/высота, день/ночь и кнопки «моё место»
Переиспользовать существующие контролы (все уже есть в `showSessionDetail`):
- День/ночь: `addDarkMapControl(map)` (main.js:7854).
- Моё место: кнопка `◎` (`&#x25CE;`) — паттерн `locBtn` (main.js:5171).
- Уклон/высота: паттерн `sdColorCtl` (main.js:4934): флипает `baldav_route_color_mode`,
перерисовывает отображение маршрута.
## Проблема 4+2-визуал — рефактор отрисовки редактора (display vs hit)
Развести показ и взаимодействие:
- **Показ**: `drawRoutePolyline(map, fullGeom(), color, mode)` — оковка+уклон+тонкая линия,
неинтерактивно (pane `routePane` z250). Перерисовывать при любом изменении и при смене
режима уклон/высота. Прямые отрезки (без `ele`) выходят тонкой линией — визуально отличают
дорогу (оковка) от прямой.
- **Взаимодействие**: под каждой ногой — **прозрачная широкая полилиния (weight ~24)** как
цель тапа: короткий тап → `insertInLeg(i, e.latlng)` (вставить точку), долгий тап
(`contextmenu`) → `toggleLeg(i)` (дорога/прямая). Широкая зона решает «трудно попасть» (#4).
- Маркеры опорных точек — как сейчас (drag двигает, тап удаляет), поверх (markerPane 600).
- `drawLegs` заменить на `drawRoute()` (показ) + `drawHitLines()` (взаимодействие).
## Проблема 5 — после сохранения тонна точек; нужно хранить 5-6 опорных
Хранить и опорные точки, и плотную геометрию:
- Миграция (идемпотентно, как db.js:1634): `routes.waypoints_json TEXT`.
- `addRoute(name,color,points,waypoints)` / `updateRoutePoints(id,points,waypoints)` пишут
`waypoints_json = JSON({ waypoints:[{lat,lon}], legMode:[...] })`; `getRoute` возвращает
`waypoints` (или null).
- Сохранение из редактора: `points` = `fullGeom()` (плотная, с `ele`, для показа/уклона),
`waypoints` = опорные + `legMode` (для правки).
- Открытие на правку: есть `waypoints` → грузим опорные (5-6 маркеров) + `legMode`, до-роутим
дорожные ноги. Нет (импорт GPX / старые) → как сейчас (плотные точки как опорные, прямые).
- Список маршрутов (main.js:3240): показывать число опорных, если они есть.
## Проблема 3 — низ редактора выглядит криво
Своя разметка низа (не трогать `.rec-bottom` — им пользуется экран записи):
- В `showRouteEditor` HTML заменить блок `.rec-bottom` на `.re-bottom`: компактная подсказка
(на всю ширину, мелкий шрифт, ~3 строки) СВЕРХУ, под ней ряд кнопок
`[🚲 По дорогам][Отменить][Сохранить]`, затем узкая `[Отмена]` (как в макете image 25).
- CSS `.re-bottom` (колонка), `.re-hint`, `.re-btns` (ряд) в styles.css.
## Проблема 2 — достройка мостит лишнее и делает объезды
`GPS.fillGaps` (gps.js:1400): мостить только явные длинные потери сигнала.
- Порог по умолчанию: `gapThresholdSec` для достройки поднять до **60с** (был 30 из config),
хорда **> 150м** (была 60). Убирает нормальные 30с/89м-участки.
- После роутинга пропускать, если выигрыш мал: `addedM = rlen - chord < 40м` (дорога ≈ прямая,
«+0.00 км» — мостить нечего).
- Ужесточить объезд: пропускать, если `rlen > chord * 2` (было ×3) — большой крюк вероятно неверен.
- Статусы (`filled`/`skipped-*`) и панель предпросмотра — как есть.
- (Костыль/ограничение, объявляю: пороги зашиты в дефолты `fillGaps`; при желании вынести в
настройки отдельным пунктом — не в этой задаче.)
## Переиспользование (не изобретать)
- `drawRoutePolyline` (main.js:3196), `routeElevStats` (gps.js:2019), `routeSlopeColor`/`routeElevColor`.
- Контролы: `addDarkMapControl` (7854), `locBtn` (5171), `sdColorCtl` (4934).
- `parseRouteGPX` уже читает `<ele>` (gps.js:1383). Миграции routes по образцу db.js:1634.
## Верификация (десктоп Xvfb :99 + домашний RouteServer, затем тело)
1. Редактор: «Новый маршрут» → 3 точки → дорожные ноги рисуются **оковкой с уклоном**;
тумблеры день/ночь, уклон/высота, кнопка `◎` работают; тап по линии вставляет точку,
долгий тап переключает дорога/прямая; низ — подсказка сверху, кнопки рядом (скрин).
2. Сохранить → в списке число опорных (5-6, не тысячи); открыть на правку → 5-6 маркеров;
деталь маршрута рисует оковку+уклон (как импорт).
3. Достройка: на треке 2026-09-08 (уже gap_filled — сначала «Убрать достройку», затем 🧩) —
мостятся только длинные дропауты, нет объездов на прямых и «+0.00 км» (скрин панели).
4. Экран записи не сломан (`.rec-bottom` не тронут). Коммит перед тестом, фиксы — отдельным
коммитом. На тело — по явной просьбе `./build.sh android -d -i`.
## Не трогаем
- `showDrawRouteScreen` (рисование тренировки), список городов-пресетов, приватность.
- Не понижаем достройку до «выкл» — делаем точнее, а не убираем.

View file

@ -0,0 +1,37 @@
# Сессия 2026-09-11 — редактор маршрутов и достройка: 6 правок
## Контекст
После первой версии планировщика и достройки пользователь на телефоне поймал 6 проблем.
Работал по утверждённому плану `docs/plans/2026-09-11-route-editor-gapfill-fixes.md`.
Согласовано: тап по линии = вставить точку (долгий тап = дорога/прямая) с широкой зоной
попадания; в редакторе показывать оковку с уклоном как в детали; достройка мостит только
реальные длинные потери GPS.
Разбор трека 2026-09-08 (session 1788878075973, 14.67 км, 1648 точек, забрал копию БД с
телефона через run-as): сэмплинг <5с, но порог dt>30с/хорда>60м давал 15 «пропусков», часть —
обычная езда (три по 30с/89м = 10 км/ч), отсюда объезды и «+0.00 км».
## Сделано
1. Тумблеры день/ночь, уклон/высота и кнопка «моё место» (◎) в редакторе — переиспользованы
`addDarkMapControl`, паттерны `sdColorCtl`/`locBtn`.
2. Достройка (`GPS.fillGaps`): порог по умолчанию dt>60с, хорда>150м; пропуск, если дорога
> 2× хорды (крюк) или добавляет < 40м (дорога ≈ прямая). Статус `skipped-nogain` — не
рисуется и не считается «не удалось».
3. Низ редактора: своя разметка `.re-bottom` — подсказка сверху, кнопки рядом, «Отмена» ниже
(не трогая `.rec-bottom` экрана записи).
4. Вставка точки: тап по линии (широкая невидимая полилиния-цель, `pointer-events:stroke`),
долгий тап — дорога/прямая.
5. Хранение: `routes.waypoints_json` {waypoints, legMode}. Сохраняются и опорные (5-6, для
правки), и плотная геометрия. Открытие на правку грузит опорные; список показывает их число.
6. Оковка+уклон построенного маршрута: в `routeDirty` сохраняются высоты `ele` из BRouter
(раньше отбрасывались) → `drawRoutePolyline` рисует оковку и в редакторе, и в детали.
`drawRoutePolyline`: прямые отрезки без высот — только тонкой линией.
## Проверка
- Синтаксис gps/db/main OK (node --check). const в новых функциях — нет.
- ДЕСКТОП-ТЕСТ на Xvfb со скринами — следующий шаг (на момент записи не гонял).
- На теле встроенным движком — после `./build.sh android -d -i`.
## Дальше
- Прогнать на Xvfb: редактор (оковка/тумблеры/тап-вставка/низ), сохранение опорных, деталь с
оковкой; достройка на 09-08 — только длинные дропауты. Экран записи не сломан.

View file

@ -1135,6 +1135,39 @@ select {
min-height: 200px;
}
/* Route editor bottom bar: compact hint on top, buttons in a row, cancel below. */
.re-bottom {
display: flex;
flex-direction: column;
gap: 6px;
padding: 6px 12px;
padding-bottom: calc(6px + var(--safe-area-bottom));
background: var(--surface);
border-top: 1px solid var(--border);
}
.re-hint {
font-size: 11px;
line-height: 1.25;
color: var(--muted);
text-align: center;
}
.re-btns {
display: flex;
gap: 8px;
}
.re-btns button {
flex: 1;
padding: 9px 6px;
font-size: 13px;
}
.re-cancel {
color: #cf6679;
padding: 6px;
font-size: 13px;
}
/* Invisible fat hit target for route legs — clickable despite zero opacity. */
.re-hit { pointer-events: stroke; }
.rec-map-wrap {
flex: 1;
min-height: 200px;

View file

@ -1077,27 +1077,32 @@ async function deleteGoalMarker(id) {
// ---------------------------------------------------------------------------
async function getRoutes() {
// Routes are few and small — load the blob to report a point count, drop it.
const rows = await select("SELECT id, name, color, created_at, points_json FROM routes ORDER BY created_at DESC");
let rows = await select("SELECT id, name, color, created_at, points_json, waypoints_json FROM routes ORDER BY created_at DESC");
return rows.map(r => {
let n = 0, hasEle = false;
let n = 0, hasEle = false, wpts = null;
try { let pts = JSON.parse(r.points_json) || []; n = pts.length; hasEle = pts.some(p => p.ele != null); } catch {}
return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, pts: n, hasEle };
// wpts = count of editable waypoints (routes built in the on-phone planner); null = imported
try { if (r.waypoints_json) { let w = JSON.parse(r.waypoints_json); wpts = (w.waypoints || w).length; } } catch {}
return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, pts: n, hasEle, wpts };
});
}
async function getRoute(id) {
const rows = await select("SELECT * FROM routes WHERE id = ?1", [id]);
let rows = await select("SELECT * FROM routes WHERE id = ?1", [id]);
if (!rows.length) return null;
const r = rows[0];
let r = rows[0];
let points = [];
try { points = JSON.parse(r.points_json) || []; } catch {}
return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, points };
// waypoints = { waypoints:[{lat,lon}], legMode:[...] } for planner-built routes; null otherwise
let waypoints = null;
try { if (r.waypoints_json) waypoints = JSON.parse(r.waypoints_json); } catch {}
return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, points, waypoints };
}
async function addRoute(name, color, points) {
const r = await execute(
"INSERT INTO routes (name, color, points_json) VALUES (?1, ?2, ?3)",
[name, color || "#e53935", JSON.stringify(points || [])]
async function addRoute(name, color, points, waypoints) {
let r = await execute(
"INSERT INTO routes (name, color, points_json, waypoints_json) VALUES (?1, ?2, ?3, ?4)",
[name, color || "#e53935", JSON.stringify(points || []), waypoints ? JSON.stringify(waypoints) : null]
);
return r.lastInsertId;
}
@ -1116,8 +1121,13 @@ async function deleteRoute(id) {
// Overwrite a route's points (used to persist fetched elevations back into
// points_json — points are {lat, lon, ele?}).
async function updateRoutePoints(id, points) {
await execute("UPDATE routes SET points_json = ?1 WHERE id = ?2", [JSON.stringify(points || []), id]);
async function updateRoutePoints(id, points, waypoints) {
if (waypoints !== undefined) {
await execute("UPDATE routes SET points_json = ?1, waypoints_json = ?2 WHERE id = ?3",
[JSON.stringify(points || []), waypoints ? JSON.stringify(waypoints) : null, id]);
} else {
await execute("UPDATE routes SET points_json = ?1 WHERE id = ?2", [JSON.stringify(points || []), id]);
}
}
// ---------------------------------------------------------------------------
@ -1629,6 +1639,10 @@ async function runMigrations() {
points_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`);
// Planner-built routes also store their editable waypoints + per-leg mode, so editing
// shows 5-6 points instead of the dense routed geometry. Imported routes leave it null.
let routesCols = (await select("PRAGMA table_info(routes)")).map(r => r.name);
if (!routesCols.includes("waypoints_json")) await execute("ALTER TABLE routes ADD COLUMN waypoints_json TEXT");
// --- Supplement columns migration: add missing micronutrient columns ---
const suppCols = (await select("PRAGMA table_info(supplements)")).map(r => r.name);

View file

@ -1393,21 +1393,26 @@ GPS.parseRouteGPX = function(xmlString) {
return { name, points };
};
// Fill GPS dropouts along roads: for each gap (dt > gapThreshold and a real chord), route
// 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 }:
// Fill GPS dropouts along roads: for each REAL signal loss (a long time gap with a large
// chord), route the SHORTEST road path between the pre/post-gap points and splice the routed
// points in (flagged estimated). Deliberately conservative so normal slow/dense riding is
// NOT touched: only long gaps (dt > gapThresholdSec, default 60s) with a big chord
// (> minChordM, default 150m) qualify; a fill is kept only if the road neither detours too
// far (rlen <= maxRatio×chord) nor barely differs from the straight line (adds >= minGainM),
// since a road ≈ chord means the straight was already right ("+0.00 km" fills). Async
// (BRouter). 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".
// status: "filled" | "skipped-long" | "skipped-nogain" | "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 || {};
let gapThr = opts.gapThresholdSec ?? (GPS.config.gapThresholdSec ?? 30);
let ratio = opts.maxRatio ?? 3;
let gapThr = opts.gapThresholdSec ?? 60; // only bridge a real signal loss, not a 30s hiccup
let minChord = opts.minChordM ?? 150; // and only when the endpoints are genuinely far apart
let ratio = opts.maxRatio ?? 2; // road > 2× chord = big detour, likely wrong → keep straight
let minGain = opts.minGainM ?? 40; // road ≈ chord = nothing to gain → keep straight
let sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let out = [];
let gaps = [];
@ -1417,7 +1422,7 @@ GPS.fillGaps = async function(points, opts) {
if (!b) continue;
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
if (dt <= gapThr || chord < minChord) continue; // not a real dropout worth bridging
let ta = new Date(a.ts).getTime(), tb = new Date(b.ts).getTime();
let routed = [], status = "skipped-noroute", addedM = 0;
try {
@ -1426,10 +1431,13 @@ GPS.fillGaps = async function(points, opts) {
if (rp.length >= 3) {
let rlen = 0;
for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
addedM = Math.max(0, rlen - chord);
if (rlen > chord * ratio) {
status = "skipped-long"; // road detoured too far — keep the chord
} else if (addedM < minGain) {
status = "skipped-nogain"; // road ≈ straight — nothing to bridge
} else {
status = "filled"; addedM = Math.max(0, rlen - chord);
status = "filled";
for (let k = 1; k < rp.length - 1; k++) {
let frac = k / (rp.length - 1);
let ts = new Date(ta + (tb - ta) * frac).toISOString();

View file

@ -3210,10 +3210,12 @@ function drawRoutePolyline(map, pts, color, mode) {
let valid = st.smoothed.filter(e => e != null);
let lo = Math.min(...valid), hi = Math.max(...valid), span = (hi - lo) || 1;
for (let i = 0; i < pts.length - 1; i++) {
// No elevation on this segment (e.g. a straight leg between planner waypoints) → leave
// it as the thin identity line only, so road (casing) and straight (thin) read apart.
if (st.smoothed[i] == null && st.smoothed[i+1] == null) continue;
let col;
if (mode === "elev") {
let e = st.smoothed[i] ?? st.smoothed[i+1];
if (e == null) continue;
col = routeElevColor((e - lo) / span);
} else {
col = routeSlopeColor(st.grades[i]);
@ -3237,7 +3239,7 @@ async function showRoutesScreen(container) {
<div class="settings-item route-row" data-id="${r.id}" data-name="${esc(r.name)}" style="gap:8px">
<input type="radio" name="active-route" value="${r.id}" ${String(r.id) === active ? "checked" : ""} title="Сделать активным" style="width:18px;height:18px;flex:none">
<span class="route-swatch" style="width:16px;height:16px;border-radius:3px;background:${esc(r.color)};flex:none"></span>
<span class="route-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;min-width:0" title="Открыть (высоты, профиль)">${esc(r.name)} <span class="prod-hint">${r.pts} тчк${r.hasEle ? " · ⛰" : ""}</span></span>
<span class="route-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;min-width:0" title="Открыть (высоты, профиль)">${esc(r.name)} <span class="prod-hint">${r.wpts != null ? r.wpts + " тчк" : r.pts + " тчк"}${r.hasEle ? " · ⛰" : ""}</span></span>
<input type="color" class="route-color" value="${esc(r.color)}" style="width:30px;height:26px;border:none;background:none;padding:0;flex:none">
<button class="route-rename btn-secondary" style="padding:4px 8px;flex:none">✎</button>
<button class="route-del btn-secondary" style="padding:4px 8px;color:#cf6679;flex:none">✕</button>
@ -3486,19 +3488,23 @@ async function showRouteDetail(container, routeId) {
async function showRouteEditor(container, routeId) {
let editing = routeId != null;
let route = editing ? await DB.getRoute(routeId) : null;
let points = (editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : [];
// Editable waypoints. Planner-built routes store them separately (5-6 pts) + per-leg mode;
// imported / legacy routes only have dense geometry → load that as waypoints (legs straight).
let wpSaved = (editing && route && route.waypoints && route.waypoints.waypoints) ? route.waypoints : null;
let points = wpSaved
? wpSaved.waypoints.map(p => ({ lat: p.lat, lon: p.lon }))
: ((editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : []);
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
let markers = [];
// 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)
// Per-leg road routing: each leg between consecutive waypoints is independently "road"
// (BRouter geometry, with elevation, cached in legGeom[i]) or "straight". Default = road.
let hitLines = []; // transparent fat polyline per leg — the tap/long-press target
let displayLayer = null; // the visible route (casing+slope+thin line) via drawRoutePolyline
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 legGeom = []; // routed [{lat,lon,ele}] per road leg, or null
let defaultMode = "road";
let routeTimer = null;
let dirty = new Set(); // leg indices awaiting a (debounced) route
let dirty = new Set();
container.innerHTML = `
<div class="draw-screen">
@ -3507,12 +3513,14 @@ async function showRouteEditor(container, routeId) {
<div class="rec-stat"><span id="re-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<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 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>
<div class="re-bottom">
<div class="re-hint">тап карты — точка · тап линии — вставить точку · долгий тап — дорога/прямая · тап точки — удалить</div>
<div class="re-btns">
<button id="re-brouter" class="btn-secondary active" title="Все отрезки: по дорогам / прямые">🚲 По дорогам</button>
<button id="re-undo" class="btn-secondary">Отменить</button>
<button id="re-save" class="btn-primary">Сохранить</button>
</div>
<button id="re-cancel" class="btn-secondary re-cancel">Отмена</button>
</div>
</div>`;
@ -3527,8 +3535,8 @@ async function showRouteEditor(container, routeId) {
let ptsEl = document.querySelector("#re-pts");
let dotIcon = L.divIcon({
className: "re-dot-wrap",
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],
html: `<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 2px #000"></div>`,
iconSize: [18, 18], iconAnchor: [9, 9],
});
function normalizeLegs() {
@ -3536,19 +3544,19 @@ async function showRouteEditor(container, routeId) {
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).
// Drawn/saved geometry for a leg: routed pts for a routed road leg (carry ele), 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).
// Full dense geometry = concat of every leg (shared junction not duplicated); keeps ele.
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 });
for (let k = 1; k < lp.length; k++) out.push({ lat: lp[k].lat, lon: lp[k].lon, ele: lp[k].ele });
}
return out;
}
@ -3558,22 +3566,29 @@ async function showRouteEditor(container, routeId) {
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
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);
// Visible route: casing (slope/elev colour) + thin identity line, like the detail view.
function drawDisplay() {
if (displayLayer) { map.removeLayer(displayLayer); displayLayer = null; }
if (points.length >= 2) {
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
displayLayer = drawRoutePolyline(map, fullGeom(), color, mode);
}
}
// Fat transparent hit target per leg (tap = insert, long-press = toggle road/straight).
// pointer-events:stroke (CSS .re-hit) makes the invisible fat stroke clickable.
function drawHitLines() {
hitLines.forEach(l => map.removeLayer(l)); hitLines = [];
for (let i = 0; i < points.length - 1; i++) {
let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]),
{ color: "#000", weight: 26, opacity: 0, interactive: true, className: "re-hit" }).addTo(map);
(function (idx) {
line.on("click", (e) => { L.DomEvent.stop(e); insertInLeg(idx, e.latlng); }); // tap → insert
line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight
})(i);
hitLines.push(line);
}
}
function redraw() { drawDisplay(); drawHitLines(); }
function markDirty(i) { if (i >= 0 && i < points.length - 1) dirty.add(i); }
function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(routeDirty, 450); }
async function routeDirty() {
@ -3583,18 +3598,18 @@ async function showRouteEditor(container, routeId) {
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;
legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope
} catch (e) { legGeom[i] = null; } // silent fallback to straight (no road / no segments)
}
drawLegs(); updateStats();
redraw(); updateStats();
}
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();
redraw(); updateStats();
}
function afterEdit() { normalizeLegs(); drawLegs(); updateStats(); scheduleRoute(); }
function afterEdit() { normalizeLegs(); redraw(); updateStats(); scheduleRoute(); }
function makeMarker(p) {
let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon });
@ -3603,7 +3618,7 @@ async function showRouteEditor(container, routeId) {
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();
redraw(); updateStats();
});
m.on("dragend", () => {
let idx = markers.indexOf(m); if (idx < 0) return;
@ -3640,13 +3655,54 @@ async function showRouteEditor(container, routeId) {
afterEdit();
}
// Existing route: load its saved dense points as waypoints, legs straight (reproduce
// the saved shape as-is); legs added later default to road.
// --- Map controls, reused from the track/detail views ---
addDarkMapControl(map); // day/night tiles
// Slope <-> elevation colour toggle (shared preference); redraws the route.
let colorCtl = L.control({ position: "topleft" });
colorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "∡" : "⛰";
d.title = "Раскраска маршрута: уклон / высота";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", nm);
d.innerHTML = nm === "slope" ? "∡" : "⛰";
drawDisplay();
};
return d;
};
colorCtl.addTo(map);
// Locate me
let locCtl = L.control({ position: "topleft" });
locCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = "&#x25CE;";
d.title = "Моё местоположение";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
err => console.error("GPS error:", err.message), { enableHighAccuracy: true, timeout: 10000 });
};
return d;
};
locCtl.addTo(map);
// Existing route: load waypoints. Planner-built → its saved leg modes (re-route road legs
// to get fresh geometry + elevations). Imported/legacy dense → all legs straight.
if (points.length) {
points.forEach(p => markers.push(makeMarker(p)));
for (let i = 0; i < points.length - 1; i++) { legMode[i] = "straight"; legGeom[i] = null; }
normalizeLegs(); drawLegs(); updateStats();
for (let i = 0; i < points.length - 1; i++) {
legMode[i] = (wpSaved && wpSaved.legMode && wpSaved.legMode[i]) ? wpSaved.legMode[i] : (wpSaved ? "road" : "straight");
legGeom[i] = null;
if (legMode[i] === "road") markDirty(i);
}
normalizeLegs(); redraw(); updateStats();
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
if (dirty.size) scheduleRoute();
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
@ -3669,23 +3725,26 @@ async function showRouteEditor(container, routeId) {
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();
redraw(); 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 = "Сохранение...";
let savePts = fullGeom(); // concat of all legs (road geometry where routed, else straight)
if (savePts.length < 2) { showToast("Маршрут пуст"); btn.disabled = false; btn.textContent = "Сохранить"; return; }
let dense = fullGeom(); // routed geometry with elevations (for display + slope)
if (dense.length < 2) { showToast("Маршрут пуст"); btn.disabled = false; btn.textContent = "Сохранить"; return; }
// Store the editable waypoints + per-leg mode alongside the dense geometry, so re-editing
// shows the 5-6 points again instead of the thousands of routed points.
let wpOut = { waypoints: points.map(p => ({ lat: p.lat, lon: p.lon })), legMode: legMode.slice() };
if (editing) {
await DB.updateRoutePoints(routeId, savePts);
showToast("Маршрут обновлён — загрузите высоты заново");
await DB.updateRoutePoints(routeId, dense, wpOut);
showToast("Маршрут обновлён");
showRouteDetail(container, routeId);
} else {
let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить");
if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; }
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", savePts);
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", dense, wpOut);
showToast("Маршрут создан");
showRouteDetail(container, id);
}
@ -4832,7 +4891,9 @@ async function showSessionDetail(sessionId) {
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 {
} else if (g.status === "skipped-long" || g.status === "skipped-noroute") {
// a real gap we couldn't bridge — mark it red so the user knows it's unfilled.
// "skipped-nogain" (road ≈ straight) is fine as-is → not drawn, not counted.
skippedN++;
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);
}