Compare commits
3 commits
80e35aa096
...
5a80469bba
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a80469bba | |||
| aa7022ec6b | |||
| e45c92f09a |
11 changed files with 343 additions and 82 deletions
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
|
||||
|
|
|
|||
96
docs/plans/2026-09-11-route-editor-gapfill-fixes.md
Normal file
96
docs/plans/2026-09-11-route-editor-gapfill-fixes.md
Normal 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).
|
||||
- Моё место: кнопка `◎` (`◎`) — паттерн `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` (рисование тренировки), список городов-пресетов, приватность.
|
||||
- Не понижаем достройку до «выкл» — делаем точнее, а не убираем.
|
||||
46
docs/session-logs/2026-09-11_route-editor-gapfill-fixes.md
Normal file
46
docs/session-logs/2026-09-11_route-editor-gapfill-fixes.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Сессия 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`: прямые отрезки без высот — только тонкой линией.
|
||||
|
||||
## Проверка (десктоп Xvfb :99 + домашний RouteServer, скрины)
|
||||
- Редактор: 3 точки по Новочеркасску → маршрут рисуется оковкой с уклоном (зелёный→красный) +
|
||||
тонкой линией, как в детали (#6). Тумблер уклон/высота (∡→⛰) переключает раскраску (#1).
|
||||
Тап по линии вставил точку (4-я, маршрут перестроился) — легко попал (#4). Низ: подсказка
|
||||
сверху, кнопки рядом, «Отмена» ниже (#3).
|
||||
- Сохранение: в БД points_json = 61 точка с ele + waypoints_json = 4 опорные; список показал
|
||||
«4 тчк · ▲» (#5). Деталь маршрута — оковка+уклон+профиль высоты, как импорт (#6).
|
||||
- Баг, найденный тестом: сохранение показывало «Маршрут пуст» — `addRoute` возвращал
|
||||
`lastInsertId` (i64 портится SQL-плагином ±1). Исправлено: id перечитывается через
|
||||
`CAST(id AS TEXT)` (как в `importGpxSession`). После фикса сохранение ведёт прямо в деталь.
|
||||
- Достройка (#2): на 06-22 старый порог давал 4 пропуска, новый (dt>60/хорда>150 + пропуск
|
||||
крюков и «нулевого выигрыша») — 2 реальных длинных дропаута (+1.64 км), маргинальные отсеяны.
|
||||
- Экран записи (`.rec-bottom`) не тронут. Тестовые маршруты из десктопной БД убраны.
|
||||
|
||||
## Дальше
|
||||
- На теле встроенным движком офлайн: `./build.sh android -d -i`. Проверить тач-жесты (тап-вставка,
|
||||
долгий тап — дорога/прямая) и достройку на реальных треках (09-08 и др.).
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1077,29 +1077,37 @@ 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) {
|
||||
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;
|
||||
// lastInsertId comes back as an i64 the SQL plugin can corrupt by ±1 — re-read the id as
|
||||
// text instead (same guard as importGpxSession), so the caller opens the right route.
|
||||
let rows = await select("SELECT CAST(id AS TEXT) AS id FROM routes ORDER BY id DESC LIMIT 1");
|
||||
return rows.length ? rows[0].id : null;
|
||||
}
|
||||
|
||||
async function renameRoute(id, name) {
|
||||
|
|
@ -1116,8 +1124,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 +1642,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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = "◎";
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.450"
|
||||
version = "0.1.451"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.450"
|
||||
version = "0.1.451"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.450",
|
||||
"version": "0.1.451",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue