Compare commits
16 commits
5a80469bba
...
6dfe262801
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dfe262801 | |||
| d79a9f217a | |||
| 4f31088af9 | |||
| 0be980849c | |||
| d9a3254ffb | |||
| 999361a2e9 | |||
| eb2aab3e6b | |||
| a2cc150298 | |||
| a30c742bc6 | |||
| c564c419db | |||
| abdcb94f55 | |||
| 5fe30399ab | |||
| 69f6fdac18 | |||
| 7bb764d2ca | |||
| e40f6e25b7 | |||
| 1c326ab19c |
18 changed files with 1255 additions and 27 deletions
|
|
@ -22,12 +22,12 @@
|
|||
| Файл | Строк | Назначение |
|
||||
|------|-------|------------|
|
||||
| `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`/`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/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`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea` |
|
||||
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) |
|
||||
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
|
||||
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
|
||||
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
|
||||
| `js/router.js` | ~80 | Оффлайн-роутинг BRouter. `BRouter.route(points,[profile])`: Android → встроенный движок (`invoke("brouter_route")`), иначе/при ошибке → PC-RouteServer по HTTP (`http_request`, мимо CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Профили `trekking` (планирование) / `shortest` (гэпы). Докачка сегментов `.rd5`: `downloadTile/downloadStatus/listTiles/downloadTileAndWait` |
|
||||
| `js/router.js` | ~80 | Оффлайн-роутинг BRouter. `BRouter.route(points,[profile])`: Android → встроенный движок (`invoke("brouter_route")`), иначе/при ошибке → PC-RouteServer по HTTP (`http_request`, мимо CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Профили `trekking` (планирование) / `shortest` (гэпы). `BRouter.route(points, profile, nogos)` — nogos `[{lat,lon,radius}]` → `&nogos=lon,lat,r` в RouteServer и в запрос встроенного движка (Kotlin `rc.nogopoints`). Докачка сегментов `.rd5`: `downloadTile/downloadStatus/listTiles/downloadTileAndWait` |
|
||||
| `js/poi.js` | ~200 | POI-оверлей на картах: круглые контрастные бейджи (эмодзи в круге, не пины), фильтр категорий (`POI.attach(map)`, данные `data/pois.json`, localStorage `baldav_poi_cats/shown`) |
|
||||
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
|
||||
| `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим |
|
||||
|
|
@ -149,7 +149,9 @@
|
|||
### Модалки
|
||||
- Еда: showFoodModal, showEditFoodModal, showFoodFromRecipeModal
|
||||
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
|
||||
- Тренировки: showSessionDetail (карта, графики скорости/высоты, пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
|
||||
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
|
||||
- Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save)
|
||||
- Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter
|
||||
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal
|
||||
|
||||
## IPC команды (Rust → JS)
|
||||
|
|
@ -177,8 +179,9 @@
|
|||
| `ble_save_device` | Сохранение привязанного устройства |
|
||||
| `import_tiles` | Чтение тайлового SQLite батчами (base64) |
|
||||
| `download_file` | Скачивание бинарного файла по URL (без лимита размера) |
|
||||
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп) |
|
||||
| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') |
|
||||
| `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) |
|
||||
| `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` |
|
||||
|
||||
## Формула ккал (GPS)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@
|
|||
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, **тап по линии — вставить точку** (широкая зона попадания), долгий тап по линии — переключить отрезок дорога/прямая, тащить точку — двигать, тап по точке — удалить. Роутинг **по-сегментный**: каждый отрезок между опорными точками отдельно либо по дорогам (BRouter, профиль trekking, высоты из BRouter), либо прямой; по дорогам по умолчанию, где дороги/сегментов нет — молча прямая; кнопка **«🚲 По дорогам»** задаёт режим сразу всем. Маршрут в редакторе рисуется **оковкой с раскраской уклон/высота** как в детали; тумблеры день/ночь, уклон/высота и кнопка «моё место» на карте. Сохраняются и **опорные точки** (5-6, для правки — при повторном открытии столько же), и плотная дорожная геометрия с высотами (для показа и профиля уклона).
|
||||
- **Оффлайн-роутинг (BRouter)** — движок BRouter встроен в приложение (Android): маршруты строятся по дорогам без сети, сегменты `.rd5` региона качаются с домашнего зеркала прямо в приложении (экран дорог → «Сегменты»). Используется в редакторе маршрутов («По дорогам») и для заполнения GPS-пропусков. На десктопе/дома движка нет — те же вызовы уходят на домашний BRouter-RouteServer по HTTP.
|
||||
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами.
|
||||
- **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам.
|
||||
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
|
||||
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
|
||||
- **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется.
|
||||
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
|
||||
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
|
||||
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
|
||||
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
|
||||
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
|
||||
|
|
|
|||
109
docs/plans/2026-09-12-nogo-reroute.md
Normal file
109
docs/plans/2026-09-12-nogo-reroute.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# План: непроезжие места (nogo) + объезд с возвратом на маршрут на ходу
|
||||
|
||||
## Context (зачем)
|
||||
Пользователь поехал по маршруту, импортированному из web-BRouter, и в одном месте дороги по
|
||||
факту не оказалось. Отсюда две связанные потребности:
|
||||
1. **Помечать непроезжие места** так, чтобы роутинг их ОБЪЕЗЖАЛ. В web-BRouter это уже есть
|
||||
(BR.Nogo, `nogos=`) — там ничего не строим. В BalDav nogo-роутинга НЕТ: `BRouter.route`
|
||||
не принимает nogo ни для RouteServer, ни для встроенного движка. Решение — полное избегание
|
||||
(согласовано).
|
||||
2. **Объезд на ходу с возвратом на маршрут.** Активный маршрут ведёт через непроезжее место.
|
||||
Нужно от точки затыка отступить ~1-2 км ВПЕРЁД по сохранённому маршруту и построить объезд
|
||||
туда от текущей позиции (в обход непроезжего места); не вышло — отступать дальше. Сохранённый
|
||||
маршрут НЕ менять — только показать навигацию, как вернуться на траекторию без больших потерь
|
||||
и разворотов на 180° (если возможно). Результат — навигационный оверлей поверх карты записи.
|
||||
|
||||
Итог решений: nogo — полное избегание (RouteServer + встроенный движок, Kotlin + пересборка);
|
||||
цель объезда — точка НА сохранённом маршруте за местом затыка; результат — навигация, без
|
||||
сохранения и без правки маршрута.
|
||||
|
||||
## Часть A — Nogo (непроезжие места) с полным избеганием
|
||||
|
||||
### A1. БД (`frontend/js/db.js`)
|
||||
- Таблица `CREATE TABLE IF NOT EXISTS nogo_areas (id, lat, lon, radius_m, note, created_at)`
|
||||
(по образцу `goal_markers`, db.js:1628). CRUD: `getNogoAreas`/`addNogoArea(lat,lon,radius,note)`/
|
||||
`deleteNogoArea(id)`; экспорт в `window.DB`.
|
||||
|
||||
### A2. Роутинг с nogo (`frontend/js/router.js`)
|
||||
- `BRouter.route(points, profile, nogos)` — `nogos` = `[{lat,lon,radius}]`.
|
||||
- RouteServer-ветка: добавить `&nogos=lon,lat,radius|...` в URL (`_routeServer(pts, profile, nogos)`).
|
||||
- Встроенная: класть `nogos` в `brouter_request.json` (invoke `brouter_route`).
|
||||
- Все текущие вызовы `route(pts, profile)` продолжают работать (nogos по умолчанию — из
|
||||
`DB.getNogoAreas()`, подтягивать в вызывающих местах: планировщик, достройка, объезд).
|
||||
|
||||
### A3. Встроенный движок (`.../MainActivity.kt`) + пересборка APK
|
||||
- В `routeCommand`/`routeBrouter` прочитать `nogos` из запроса; на каждый — `OsmNodeNamed`
|
||||
(ilon/ilat в микроградусах, `radius` в метрах, `isNogo=true`), собрать в список и передать
|
||||
в `rc.nogopoints` + `rc.prepareNogoPoints(...)` ДО `RoutingEngine`. Зеркалить `btools.server.
|
||||
request.ServerHandler` (парсинг nogos там же в jar). Пересобрать: `./build.sh android -d -i`.
|
||||
|
||||
### A4. UI nogo (`frontend/js/main.js`, `styles.css`)
|
||||
- Показ: красные полупрозрачные круги (`L.circle`) на картах планировщика, записи и детали
|
||||
трека — новый хелпер `drawNogoAreas(map)` (по аналогии с `POI.attach`/`drawActiveRouteOverlay`).
|
||||
- Добавление:
|
||||
- Карта записи: кнопка в стопке (`rec-map-btn`) «нет проезда» — ставит nogo в `lastMovingPoint`
|
||||
(радиус по умолчанию, напр. 25 м; настраиваемый позже).
|
||||
- Планировщик: режим «тап — поставить nogo» (переключатель), тап ставит круг.
|
||||
- Удаление: тап по кругу → подтверждение → `deleteNogoArea`.
|
||||
- Планировщик (`showRouteEditor`, `routeDirty`): передавать nogos в каждый вызов `BRouter.route`,
|
||||
чтобы построенные ноги обходили непроезжее. Показывать круги на карте редактора.
|
||||
|
||||
## Часть B — Объезд с возвратом на маршрут на ходу (карта записи)
|
||||
|
||||
### B1. Алгоритм `rejoinReroute(cur, bearing, routePts, nogos)` (main.js)
|
||||
Возвращает связующий путь от текущей позиции до точки на маршруте за затыком.
|
||||
- Опорная точка затыка `ref`: ближайший к текущей позиции nogo, попавший на маршрут (в пределах
|
||||
~150 м от линии); если nogo нет — проекция текущей позиции на маршрут.
|
||||
- `refIdx` — индекс на `routePts`, ближайший к `ref`; накопленная дистанция `cum[]` вдоль маршрута.
|
||||
- Скип `skip` от 1500 м, шаг +1500 м до конца маршрута (несколько попыток):
|
||||
- `target` = точка маршрута на дистанции `cum[refIdx] + skip` (ВПЕРЁД, к концу маршрута).
|
||||
- вейпоинты: `[cur, target]`, а «без разворота» — при наличии курса добавлять помощник в
|
||||
~30-50 м по `bearing` первым (`[cur, helper, target]`), чтобы старт шёл вперёд.
|
||||
- `gpx = BRouter.route(wps, "trekking", nogos)`; принять, если проложился и длина разумна
|
||||
(< ~3× прямой до target). Иначе увеличить `skip`.
|
||||
- Курс `bearing`: из `GPS.recording` (последние точки / `smoothedHeading` компаса).
|
||||
|
||||
### B2. UI объезда (карта записи, `showRecordScreen` ~3956+)
|
||||
- Кнопка в стопке `rec-map-btn` «объехать» (доступна при активном маршруте). По тапу:
|
||||
запускает `rejoinReroute`, рисует связующий путь навигационным оверлеем (отдельный слой,
|
||||
ярко, поверх; НЕ трогает `baldav_active_route` и запись). Точку возврата пометить.
|
||||
- Если объезд не найден даже у конца маршрута — тост «объезд не найден, отступаю дальше не вышло».
|
||||
- Оверлей держать до ручного сброса/новой перестройки; запись трека не прерывается.
|
||||
|
||||
## Файлы
|
||||
- `frontend/js/db.js` — таблица `nogo_areas` + CRUD.
|
||||
- `frontend/js/router.js` — `route(...,nogos)`, `_routeServer(...,nogos)`.
|
||||
- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt` — nogo в движке.
|
||||
- `frontend/js/main.js` — `drawNogoAreas`, кнопки nogo (запись/планировщик), передача nogos в
|
||||
роутинг, `rejoinReroute` + кнопка «объехать» на карте записи.
|
||||
- `frontend/css/styles.css` — стиль круга nogo / панели.
|
||||
|
||||
## Переиспользование (не изобретать)
|
||||
- `goal_markers` CRUD (db.js:1059) — образец для `nogo_areas`.
|
||||
- `drawActiveRouteOverlay`/`drawRoutePolyline` (main.js:3163/3196) — образец слоя-оверлея.
|
||||
- `BRouter.route` (router.js), `GPS.distance`, `GPS.recording.lastMovingPoint`, компас
|
||||
(`smoothedHeading` в `showRecordScreen`).
|
||||
- Kotlin `routeBrouter` (MainActivity.kt:323) — точка вставки nogopoints; зеркалить
|
||||
`btools.server.request.ServerHandler` из jar.
|
||||
- `POI.attach` (poi.js) — образец «слой + добавление/удаление на карте».
|
||||
|
||||
## Верификация
|
||||
Десктоп (Xvfb :99 + домашний RouteServer):
|
||||
1. Nogo: поставить круг на дороге в планировщике → нога маршрута обходит его (сравнить с/без).
|
||||
2. `rejoinReroute` в изоляции: задать активный маршрут, «текущую позицию» рядом с затыком и
|
||||
nogo на маршруте → построенный объезд ведёт к точке на маршруте за nogo, не через nogo;
|
||||
при неудаче отступает дальше. Скрин.
|
||||
Устройство (реальная поездка / симуляция):
|
||||
3. `./build.sh android -d -i` (нужно для nogo во встроенном движке офлайн). На теле: пометить
|
||||
«нет проезда», «объехать» → навигационный оверлей к точке возврата; сохранённый маршрут и
|
||||
запись не меняются.
|
||||
Регрессии: планировщик и достройка (используют `BRouter.route`) работают и без nogo; экран
|
||||
записи и его кнопки целы.
|
||||
|
||||
## Стадии (инкрементально, коммит на стадию)
|
||||
1. A1+A2+A3 (nogo сквозь весь роутинг) + A4-показ/добавление → планировщик обходит nogo.
|
||||
2. B (объезд с возвратом) поверх готового nogo-роутинга.
|
||||
|
||||
## Не трогаем
|
||||
- Web-BRouter (nogo там уже есть).
|
||||
- Сохранённые маршруты не меняем при объезде; запись трека не прерываем.
|
||||
37
docs/plans/2026-09-14-ridden-roads-walk.md
Normal file
37
docs/plans/2026-09-14-ridden-roads-walk.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# План: детект ходьбы (b) + предпочтение проезженных дорог (a)
|
||||
|
||||
## Context
|
||||
Маршруты из BRouter не совпадают с реальностью (роутит по несуществующим дорогам). Нужно:
|
||||
(a) при построении предпочитать уже проезженные дороги, к публичным данным (BRouter) обращаться
|
||||
только там, где проезженного нет; (b) в вело-треке детектить пешие куски по скорости, красить
|
||||
их отдельно и не считать «дорогой» (по пашне не проедешь). Решения (согласовано): обе сразу;
|
||||
детект ходьбы по скорости (универсально) + порог по передаче; окраска + исключение из дорог +
|
||||
опционально стат.
|
||||
|
||||
## Стадия B - детект ходьбы (сделано)
|
||||
- `GPS.detectWalk(points, opts)` (gps.js): sm-сглаженная скорость ниже порога дольше
|
||||
`walkMinRunSec` = ходьба. Порог = 30-rpm по передаче (односкоростной) или `walkDetectKmh`.
|
||||
- Персист `gps_points.walk` (`DB.setWalkFlags`); считается в `recalculateSession` и
|
||||
`recomputeAllStats`.
|
||||
- Rust `rebuild_roads`: walk-point -> atype 'walk' (CASE в SQL) -> в оверлее цвет ходьбы,
|
||||
не вело-дорога.
|
||||
- Деталь трека: пешие runs красятся `roadColorWalk`; стат «пешком: N мин / X км».
|
||||
|
||||
## Стадия A - роутер по проезженным дорогам (план)
|
||||
- Rust `get_ride_graph(min_speed)`: читает gps_points (исключая `walk=1`), снапит узлы на сетку
|
||||
~20м, рёбра между последовательными точками сессии (вес = расстояние), возвращает компактный
|
||||
граф JSON {nodes:[{id,lat,lon}], edges:[[a,b,dist]]}. Кэш в JS (перестраивать по запросу).
|
||||
- JS: индекс ближайшего узла (grid hash), Dijkstra между узлами у концов ноги.
|
||||
- Планировщик `showRouteEditor.routeDirty`: для дорожной ноги сначала пробовать ридден-граф
|
||||
(если оба конца в ~60м от узла и путь не длиннее ~2.5× прямой) - использовать его; иначе
|
||||
BRouter (+nogos). Ридден-путь отличать визуально (или пометкой источника).
|
||||
|
||||
## Файлы
|
||||
- `frontend/js/gps.js` (detectWalk, хуки), `frontend/js/db.js` (walk column, setWalkFlags),
|
||||
`frontend/js/main.js` (окраска, стат; Стадия A - роутер + интеграция в планировщик),
|
||||
`src-tauri/src/lib.rs` (rebuild_roads walk; Стадия A - get_ride_graph).
|
||||
|
||||
## Верификация
|
||||
- B: `detectWalk` харнесс (98% на пешем треке vs 1% на нормальном); визуал окраски на теле.
|
||||
- A: на десктопе - планировщик по знакомому месту идёт по проезженному, а не по фейковой дороге;
|
||||
где не ездил - BRouter. Затем тело (встроенный движок), пересборка APK.
|
||||
96
docs/plans/2026-09-14-share-image.md
Normal file
96
docs/plans/2026-09-14-share-image.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# План: генератор шэр-картинки трека («Похвастаться»)
|
||||
|
||||
## Context (зачем)
|
||||
Пользователь хочет делиться красивой картинкой поездки: форма трека + крупные статы
|
||||
(как на присланном скрине). Три части: (1) картинка с формой трека и статами, прозрачная
|
||||
или на фото-подложке; (2) настраиваемая подложка (фото) в настройках; (3) кнопка
|
||||
«Похвастаться» в детали трека, которая даёт **Поделиться** (системный шэр) и **Сохранить файл**.
|
||||
|
||||
Итог решений (согласовано): экспорт — и «Поделиться», и «Сохранить»; в диалоге переключатель
|
||||
прозрачный/фото; статы на картинке — дистанция, время, средняя скорость и (мельче) набор
|
||||
высоты, ккал, дата.
|
||||
|
||||
Рисуем на `<canvas>` в WebView (чистый JS). Бинарный вывод PNG в WebView невозможен (blob
|
||||
download не работает), поэтому сохранение/шэр — нативно (Kotlin), плюс чтение выбранного фото
|
||||
в base64 — Rust. Нужна пересборка APK. FileProvider уже есть в манифесте, `file_paths` содержит
|
||||
`cache-path "."` — годится для шэра.
|
||||
|
||||
## Часть A — Рендер картинки на canvas (JS, `frontend/js/main.js` + `styles.css`)
|
||||
- Новая функция `buildShareImage(session, stats, points, opts)` → возвращает dataURL PNG.
|
||||
Холст 1080×1920 (сторис 9:16):
|
||||
- Фон: `opts.bg==="photo"` и есть сохранённая подложка → нарисовать её cover-fit + лёгкий
|
||||
тёмный градиент снизу для читаемости; иначе прозрачный (холст не заливать).
|
||||
- Форма трека: точки трека спроецировать (равнопрямоугольная проекция в масштабе трека,
|
||||
паддинг), нарисовать линией цвета трека (`GPS.config.trackColor`) с мягким свечением, в
|
||||
верхней ~40% части.
|
||||
- Статы: крупно по центру — Дистанция / Время / Средняя скорость (значение + подпись
|
||||
English: Distance/Time/Speed, «km»/«km/h», 1 знак) как на скрине; мелкой строкой снизу —
|
||||
«↑ N m · K kcal · дата». Белый текст с тенью/обводкой (читаемо и на прозрачном, и на фото).
|
||||
- Мелкий локальный форматтер времени «1h 29min»/«29 min» (не `GPS.formatDuration`, там h:mm:ss).
|
||||
- Модалка предпросмотра `showShareModal(session, stats, points)`: показывает `<img src=dataURL>`,
|
||||
переключатель **Прозрачный / С фото** (перерисовывает), кнопки **Поделиться**, **Сохранить**,
|
||||
**Закрыть**. Паттерн модалки — как `appConfirm` (main.js:1040).
|
||||
|
||||
## Часть B — Кнопка «Похвастаться» в детали трека
|
||||
- В `showSessionDetail` рядом с `#sd-export` (main.js:5011-5015) добавить
|
||||
`<button id="sd-share">Похвастаться</button>` → `showShareModal(session, stats, points)`
|
||||
(переменные `session`/`stats`/`points` уже в области видимости деталей).
|
||||
|
||||
## Часть C — Нативный вывод PNG (Kotlin + Rust, пересборка APK)
|
||||
- Rust `export_image(app, filename, base64, action)` (action `save`|`share`): пишет base64 в
|
||||
`export_image.b64` (+ app root) и команду `exportimg:<action>:<filename>` в `gps_command.txt`
|
||||
(тот же файловый мост, что `save_file`, lib.rs:515). Зарегистрировать в `generate_handler`.
|
||||
- Kotlin (`MainActivity.kt`): в обработчике команд ветка `exportimg:` — прочитать
|
||||
`export_image.b64`, `Base64.decode`:
|
||||
- `save` → новый `saveImageLauncher = CreateDocument("image/png")` пишет байты (по образцу
|
||||
`saveFileLauncher`, MainActivity.kt:55).
|
||||
- `share` → записать в `cacheDir/<filename>`, `FileProvider.getUriForFile(ctx,
|
||||
"${applicationId}.fileprovider", f)`, `Intent(ACTION_SEND) type=image/png EXTRA_STREAM=uri
|
||||
FLAG_GRANT_READ_URI_PERMISSION`, `startActivity(createChooser)`.
|
||||
- JS: `BRouter`-подобных нет; звать напрямую `invoke("export_image", {filename, base64, action})`.
|
||||
base64 = `dataURL.split(",")[1]`.
|
||||
|
||||
## Часть D — Настраиваемая подложка (настройки)
|
||||
- Rust `read_file_base64(app, path)` → вернуть содержимое файла base64 (для чтения выбранного
|
||||
фото; `pick_file_open` уже копирует выбранное в `picked_import.db`, lib.rs:167/183).
|
||||
- Настройки (`renderSettings` / секция «Карты» или новая «Картинка»): строка «Фон для картинки»
|
||||
с кнопками **Выбрать** и **Сбросить**.
|
||||
- Выбрать: `pick_file_open("image/*")` → поллинг `get_picked_file` → путь → `read_file_base64`
|
||||
→ загрузить в `Image`, **уменьшить** до ширины ≤1080 на офскрин-canvas → `toDataURL("image/
|
||||
jpeg",0.85)` → сохранить в `localStorage.baldav_share_bg` (после даунскейла ~200-400КБ, влезает).
|
||||
- Сбросить: удалить ключ.
|
||||
- `buildShareImage` берёт подложку из `localStorage.baldav_share_bg`.
|
||||
|
||||
## Файлы
|
||||
- `frontend/js/main.js` — `buildShareImage`, `showShareModal`, кнопка `#sd-share`, пикер фона
|
||||
в настройках, `invoke("export_image"/"read_file_base64")`.
|
||||
- `frontend/css/styles.css` — стиль модалки предпросмотра.
|
||||
- `src-tauri/src/lib.rs` — команды `export_image`, `read_file_base64` (+ регистрация).
|
||||
- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt` — `exportimg:`
|
||||
(saveImageLauncher + share-интент).
|
||||
|
||||
## Переиспользование (не изобретать)
|
||||
- Файловый мост `save_file`/`gps_command.txt` (lib.rs:515, MainActivity.kt:457) — образец для
|
||||
`export_image`.
|
||||
- `saveFileLauncher` (CreateDocument, MainActivity.kt:55) — образец `saveImageLauncher`.
|
||||
- `pick_file_open`/`get_picked_file` (lib.rs:167/183) — выбор фото.
|
||||
- FileProvider (`AndroidManifest.xml:50`, `file_paths.xml` cache-path) — шэр.
|
||||
- `appConfirm` модалка (main.js:1040), `showToast`, `GPS.formatDistance`, `state.profile`.
|
||||
- Точки/статы трека уже посчитаны в `showSessionDetail`.
|
||||
|
||||
## Верификация
|
||||
Десктоп (Xvfb :99): кнопка «Похвастаться» → модалка с картинкой (форма трека + статы),
|
||||
переключатель прозрачный/фото рисует корректно; «Сохранить» на десктопе — через `save_file`-путь
|
||||
(проверить, что dataURL строится; нативный SAF/шэр — только на теле). Скрин модалки.
|
||||
Устройство (`./build.sh android -d -i`, по явной просьбе): «Поделиться» открывает системный
|
||||
чужой чузер с PNG; «Сохранить» — SAF, файл открывается как картинка; в настройках выбор фото →
|
||||
на картинке подложка; прозрачный вариант — PNG с альфой (проверить в галерее на светлом фоне
|
||||
через тень текста).
|
||||
|
||||
## Стадии
|
||||
1. A+B (рендер + модалка + кнопка) с «Сохранить»/«Поделиться» заглушены тостом на десктопе —
|
||||
проверить рендер визуально.
|
||||
2. C (нативный save/share) + D (подложка) → пересборка APK, тест на теле.
|
||||
|
||||
## Не трогаем
|
||||
- Карту/треки не меняем; экспорт GPX (`sd-export`) остаётся.
|
||||
50
docs/session-logs/2026-09-12_nogo-reroute.md
Normal file
50
docs/session-logs/2026-09-12_nogo-reroute.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Сессия 2026-09-12 — непроезжие места (nogo) + объезд на ходу
|
||||
|
||||
## Контекст
|
||||
После реальной поездки по маршруту из web-BRouter в одном месте дороги не оказалось. Две идеи
|
||||
(план `docs/plans/2026-09-12-nogo-reroute.md`, согласовано):
|
||||
1. Помечать непроезжие места так, чтобы роутинг их ОБЪЕЗЖАЛ (полное избегание). В web-BRouter
|
||||
nogo уже есть — там ничего не строим.
|
||||
2. На ходу строить объезд с ВОЗВРАТОМ на сохранённый маршрут за местом затыка (отступ ~1-2 км
|
||||
вперёд, растёт при неудаче), не меняя сохранённый маршрут; показывать навигацией, без
|
||||
разворотов на 180 если можно.
|
||||
|
||||
Разведка: web-бандл уже с nogo (BR.Nogo, `nogos=`). В BalDav nogo-роутинга не было. API
|
||||
встроенного движка (javap): `RoutingContext.nogopoints` + static `prepareNogoPoints(list)`,
|
||||
`OsmNodeNamed.radius/isNogo/nogoWeight`.
|
||||
|
||||
## Сделано — Стадия 1 (nogo сквозь роутинг + показ/добавление)
|
||||
- БД: таблица `nogo_areas` (lat/lon/radius_m/note) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`.
|
||||
- `router.js`: `BRouter.route(points, profile, nogos)` → `&nogos=lon,lat,r` в RouteServer и
|
||||
`nogos` в запрос встроенного движка.
|
||||
- Rust `brouter_route`: параметр `nogos` (Option<String>) → в `brouter_request.json`.
|
||||
- Kotlin `routeBrouter`: парсит nogos → `OsmNodeNamed`(radius, isNogo, nogoWeight=NaN) →
|
||||
`prepareNogoPoints` + `rc.nogopoints`. Нужна пересборка APK для офлайн-движка.
|
||||
- UI: хелпер `drawNogoAreas` (красные круги, тап — убрать). Планировщик: кнопка ⛔ (режим),
|
||||
тап по карте/линии ставит nogo, каждая нога роутится с nogos; круги показываются. Карта
|
||||
записи: кнопка ⛔ «здесь нет проезда» (ставит в текущей позиции) + показ. Деталь трека: показ.
|
||||
|
||||
## Проверка
|
||||
- Синтаксис db/router/main OK. RouteServer с `&nogos`: маршрут 2873м → 3329м (объезжает круг 300м).
|
||||
- ДЕСКТОП-ТЕСТ планировщика (nogo обходится визуально) — следующий шаг.
|
||||
- Встроенный движок (офлайн) — после `./build.sh android -d -i`, на теле.
|
||||
|
||||
## Проверка Стадии 1 (десктоп Xvfb + RouteServer)
|
||||
- Планировщик: 2 точки → дорожная нога; ⛔ → тап по линии ставит красный круг → нога
|
||||
перестроилась в обход (nogo в БД: 47.4238,40.087,25м). Тестовые nogo/маршруты убраны.
|
||||
- RouteServer `&nogos`: 2873м → 3329м. Коммит `1c326ab`.
|
||||
|
||||
## Сделано — Стадия 2 (объезд с возвратом на маршрут)
|
||||
- `rejoinReroute(cur, bearing, routePts, nogos)` (main.js): опорная точка затыка = ближайший
|
||||
nogo на маршруте (≤150м), иначе текущая позиция; цель = точка маршрута на +1.5 км вперёд,
|
||||
шаг +1.5 км до конца при неудаче; помощник по курсу (~40 м) чтобы не разворачиваться; принять,
|
||||
если длина ≤ max(500, 3× прямой). Не меняет сохранённый маршрут.
|
||||
- Карта записи: кнопка «↪» — строит объезд от `lastMovingPoint` к точке возврата, рисует ярким
|
||||
оверлеем (magenta с бело-чёрной оковкой) + метка возврата; долгий тап убирает.
|
||||
|
||||
## Проверка Стадии 2
|
||||
- Синтаксис ок. Алгоритм `rejoinReroute` проверить харнессом против RouteServer (десктоп).
|
||||
- Полный прогон на ходу + встроенный движок офлайн — на теле после `./build.sh android -d -i`.
|
||||
|
||||
## Дальше
|
||||
- Сборка/установка на тело — по явной просьбе. Реальная поездка: пометить непроезжее, «↪».
|
||||
70
docs/session-logs/2026-09-14_ridden-roads-walk.md
Normal file
70
docs/session-logs/2026-09-14_ridden-roads-walk.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Сессия 2026-09-14 - детект ходьбы + предпочтение проезженных дорог
|
||||
|
||||
## Контекст
|
||||
Маршруты из BRouter не совпадают с землёй. Две задачи (согласовано - обе сразу):
|
||||
(a) при построении предпочитать уже проезженные дороги, к публичным данным - только в пробелах;
|
||||
(b) в вело-треке детектить пешие куски (по скорости), красить их и не строить по ним
|
||||
(по пашне не проедешь).
|
||||
|
||||
План: `docs/plans/2026-09-14-ridden-roads-walk.md`. Детект ходьбы - по скорости (универсально),
|
||||
порог уточняется по передаче; окраска на треке + исключение из дорог + опционально стат.
|
||||
|
||||
## Сделано - Стадия B (детект ходьбы: цвет + исключение из дорог)
|
||||
- `GPS.detectWalk(points, opts)` (gps.js): contiguous runs со сглаженной скоростью ниже порога
|
||||
(`walkDetectKmh` 8 км/ч, или скорость 30-rpm по передаче для односкоростного) дольше
|
||||
`walkMinRunSec` (60с) = ходьба. Возвращает булев массив по точкам.
|
||||
- Персист: колонка `gps_points.walk`; `DB.setWalkFlags`; считается и сохраняется в
|
||||
`recalculateSession` и в `recomputeAllStats` (backfill по «Пересчитать всё»).
|
||||
- Rust `rebuild_roads`: пеший point -> atype 'walk' (в SQL CASE), значит в оверлее «дорог»
|
||||
красится цветом ходьбы и не считается вело-дорогой.
|
||||
- Деталь трека: пешие участки красятся `roadColorWalk` поверх трека; в статах «пешком: N мин / X км».
|
||||
|
||||
## Проверка B
|
||||
- Синтаксис OK. `detectWalk` харнессом на реальных треках: почти-пеший (session 1782752407585,
|
||||
4018м) - 98% точек пешие («пешком 60 мин, 3.89 км»); нормальный 16км - 1% (не пеший). Различает.
|
||||
- Визуал окраски + Rust rebuild_roads с walk - на десктопе/теле (нужна пересборка для движка;
|
||||
на десктопе rebuild_roads Rust работает).
|
||||
|
||||
## Сделано - Стадия A (роутер по проезженным дорогам)
|
||||
- Rust `get_ride_graph(min_speed)`: узлы на сетке ~20м (исключая walk=1), рёбра между
|
||||
последовательными точками сессии (вес = расстояние между УЗЛАМИ - важно, иначе внутрикл.
|
||||
путь теряется и Dijkstra недооценивает). Возвращает {nodes, edges}.
|
||||
- JS: `loadRideGraph` (кэш), grid-индекс `_nearestRideNode`, `_rideDijkstra`, `rideRoute` -
|
||||
путь по проезженному между концами ноги (снап ≤60м, отсев если > 2.5× прямой).
|
||||
- Планировщик `routeDirty`: сначала `rideRoute`, если null - BRouter(+nogos). Ридден-нога
|
||||
без высот -> тонкая линия; BRouter - оковка (визуально отличаются).
|
||||
|
||||
## Проверка A (десктоп Xvfb + БД)
|
||||
- Данные: 139851 ридден-точек -> 6722 узла, 8913 рёбер. Харнесс: между двумя он-трек точками
|
||||
(1409м) находит путь по проезженному (89 узлов). Баг веса ребра (143м вместо 1717м) найден и
|
||||
исправлен (вес = между узлами).
|
||||
- В приложении (Новочеркасск): нога у кольца/Ермака легла ТОНКОЙ линией по проезженной дороге
|
||||
(ридден), а не оковкой (BRouter). Клик мимо проезженного -> BRouter (корректно). Скрин.
|
||||
- Побочно: `formatDistance` теперь округляет метры (было «966.145...м»).
|
||||
- На теле (встроенный движок + реальный walk) - после пересборки APK.
|
||||
|
||||
## Доработки по фидбеку с телефона (0.1.455+)
|
||||
- Детект ходьбы: перекраска на треке подтверждена. Калории за пеший кусок уже честные -
|
||||
`detectWalk` и калорийная ветка `computeStats` (walkBikeMs) используют один порог 30-rpm,
|
||||
так что крашеный кусок считается пешим (net walking MET), не вело-физикой.
|
||||
- Привязка к проеханному на теле подтверждена.
|
||||
- «Похвастаться» вне стека окон (свайп «назад» листал экраны позади) - `showShareModal`
|
||||
теперь через `winPush("share", ...)`; «Закрыть» и системный «назад» снимают именно её.
|
||||
- «Похвастаться»: выбор полей картинки. `buildShareImage` принимает `opts.fields`; раскладка
|
||||
адаптивная (крупные статы стопкой, при выключенном треке блок центрируется). В модалке 8
|
||||
галочек (форма трека, дистанция, время, ср./макс. скорость, набор, калории, дата), выбор в
|
||||
`localStorage.baldav_share_fields`. Проверено на десктопе (Xvfb): галочки рисуются, перерисовка
|
||||
и адаптив работают, «Закрыть» возвращает в деталь без листания.
|
||||
|
||||
## Аккордеон сегментов (деталь трека)
|
||||
- Список сегментов (до 71 шт) разворачивал пол-экрана, до кнопок внизу было долго листать.
|
||||
Секцию «Сегменты» завернул в нативный `<details class="sd-accordion">` со `<summary>` -
|
||||
свёрнута по умолчанию, клик по заголовку разворачивает/сворачивает (шеврон крутится,
|
||||
рядом счётчик N). Кнопки (Похвастаться, Экспорт, Удалить и т.д.) теперь сразу видны.
|
||||
- Проверено на десктопе (Xvfb): свёрнуто по умолчанию, «СЕГМЕНТЫ 71» + блок велосипеда и
|
||||
кнопки сразу под ним; клик разворачивает список, шеврон ▸ -> ▾.
|
||||
|
||||
## Замечания на будущее
|
||||
- Ридден-нога без высот (тонкая); при желании нести среднюю высоту узла для оковки.
|
||||
- `walk` на десктопе не заполнен (не пересчитывал) - граф пока включает все точки; на реальном
|
||||
устройстве после «Пересчитать всё» пешие исключатся.
|
||||
39
docs/session-logs/2026-09-14_share-image.md
Normal file
39
docs/session-logs/2026-09-14_share-image.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Сессия 2026-09-14 — шэр-картинка трека («Похвастаться»)
|
||||
|
||||
## Контекст
|
||||
Пользователь хочет делиться картинкой поездки: форма трека + крупные статы (как на скрине),
|
||||
прозрачная или на фото-подложке. План `docs/plans/2026-09-14-share-image.md`. Согласовано:
|
||||
кнопка даёт «Поделиться» и «Сохранить»; в диалоге переключатель прозрачный/фото; статы —
|
||||
дистанция, время, средняя скорость + набор/ккал/дата.
|
||||
|
||||
Разведка: `save_file` (SAF, текст) — не годится для бинаря PNG; нужен нативный бинарный сейв +
|
||||
ACTION_SEND (FileProvider уже в манифесте, `file_paths` с cache-path есть) + чтение фото в
|
||||
base64 (Rust). Canvas в WebView доступен.
|
||||
|
||||
## Сделано — Стадия 1 (рендер + модалка + кнопка), чистый JS
|
||||
- `buildShareImage(session, stats, points, opts)` (main.js): холст 1080×1920; фон прозрачный
|
||||
или фото (cover-fit + тёмный градиент); форма трека (проекция точек, линия цвета трека со
|
||||
свечением); статы крупно Distance/Time/Speed + мелко ↑набор/ккал/дата; белый текст с тенью.
|
||||
- `showShareModal` — предпросмотр `<img>` + переключатель Прозрачный/С фото + кнопки
|
||||
Поделиться/Сохранить/Закрыть. `exportShareImage(dataUrl, filename, action)` зовёт
|
||||
`invoke("export_image")` (нативной команды пока нет — на десктопе тост-ошибка, это ок).
|
||||
- Кнопка `#sd-share` «Похвастаться» в детали трека. CSS `.share-modal` (шахматка под прозрачность).
|
||||
|
||||
## Сделано — Стадия 2 (нативный вывод + подложка)
|
||||
- Rust `export_image(filename, base64, action)` (пишет base64 + команду `exportimg:` в мост) и
|
||||
`read_file_base64(path)` (для чтения выбранного фото); зарегистрированы.
|
||||
- Kotlin: ветка `exportimg:` — Base64-декод; `save` → `saveImageLauncher` (CreateDocument
|
||||
image/png, байты), `share` → запись в `cacheDir/shared`, FileProvider URI, `ACTION_SEND`
|
||||
image/png через чузер.
|
||||
- Настройки: строка «Фон для картинки» (Выбрать/Сброс): `pickLocalFile("image/*")` →
|
||||
`read_file_base64` → даунскейл ≤1080 → `localStorage.baldav_share_bg` (jpeg 0.85).
|
||||
|
||||
## Проверка
|
||||
- Стадия 1 рендер — ПРОВЕРЕНО на десктопе (Xvfb): модалка рисует форму трека + Distance/Time/
|
||||
Speed + ↑набор/ккал/дата, прозрачный PNG (шахматка), переключатель/кнопки. Скрин.
|
||||
- lib.rs пересобрался в `tauri dev` без ошибок (команды компилируются/регистрируются).
|
||||
- Нативный save/share/чтение фото и Kotlin — ТОЛЬКО на теле после `./build.sh android -d -i`.
|
||||
|
||||
## Дальше
|
||||
- Пересборка APK и тест на теле: «Поделиться» (чузер), «Сохранить» (SAF), выбор подложки →
|
||||
вариант «С фото». По явной просьбе.
|
||||
|
|
@ -1168,6 +1168,51 @@ select {
|
|||
/* Invisible fat hit target for route legs — clickable despite zero opacity. */
|
||||
.re-hit { pointer-events: stroke; }
|
||||
|
||||
/* Share image ("Похвастаться") preview modal. */
|
||||
.share-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
background: rgba(0,0,0,0.88);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
padding-top: calc(16px + var(--safe-area-top));
|
||||
padding-bottom: calc(16px + var(--safe-area-bottom));
|
||||
}
|
||||
.share-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.share-preview {
|
||||
max-width: 100%;
|
||||
max-height: 54vh;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
/* checkerboard so a transparent PNG reads as transparent */
|
||||
background-image:
|
||||
linear-gradient(45deg, #3a3a3a 25%, transparent 25%, transparent 75%, #3a3a3a 75%),
|
||||
linear-gradient(45deg, #3a3a3a 25%, #262626 25%, #262626 75%, #3a3a3a 75%);
|
||||
background-size: 24px 24px;
|
||||
background-position: 0 0, 12px 12px;
|
||||
}
|
||||
.share-toggle, .share-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.share-toggle button, .share-actions button { padding: 9px 14px; }
|
||||
.share-fields {
|
||||
display: flex; flex-wrap: wrap; gap: 6px 12px; justify-content: center;
|
||||
max-width: 520px;
|
||||
}
|
||||
.share-field {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 13px; color: #ddd; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.share-field input { width: 16px; height: 16px; flex: none; }
|
||||
|
||||
.rec-map-wrap {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
|
|
@ -1469,6 +1514,34 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Collapsible section (native <details>): header stays visible, body folds away
|
||||
so the action buttons below aren't buried under a long segment list. */
|
||||
.sd-acc-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.sd-acc-title::-webkit-details-marker { display: none; }
|
||||
.sd-acc-title::before {
|
||||
content: "\25B8"; /* ▸ */
|
||||
display: inline-block;
|
||||
transition: transform 0.15s;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sd-accordion[open] .sd-acc-title::before { transform: rotate(90deg); }
|
||||
.sd-acc-count {
|
||||
background: var(--border);
|
||||
color: var(--on-bg);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sd-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
|
|||
|
|
@ -810,6 +810,18 @@ async function revertGapFill(sessionId) {
|
|||
await execute("UPDATE gps_sessions SET gap_filled=0 WHERE id=?1", [sessionId]);
|
||||
}
|
||||
|
||||
// Persist the per-point "walking" flag (walkIds = point ids in walking runs). Used by the
|
||||
// track colour, the ridden-roads overlay (rebuild_roads) and the ride-graph router.
|
||||
async function setWalkFlags(sessionId, walkIds) {
|
||||
await execute("UPDATE gps_points SET walk=0 WHERE session_id=?1", [sessionId]);
|
||||
let CHUNK = 200;
|
||||
for (let i = 0; i < walkIds.length; i += CHUNK) {
|
||||
let ch = walkIds.slice(i, i + CHUNK);
|
||||
let ph = ch.map((_, k) => "?" + (k + 1)).join(",");
|
||||
await execute(`UPDATE gps_points SET walk=1 WHERE id IN (${ph})`, ch);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) {
|
||||
// Guard against NaN — SQLite may reject or corrupt NaN values
|
||||
const safe = v => (v != null && !isNaN(v)) ? v : null;
|
||||
|
|
@ -1072,6 +1084,27 @@ async function deleteGoalMarker(id) {
|
|||
await execute("DELETE FROM goal_markers WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nogo areas — impassable spots the router must avoid (circle: lat/lon + radius)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getNogoAreas() {
|
||||
return select("SELECT * FROM nogo_areas ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
async function addNogoArea(lat, lon, radiusM, note) {
|
||||
await execute(
|
||||
"INSERT INTO nogo_areas (lat, lon, radius_m, note) VALUES (?1, ?2, ?3, ?4)",
|
||||
[lat, lon, radiusM ?? 25, note ?? null]
|
||||
);
|
||||
let rows = await select("SELECT CAST(id AS TEXT) AS id FROM nogo_areas ORDER BY id DESC LIMIT 1");
|
||||
return rows.length ? rows[0].id : null;
|
||||
}
|
||||
|
||||
async function deleteNogoArea(id) {
|
||||
await execute("DELETE FROM nogo_areas WHERE id = ?1", [id]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Saved routes (hand-drawn reference paths overlaid on the map)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1259,6 +1292,7 @@ window.DB.deleteGpsSession = deleteGpsSession;
|
|||
window.DB.importGpxSession = importGpxSession;
|
||||
window.DB.saveGapFill = saveGapFill;
|
||||
window.DB.revertGapFill = revertGapFill;
|
||||
window.DB.setWalkFlags = setWalkFlags;
|
||||
|
||||
async function findNearestAltitude(lat, lon, radiusM) {
|
||||
const dLat = radiusM / 111000;
|
||||
|
|
@ -1288,6 +1322,9 @@ window.DB.getBikeMaintenanceDue = getBikeMaintenanceDue;
|
|||
window.DB.getGoalMarkers = getGoalMarkers;
|
||||
window.DB.addGoalMarker = addGoalMarker;
|
||||
window.DB.deleteGoalMarker = deleteGoalMarker;
|
||||
window.DB.getNogoAreas = getNogoAreas;
|
||||
window.DB.addNogoArea = addNogoArea;
|
||||
window.DB.deleteNogoArea = deleteNogoArea;
|
||||
window.DB.getRoutes = getRoutes;
|
||||
window.DB.getRoute = getRoute;
|
||||
window.DB.addRoute = addRoute;
|
||||
|
|
@ -1478,6 +1515,7 @@ async function runMigrations() {
|
|||
if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL");
|
||||
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
|
||||
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
|
||||
if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads)
|
||||
|
||||
// --- Cooking methods table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
|
||||
|
|
@ -1634,6 +1672,16 @@ async function runMigrations() {
|
|||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// --- Nogo areas (impassable spots the router avoids) ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS nogo_areas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
radius_m REAL DEFAULT 25,
|
||||
note TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// --- Saved routes (hand-drawn reference paths overlaid on the map) ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ GPS.config = {
|
|||
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
|
||||
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
|
||||
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
|
||||
walkDetectKmh: 8, // в вело-треке устойчивая скорость ниже = ведёшь/идёшь (не едешь)
|
||||
walkMinRunSec: 60, // минимум длины пешего куска, чтобы засчитать (не притормозил у столба)
|
||||
bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов)
|
||||
walkAvgSpeedKmh: 5, // средняя скорость ходьбы
|
||||
runAvgSpeedKmh: 10, // средняя скорость бега
|
||||
|
|
@ -50,6 +52,45 @@ try {
|
|||
}
|
||||
} catch {}
|
||||
|
||||
// Detect "walking" runs inside a ride (pushing the bike / on foot across a field): contiguous
|
||||
// runs where the smoothed speed stays below a threshold for at least walkMinRunSec. The
|
||||
// threshold is the single-speed push speed when gear data is given (30-rpm cadence), else
|
||||
// config.walkDetectKmh — universal by speed, refined by gear. Points are assumed time-ordered
|
||||
// (as read from DB). Returns a boolean array parallel to `points` (true = walking).
|
||||
GPS.detectWalk = function(points, opts) {
|
||||
opts = opts || {};
|
||||
let n = points ? points.length : 0;
|
||||
let out = new Array(n).fill(false);
|
||||
if (n < 3) return out;
|
||||
let thrMs;
|
||||
let hasGear = opts.chainringTeeth && opts.cogTeeth && opts.wheelDiameterInch;
|
||||
if (hasGear && (opts.gearCount == null || opts.gearCount <= 1)) {
|
||||
let circ = Math.PI * opts.wheelDiameterInch * 0.0254, ratio = opts.chainringTeeth / opts.cogTeeth;
|
||||
thrMs = (30 * circ * ratio) / 60;
|
||||
} else {
|
||||
thrMs = (GPS.config.walkDetectKmh ?? 8) / 3.6;
|
||||
}
|
||||
let minRun = opts.walkMinRunSec ?? (GPS.config.walkMinRunSec ?? 60);
|
||||
let v = new Array(n).fill(0);
|
||||
for (let i = 1; i < n; i++) {
|
||||
let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
|
||||
let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||||
let sp = points[i].speed_ms;
|
||||
v[i] = (sp != null && sp >= 0) ? sp : (dt > 0 ? d / dt : 0);
|
||||
}
|
||||
let sm = v.map((_, i) => { let s = 0, c = 0; for (let j = Math.max(0, i-2); j <= Math.min(n-1, i+2); j++) { s += v[j]; c++; } return c ? s / c : 0; });
|
||||
let i = 1;
|
||||
while (i < n) {
|
||||
if (sm[i] > 0.15 && sm[i] < thrMs) { // > 0.15 m/s excludes standing still
|
||||
let j = i, dur = 0;
|
||||
while (j < n && sm[j] > 0.15 && sm[j] < thrMs) { dur += Math.max(0, (new Date(points[j].ts) - new Date(points[j-1].ts)) / 1000); j++; }
|
||||
if (dur >= minRun) for (let k = i - 1; k < j; k++) out[k] = true;
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
|
||||
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
|
||||
// Only touch the value if it's still the old default; respect a user-set threshold.
|
||||
|
|
@ -1667,7 +1708,7 @@ GPS.formatDuration = function(seconds) {
|
|||
};
|
||||
|
||||
GPS.formatDistance = function(meters) {
|
||||
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : meters + " м";
|
||||
return meters >= 1000 ? (meters / 1000).toFixed(2) + " км" : Math.round(meters) + " м";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1733,6 +1774,16 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
|
||||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
|
||||
// Persist the per-point "walking" flag (pushed bike / on foot inside a ride) for the roads
|
||||
// overlay and the ridden-graph router. Not for walk/run sessions (all on foot already).
|
||||
if (session.activity_type !== "walk" && session.activity_type !== "run") {
|
||||
try {
|
||||
const wf = GPS.detectWalk(sorted);
|
||||
const ids = sorted.filter((p, i) => wf[i] && p.id != null).map(p => p.id);
|
||||
await DB.setWalkFlags(sessionId, ids);
|
||||
} catch (e) { console.warn("[walk] persist failed", e); }
|
||||
}
|
||||
|
||||
// Manual routes: skip glitch filtering (points are user-placed, not GPS).
|
||||
// Just fix timestamps and recompute stats.
|
||||
if (session.source === "manual") {
|
||||
|
|
@ -1959,6 +2010,14 @@ GPS.recomputeAllStats = async function(onProgress) {
|
|||
});
|
||||
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses);
|
||||
// Persist the per-point "walking" flag too (roads overlay + ridden-graph router).
|
||||
if (session.activity_type !== "walk" && session.activity_type !== "run") {
|
||||
try {
|
||||
const sw = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
const wf = GPS.detectWalk(sw);
|
||||
await DB.setWalkFlags(sid, sw.filter((p, k) => wf[k] && p.id != null).map(p => p.id));
|
||||
} catch (e) { console.warn("[walk] persist failed", sid, e); }
|
||||
}
|
||||
updated++;
|
||||
} catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3169,6 +3169,312 @@ async function drawActiveRouteOverlay(map, prev) {
|
|||
return drawRoutePolyline(map, route.points, route.color);
|
||||
}
|
||||
|
||||
// Draw the impassable "nogo" circles (DB.nogo_areas) as red translucent circles. Tapping a
|
||||
// circle offers to remove it; onChange() (if given) fires after a removal so the caller can
|
||||
// re-fetch nogos and re-route. Returns the layer group (removed via the `prev` arg on redraw).
|
||||
async function drawNogoAreas(map, prev, onChange) {
|
||||
if (prev) { try { map.removeLayer(prev); } catch {} }
|
||||
let areas = await DB.getNogoAreas();
|
||||
let group = L.layerGroup();
|
||||
for (let a of areas) {
|
||||
let c = L.circle([a.lat, a.lon], { radius: a.radius_m || 25, color: "#e53935", weight: 2, fillColor: "#e53935", fillOpacity: 0.22 });
|
||||
((area) => c.on("click", async (e) => {
|
||||
L.DomEvent.stop(e);
|
||||
if (await appConfirm("Убрать пометку «нет проезда»?", "Убрать")) {
|
||||
await DB.deleteNogoArea(area.id);
|
||||
if (onChange) onChange();
|
||||
}
|
||||
}))(a);
|
||||
c.addTo(group);
|
||||
}
|
||||
group.addTo(map);
|
||||
return group;
|
||||
}
|
||||
|
||||
// nogos for the router: [{lat,lon,radius}] from the nogo_areas table.
|
||||
async function getNogosForRouter() {
|
||||
return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ridden-roads router: route on the user's own tracks first, BRouter for the rest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _rideGraph = null; // { nodes:[[lat,lon]], adj:Map, grid:Map, cellDeg }
|
||||
async function loadRideGraph(force) {
|
||||
if (_rideGraph && !force) return _rideGraph;
|
||||
let raw = await window.__TAURI__.core.invoke("get_ride_graph", { minSpeedMs: 0.8 });
|
||||
let g = JSON.parse(raw);
|
||||
let nodes = g.nodes || [];
|
||||
let adj = new Map();
|
||||
for (let e of (g.edges || [])) {
|
||||
let a = e[0], b = e[1], d = e[2];
|
||||
if (!adj.has(a)) adj.set(a, []); adj.get(a).push([b, d]);
|
||||
if (!adj.has(b)) adj.set(b, []); adj.get(b).push([a, d]);
|
||||
}
|
||||
let cellDeg = 0.00045; // ~50 m nearest-node buckets
|
||||
let grid = new Map();
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let key = Math.round(nodes[i][0] / cellDeg) + "," + Math.round(nodes[i][1] / cellDeg);
|
||||
if (!grid.has(key)) grid.set(key, []);
|
||||
grid.get(key).push(i);
|
||||
}
|
||||
_rideGraph = { nodes, adj, grid, cellDeg };
|
||||
return _rideGraph;
|
||||
}
|
||||
function _nearestRideNode(g, lat, lon, maxM) {
|
||||
let cx = Math.round(lat / g.cellDeg), cy = Math.round(lon / g.cellDeg), best = -1, bestD = Infinity;
|
||||
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
|
||||
let arr = g.grid.get((cx + dx) + "," + (cy + dy)); if (!arr) continue;
|
||||
for (let i of arr) { let d = GPS.distance(lat, lon, g.nodes[i][0], g.nodes[i][1]); if (d < bestD) { bestD = d; best = i; } }
|
||||
}
|
||||
return (best >= 0 && bestD <= maxM) ? { node: best, dist: bestD } : null;
|
||||
}
|
||||
function _rideDijkstra(g, src, dst, maxDist) {
|
||||
let distMap = new Map([[src, 0]]), prev = new Map(), heap = [[0, src]], seen = new Set();
|
||||
while (heap.length) {
|
||||
let mi = 0; for (let i = 1; i < heap.length; i++) if (heap[i][0] < heap[mi][0]) mi = i;
|
||||
let popped = heap.splice(mi, 1)[0], d = popped[0], u = popped[1];
|
||||
if (seen.has(u)) continue; seen.add(u);
|
||||
if (u === dst) break;
|
||||
if (d > maxDist) return null;
|
||||
let nb = g.adj.get(u); if (!nb) continue;
|
||||
for (let vw of nb) {
|
||||
let v = vw[0], w = vw[1]; if (seen.has(v)) continue;
|
||||
let nd = d + w;
|
||||
if (nd < (distMap.get(v) ?? Infinity)) { distMap.set(v, nd); prev.set(v, u); heap.push([nd, v]); }
|
||||
}
|
||||
}
|
||||
if (!seen.has(dst)) return null;
|
||||
let path = [], u = dst;
|
||||
while (u != null) { path.push([g.nodes[u][0], g.nodes[u][1]]); u = prev.get(u); }
|
||||
path.reverse();
|
||||
return { path, dist: distMap.get(dst) };
|
||||
}
|
||||
// Route a→b on ridden roads. Returns [{lat,lon}] (with the exact waypoints at the ends) or null
|
||||
// when either end isn't near ridden roads or the ridden path is too indirect (fall back to BRouter).
|
||||
async function rideRoute(aLat, aLon, bLat, bLon, opts) {
|
||||
opts = opts || {};
|
||||
let g = await loadRideGraph();
|
||||
if (!g || !g.nodes.length) return null;
|
||||
let sn = _nearestRideNode(g, aLat, aLon, opts.snapM ?? 60);
|
||||
let dn = _nearestRideNode(g, bLat, bLon, opts.snapM ?? 60);
|
||||
if (!sn || !dn) return null;
|
||||
if (sn.node === dn.node) return null; // same node → let BRouter handle a tiny leg
|
||||
let straight = GPS.distance(aLat, aLon, bLat, bLon);
|
||||
let cap = (opts.maxRatio ?? 2.5) * Math.max(300, straight);
|
||||
let res = _rideDijkstra(g, sn.node, dn.node, cap + 2000);
|
||||
if (!res || res.dist > cap) return null;
|
||||
return [{ lat: aLat, lon: aLon }].concat(res.path.map(p => ({ lat: p[0], lon: p[1] })), [{ lat: bLat, lon: bLon }]);
|
||||
}
|
||||
|
||||
// Move a point `d` metres along bearing `brDeg` (for a "start forward, don't U-turn" helper).
|
||||
function _offsetLatLon(lat, lon, brDeg, d) {
|
||||
let R = 6378137, br = brDeg * Math.PI / 180, la1 = lat * Math.PI / 180, lo1 = lon * Math.PI / 180;
|
||||
let la2 = Math.asin(Math.sin(la1) * Math.cos(d / R) + Math.cos(la1) * Math.sin(d / R) * Math.cos(br));
|
||||
let lo2 = lo1 + Math.atan2(Math.sin(br) * Math.sin(d / R) * Math.cos(la1), Math.cos(d / R) - Math.sin(la1) * Math.sin(la2));
|
||||
return { lat: la2 * 180 / Math.PI, lon: lo2 * 180 / Math.PI };
|
||||
}
|
||||
|
||||
// On-the-fly detour: from the current position, build a connector back ONTO the saved route
|
||||
// PAST the blockage, avoiding nogos, preferring to start in the travel direction (no 180 if a
|
||||
// bearing is given). Does NOT modify the saved route. Blockage reference = nearest nogo that
|
||||
// sits on the route (<=150 m), else the current position's nearest route point. Target = a
|
||||
// route point ~1.5 km further along; on failure step further (up to the route end).
|
||||
// Returns { points:[{lat,lon}], target:{lat,lon} } or null.
|
||||
async function rejoinReroute(cur, bearingDeg, routePts, nogos) {
|
||||
if (!cur || !routePts || routePts.length < 2) return null;
|
||||
let cum = [0];
|
||||
for (let i = 1; i < routePts.length; i++) cum[i] = cum[i-1] + GPS.distance(routePts[i-1].lat, routePts[i-1].lon, routePts[i].lat, routePts[i].lon);
|
||||
let nearestRouteDist = (pt) => { let d = Infinity; for (let p of routePts) { let dd = GPS.distance(pt.lat, pt.lon, p.lat, p.lon); if (dd < d) d = dd; } return d; };
|
||||
// reference point of the blockage
|
||||
let ref = cur;
|
||||
if (nogos && nogos.length) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (let n of nogos) if (nearestRouteDist(n) <= 150) { let dc = GPS.distance(cur.lat, cur.lon, n.lat, n.lon); if (dc < bestD) { bestD = dc; best = n; } }
|
||||
if (best) ref = best;
|
||||
}
|
||||
let refIdx = 0, refD = Infinity;
|
||||
for (let i = 0; i < routePts.length; i++) { let d = GPS.distance(ref.lat, ref.lon, routePts[i].lat, routePts[i].lon); if (d < refD) { refD = d; refIdx = i; } }
|
||||
let helper = (bearingDeg != null && !isNaN(bearingDeg)) ? _offsetLatLon(cur.lat, cur.lon, bearingDeg, 40) : null;
|
||||
let skip = 1500;
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
let want = cum[refIdx] + skip, ti = refIdx;
|
||||
while (ti < routePts.length - 1 && cum[ti] < want) ti++;
|
||||
let target = routePts[ti];
|
||||
let straight = GPS.distance(cur.lat, cur.lon, target.lat, target.lon);
|
||||
let wps = helper ? [[cur.lon, cur.lat], [helper.lon, helper.lat], [target.lon, target.lat]] : [[cur.lon, cur.lat], [target.lon, target.lat]];
|
||||
try {
|
||||
let gpx = await BRouter.route(wps, "trekking", nogos);
|
||||
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
|
||||
if (rp.length >= 2) {
|
||||
let rlen = 0; for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
|
||||
if (rlen <= Math.max(500, straight * 3)) return { points: rp.map(p => ({ lat: p.lat, lon: p.lon })), target: { lat: target.lat, lon: target.lon } };
|
||||
}
|
||||
} catch (e) { /* try a further rejoin point */ }
|
||||
if (ti >= routePts.length - 1) break;
|
||||
skip += 1500;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Share image ("Похвастаться"): track shape + stats as a story-format PNG
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Story-format duration: "1h 29min" / "29 min" (GPS.formatDuration is h:mm:ss).
|
||||
function _shareDuration(sec) {
|
||||
let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
|
||||
if (m === 60) { h += 1; m = 0; }
|
||||
return h > 0 ? (m > 0 ? h + "h " + m + "min" : h + "h") : m + " min";
|
||||
}
|
||||
// Project track points into a WxH box (equirectangular at track scale, padded, Y down).
|
||||
function _projectTrack(points, w, h, pad) {
|
||||
let lats = points.map(p => p.lat), lons = points.map(p => p.lon);
|
||||
let minLa = Math.min(...lats), maxLa = Math.max(...lats), minLo = Math.min(...lons), maxLo = Math.max(...lons);
|
||||
let kx = Math.cos((minLa + maxLa) / 2 * Math.PI / 180); // lon → same metric scale as lat
|
||||
let spanX = ((maxLo - minLo) * kx) || 1e-6, spanY = (maxLa - minLa) || 1e-6;
|
||||
let scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY);
|
||||
let ox = (w - spanX * scale) / 2, oy = (h - spanY * scale) / 2;
|
||||
return points.map(p => ({ x: ox + (p.lon - minLo) * kx * scale, y: oy + (maxLa - p.lat) * scale }));
|
||||
}
|
||||
// Which fields the share image can show, and their defaults. `f` is the per-image
|
||||
// selection (opts.fields), falling back to defaults so an unset key stays on.
|
||||
let SHARE_FIELDS = ["track", "distance", "time", "speed", "maxspeed", "elev", "kcal", "date"];
|
||||
let SHARE_FIELD_LABELS = { track: "Форма трека", distance: "Дистанция", time: "Время", speed: "Ср. скорость", maxspeed: "Макс. скорость", elev: "Набор высоты", kcal: "Калории", date: "Дата" };
|
||||
let SHARE_FIELD_DEFAULTS = { track: true, distance: true, time: true, speed: true, maxspeed: false, elev: true, kcal: true, date: true };
|
||||
function shareFieldOn(fields, key) { return fields && key in fields ? !!fields[key] : SHARE_FIELD_DEFAULTS[key]; }
|
||||
|
||||
// Build the share PNG dataURL. opts.bg: "transparent" | "photo"; opts.fields: per-field on/off.
|
||||
async function buildShareImage(session, stats, points, opts) {
|
||||
opts = opts || {};
|
||||
let f = opts.fields;
|
||||
let W = 1080, H = 1920;
|
||||
let cv = document.createElement("canvas"); cv.width = W; cv.height = H;
|
||||
let ctx = cv.getContext("2d");
|
||||
if (opts.bg === "photo") {
|
||||
let bg = localStorage.getItem("baldav_share_bg");
|
||||
if (bg) {
|
||||
await new Promise((res) => {
|
||||
let img = new Image();
|
||||
img.onload = () => { let s = Math.max(W / img.width, H / img.height); let dw = img.width * s, dh = img.height * s; ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); res(); };
|
||||
img.onerror = () => res();
|
||||
img.src = bg;
|
||||
});
|
||||
}
|
||||
let g = ctx.createLinearGradient(0, H * 0.4, 0, H);
|
||||
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.55)");
|
||||
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
|
||||
}
|
||||
let hasTrack = shareFieldOn(f, "track") && points && points.length >= 2;
|
||||
// Track shape (top ~42%).
|
||||
if (hasTrack) {
|
||||
let boxTop = 90, boxH = Math.round(H * 0.42);
|
||||
let pts = _projectTrack(points.map(p => ({ lat: p.lat, lon: p.lon })), W, boxH, 130);
|
||||
let col = (window.GPS && GPS.config && GPS.config.trackColor) || "#2f6bff";
|
||||
ctx.lineJoin = "round"; ctx.lineCap = "round";
|
||||
ctx.shadowColor = col; ctx.shadowBlur = 30; ctx.strokeStyle = col; ctx.lineWidth = 12;
|
||||
ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y + boxTop) : ctx.moveTo(p.x, p.y + boxTop));
|
||||
ctx.stroke(); ctx.shadowBlur = 0;
|
||||
}
|
||||
// Stats.
|
||||
let cx = W / 2;
|
||||
function bigStat(y, value, label) {
|
||||
ctx.textAlign = "center";
|
||||
ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3;
|
||||
ctx.fillStyle = "#fff"; ctx.font = "700 100px system-ui, -apple-system, sans-serif"; ctx.fillText(value, cx, y);
|
||||
ctx.shadowBlur = 5; ctx.fillStyle = "rgba(255,255,255,0.85)"; ctx.font = "400 42px system-ui, -apple-system, sans-serif"; ctx.fillText(label, cx, y + 56);
|
||||
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
|
||||
}
|
||||
// Collect enabled stats; lay them out adaptively so turning some off leaves no gaps.
|
||||
let big = [];
|
||||
if (shareFieldOn(f, "distance")) big.push([(stats.distance / 1000).toFixed(1) + " km", "Distance"]);
|
||||
if (shareFieldOn(f, "time")) big.push([_shareDuration(stats.duration || 0), "Time"]);
|
||||
if (shareFieldOn(f, "speed")) big.push([(stats.avgSpeed || 0).toFixed(1) + " km/h", "Speed"]);
|
||||
if (shareFieldOn(f, "maxspeed") && stats.maxSpeed != null) big.push([(stats.maxSpeed || 0).toFixed(1) + " km/h", "Max speed"]);
|
||||
let footer = [];
|
||||
if (shareFieldOn(f, "elev") && stats.elevGain != null) footer.push("↑ " + Math.round(stats.elevGain) + " m");
|
||||
if (shareFieldOn(f, "kcal") && stats.kcal != null) footer.push(Math.round(stats.kcal) + " kcal");
|
||||
if (shareFieldOn(f, "date") && session && session.date) footer.push(session.date);
|
||||
// Position the stats+footer block: just under the track when it's shown, else centred.
|
||||
let blockH = big.length * 250 + (footer.length ? 90 : 0);
|
||||
let y0 = hasTrack ? Math.round(H * 0.55) : Math.round((H - blockH) / 2 + 90);
|
||||
big.forEach((s, i) => bigStat(y0 + i * 250, s[0], s[1]));
|
||||
if (footer.length) {
|
||||
ctx.textAlign = "center"; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 6;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "400 36px system-ui, -apple-system, sans-serif";
|
||||
ctx.fillText(footer.join(" · "), cx, y0 + big.length * 250 + 40); ctx.shadowBlur = 0;
|
||||
}
|
||||
return cv.toDataURL("image/png");
|
||||
}
|
||||
// Send the generated PNG to the native layer: action "share" (system chooser) or "save" (SAF).
|
||||
async function exportShareImage(dataUrl, filename, action) {
|
||||
if (!dataUrl) return;
|
||||
let base64 = dataUrl.split(",")[1];
|
||||
try {
|
||||
await window.__TAURI__.core.invoke("export_image", { filename, base64, action });
|
||||
showToast(action === "share" ? "Открываю «Поделиться»…" : "Сохранение…");
|
||||
} catch (e) { showToast("Экспорт картинки: " + (e.message || e)); }
|
||||
}
|
||||
// Preview modal with a transparent/photo toggle + Share/Save.
|
||||
function showShareModal(session, stats, points) {
|
||||
// Per-field on/off, remembered across shares. Missing keys fall back to defaults.
|
||||
let fields = {};
|
||||
try { fields = JSON.parse(localStorage.getItem("baldav_share_fields") || "{}") || {}; } catch { fields = {}; }
|
||||
let fieldChecks = SHARE_FIELDS.map(k =>
|
||||
`<label class="share-field"><input type="checkbox" data-field="${k}" ${shareFieldOn(fields, k) ? "checked" : ""}>${SHARE_FIELD_LABELS[k]}</label>`
|
||||
).join("");
|
||||
let ov = document.createElement("div");
|
||||
ov.className = "share-modal";
|
||||
ov.innerHTML = `
|
||||
<div class="share-box">
|
||||
<img id="share-img" class="share-preview" alt="">
|
||||
<div class="share-fields">${fieldChecks}</div>
|
||||
<div class="share-toggle">
|
||||
<button id="share-bg-t" class="btn-secondary active">Прозрачный</button>
|
||||
<button id="share-bg-p" class="btn-secondary">С фото</button>
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<button id="share-do" class="btn-primary">Поделиться</button>
|
||||
<button id="share-save" class="btn-secondary">Сохранить</button>
|
||||
<button id="share-close" class="btn-secondary" style="color:#cf6679">Закрыть</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
// Register in the global window stack so the hardware/gesture back closes THIS
|
||||
// modal instead of paging the screens behind it (winPush adds a history entry).
|
||||
let base = winStack.length, closed = false;
|
||||
winPush("share", () => ov.remove());
|
||||
let closeShare = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
let count = winStack.length - base;
|
||||
for (let i = 0; i < count; i++) winStack.pop().close();
|
||||
_skipPops = count;
|
||||
if (count > 0) history.go(-count);
|
||||
ov.remove();
|
||||
};
|
||||
let bg = "transparent", dataUrl = null;
|
||||
let img = ov.querySelector("#share-img");
|
||||
let bt = ov.querySelector("#share-bg-t"), bp = ov.querySelector("#share-bg-p");
|
||||
async function render() { dataUrl = await buildShareImage(session, stats, points, { bg, fields }); img.src = dataUrl; }
|
||||
ov.querySelectorAll(".share-field input").forEach(cb => cb.onchange = () => {
|
||||
fields[cb.dataset.field] = cb.checked;
|
||||
try { localStorage.setItem("baldav_share_fields", JSON.stringify(fields)); } catch {}
|
||||
render();
|
||||
});
|
||||
bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); };
|
||||
bp.onclick = () => {
|
||||
if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана - задайте в Настройках"); return; }
|
||||
bg = "photo"; bp.classList.add("active"); bt.classList.remove("active"); render();
|
||||
};
|
||||
let filename = () => "baldav-" + ((session && session.date) || "track") + ".png";
|
||||
ov.querySelector("#share-do").onclick = () => exportShareImage(dataUrl, filename(), "share");
|
||||
ov.querySelector("#share-save").onclick = () => exportShareImage(dataUrl, filename(), "save");
|
||||
ov.querySelector("#share-close").onclick = () => closeShare();
|
||||
render();
|
||||
}
|
||||
|
||||
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
|
||||
// kept — one colour per segment): green -> yellow -> red -> brown -> black.
|
||||
// <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude.
|
||||
|
|
@ -3505,6 +3811,9 @@ async function showRouteEditor(container, routeId) {
|
|||
let defaultMode = "road";
|
||||
let routeTimer = null;
|
||||
let dirty = new Set();
|
||||
let nogos = []; // [{lat,lon,radius}] impassable circles the route must avoid
|
||||
let nogoLayer = null; // red circles overlay
|
||||
let nogoMode = false; // when on, a map tap drops a nogo instead of a waypoint
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="draw-screen">
|
||||
|
|
@ -3582,7 +3891,7 @@ async function showRouteEditor(container, routeId) {
|
|||
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("click", (e) => { L.DomEvent.stop(e); if (nogoMode) placeNogo(e.latlng); else insertInLeg(idx, e.latlng); }); // tap → insert (or nogo)
|
||||
line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight
|
||||
})(i);
|
||||
hitLines.push(line);
|
||||
|
|
@ -3595,8 +3904,13 @@ async function showRouteEditor(container, routeId) {
|
|||
let idxs = [...dirty]; dirty.clear();
|
||||
for (let i of idxs) {
|
||||
if (i >= points.length - 1 || legMode[i] !== "road") continue;
|
||||
let a = points[i], b = points[i + 1];
|
||||
// Prefer already-ridden roads; use BRouter (public OSM) only where there is no ridden path.
|
||||
let ridden = null;
|
||||
try { ridden = await rideRoute(a.lat, a.lon, b.lat, b.lon); } catch (e) { /* fall back */ }
|
||||
if (ridden && ridden.length >= 2) { legGeom[i] = ridden.map(p => ({ lat: p.lat, lon: p.lon })); continue; }
|
||||
try {
|
||||
let gpx = await BRouter.route([[points[i].lon, points[i].lat], [points[i+1].lon, points[i+1].lat]], "trekking");
|
||||
let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "trekking", nogos);
|
||||
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
|
||||
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)
|
||||
|
|
@ -3691,6 +4005,37 @@ async function showRouteEditor(container, routeId) {
|
|||
};
|
||||
locCtl.addTo(map);
|
||||
|
||||
// Nogo (impassable) circles: toggle the ⛔ button, then a map/line tap drops a red circle
|
||||
// the routing avoids. Persist in DB; every road leg re-routes to go around them.
|
||||
async function refreshNogos(reroute) {
|
||||
nogos = await getNogosForRouter();
|
||||
nogoLayer = await drawNogoAreas(map, nogoLayer, () => refreshNogos(true));
|
||||
if (reroute) {
|
||||
for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); }
|
||||
redraw(); updateStats(); scheduleRoute();
|
||||
}
|
||||
}
|
||||
async function placeNogo(latlng) {
|
||||
await DB.addNogoArea(latlng.lat, latlng.lng, 25, null);
|
||||
await refreshNogos(true);
|
||||
}
|
||||
let nogoCtl = L.control({ position: "topleft" });
|
||||
nogoCtl.onAdd = () => {
|
||||
let d = L.DomUtil.create("div", "sd-map-fs-btn");
|
||||
d.innerHTML = "⛔";
|
||||
d.title = "Пометить непроезжее: тап по карте";
|
||||
L.DomEvent.disableClickPropagation(d);
|
||||
d.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
nogoMode = !nogoMode;
|
||||
d.classList.toggle("active", nogoMode);
|
||||
showToast(nogoMode ? "Тап по карте — пометить непроезжее" : "Режим отметки выключен");
|
||||
};
|
||||
return d;
|
||||
};
|
||||
nogoCtl.addTo(map);
|
||||
refreshNogos(false);
|
||||
|
||||
// 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) {
|
||||
|
|
@ -3708,7 +4053,7 @@ async function showRouteEditor(container, routeId) {
|
|||
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
|
||||
}
|
||||
|
||||
map.on("click", e => addPoint(e.latlng));
|
||||
map.on("click", e => { if (nogoMode) placeNogo(e.latlng); else addPoint(e.latlng); });
|
||||
|
||||
document.querySelector("#re-undo").onclick = () => {
|
||||
if (!points.length) return;
|
||||
|
|
@ -4244,6 +4589,60 @@ async function renderRecordingInline(container) {
|
|||
darkBtn.onclick = () => toggleDarkMap();
|
||||
btnWrap.appendChild(darkBtn);
|
||||
|
||||
// Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute
|
||||
// will avoid it). Shows existing nogo circles too.
|
||||
let recNogoLayer = null;
|
||||
async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); }
|
||||
refreshRecNogos();
|
||||
let recNogoBtn = document.createElement("button");
|
||||
recNogoBtn.className = "rec-map-btn";
|
||||
recNogoBtn.textContent = "⛔";
|
||||
recNogoBtn.title = "Здесь нет проезда (отметить текущее место)";
|
||||
recNogoBtn.onclick = async () => {
|
||||
let p = GPS.recording.lastMovingPoint;
|
||||
if (!p) { showToast("Нет позиции"); return; }
|
||||
await DB.addNogoArea(p.lat, p.lon, 25, null);
|
||||
await refreshRecNogos();
|
||||
showToast("Отмечено: нет проезда");
|
||||
};
|
||||
btnWrap.appendChild(recNogoBtn);
|
||||
|
||||
// Reroute back onto the active route past a blockage (nogo), from the current position,
|
||||
// avoiding nogos and 180° turns. Transient navigation overlay — does NOT touch the saved
|
||||
// route or the recording. Tap again to rebuild from the new position; long-press to clear.
|
||||
let rerouteLayer = null;
|
||||
let rerouteBtn = document.createElement("button");
|
||||
rerouteBtn.className = "rec-map-btn";
|
||||
rerouteBtn.textContent = "↪";
|
||||
rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом";
|
||||
rerouteBtn.onclick = async () => {
|
||||
let rid = localStorage.getItem("baldav_active_route");
|
||||
if (!rid) { showToast("Нет активного маршрута"); return; }
|
||||
let cur = GPS.recording.lastMovingPoint;
|
||||
if (!cur) { showToast("Нет позиции"); return; }
|
||||
rerouteBtn.disabled = true; rerouteBtn.classList.add("active");
|
||||
try {
|
||||
let route = await DB.getRoute(rid);
|
||||
if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); return; }
|
||||
let ng = await getNogosForRouter();
|
||||
let bearing = (typeof smoothedHeading === "number") ? smoothedHeading : null;
|
||||
let res = await rejoinReroute(cur, bearing, route.points, ng);
|
||||
if (!res) { showToast("Объезд не найден"); rerouteBtn.classList.remove("active"); return; }
|
||||
if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; }
|
||||
let ll = res.points.map(p => [p.lat, p.lon]);
|
||||
let g = L.layerGroup();
|
||||
L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(g);
|
||||
L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(g);
|
||||
L.polyline(ll, { color: "#ff00ff", weight: 3, opacity: 1 }).addTo(g);
|
||||
L.circleMarker([res.target.lat, res.target.lon], { radius: 7, color: "#ff00ff", fillColor: "#fff", fillOpacity: 1, weight: 3 }).addTo(g);
|
||||
g.addTo(map); rerouteLayer = g;
|
||||
showToast("Объезд построен");
|
||||
} catch (e) { showToast("Объезд: " + (e.message || e)); }
|
||||
finally { rerouteBtn.disabled = false; }
|
||||
};
|
||||
rerouteBtn.oncontextmenu = (e) => { e.preventDefault(); if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; rerouteBtn.classList.remove("active"); showToast("Объезд убран"); } };
|
||||
btnWrap.appendChild(rerouteBtn);
|
||||
|
||||
POI.attach(map); // POI overlay + category filter on the recording map
|
||||
// The default-ON draw is deferred to the END of this function (after all the
|
||||
// critical wiring), where it awaits whenMapReady — so it can't delay setup.
|
||||
|
|
@ -4785,6 +5184,7 @@ async function showSessionDetail(sessionId) {
|
|||
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
|
||||
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
|
||||
</div>
|
||||
<div id="sd-walk-stat" class="prod-hint" style="text-align:center;margin-bottom:6px"></div>
|
||||
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -4812,8 +5212,8 @@ async function showSessionDetail(sessionId) {
|
|||
</div>` : ""}
|
||||
|
||||
${(stats.segments && stats.segments.length > 1) ? `
|
||||
<div class="sd-section">
|
||||
<div class="sd-title">Сегменты</div>
|
||||
<details class="sd-section sd-accordion">
|
||||
<summary class="sd-title sd-acc-title">Сегменты <span class="sd-acc-count">${stats.segments.length}</span></summary>
|
||||
<div class="climb-segments">
|
||||
${stats.segments.map(seg => {
|
||||
let icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-";
|
||||
|
|
@ -4829,7 +5229,7 @@ async function showSessionDetail(sessionId) {
|
|||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>` : ""}
|
||||
</details>` : ""}
|
||||
|
||||
<div class="sd-section" id="sd-goals-section" style="display:none">
|
||||
<div class="sd-title">Цели</div>
|
||||
|
|
@ -4847,6 +5247,7 @@ async function showSessionDetail(sessionId) {
|
|||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
|
||||
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
|
||||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
|
||||
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</button>
|
||||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||||
`, () => {});
|
||||
|
||||
|
|
@ -4868,6 +5269,7 @@ async function showSessionDetail(sessionId) {
|
|||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||||
addDarkMapControl(map); // dark-tiles toggle on the track view
|
||||
POI.attach(map); // POI overlay + category filter
|
||||
{ let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles
|
||||
|
||||
// Gap-fill: route GPS dropouts along roads (BRouter) and PREVIEW only the filled gaps
|
||||
// (not the whole track); then save (reversible, in-place) or discard. If the track is
|
||||
|
|
@ -5153,6 +5555,24 @@ async function showSessionDetail(sessionId) {
|
|||
flush();
|
||||
}
|
||||
|
||||
// Colour "walking" runs inside a ride (pushed bike / on foot across a field) so they stand
|
||||
// apart from riding; also report the pushed total. Walk/run sessions are all on foot already.
|
||||
if (session.activity_type !== "walk" && session.activity_type !== "run") {
|
||||
let walk = GPS.detectWalk(points);
|
||||
let run = [], flush = () => { if (run.length >= 2) L.polyline(run, { color: GPS.config.roadColorWalk || "#8B5E3C", weight: 6, opacity: 0.95 }).addTo(map); run = []; };
|
||||
let wSec = 0, wDist = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (walk[i]) {
|
||||
if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]);
|
||||
run.push([points[i].lat, points[i].lon]);
|
||||
if (i > 0) { wSec += Math.max(0, (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000); wDist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); }
|
||||
} else if (run.length) { run.push([points[i].lat, points[i].lon]); flush(); }
|
||||
}
|
||||
flush();
|
||||
let ws = document.querySelector("#sd-walk-stat");
|
||||
if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км";
|
||||
}
|
||||
|
||||
// Invisible but tappable hit-targets on top of track
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
let p = points[i];
|
||||
|
|
@ -5435,6 +5855,9 @@ async function showSessionDetail(sessionId) {
|
|||
let exp = document.querySelector("#sd-export");
|
||||
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
|
||||
|
||||
let shareBtn = document.querySelector("#sd-share");
|
||||
if (shareBtn) shareBtn.onclick = () => showShareModal(session, stats, points);
|
||||
|
||||
// Track ID — click to copy to clipboard
|
||||
let idEl = document.querySelector("#sd-track-id");
|
||||
if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId);
|
||||
|
|
@ -6255,6 +6678,16 @@ async function renderSettings(container) {
|
|||
</div>
|
||||
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Фон для картинки «Похвастаться»</div>
|
||||
<div class="settings-item-desc">Фото-подложка под форму трека и статы</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn-secondary" id="s-share-bg" style="padding:6px 12px;font-size:13px;">Выбрать</button>
|
||||
<button class="btn-secondary" id="s-share-bg-clear" style="padding:6px 12px;font-size:13px;color:#cf6679">Сброс</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">Очистить продукты без магазина</div>
|
||||
|
|
@ -6626,6 +7059,40 @@ async function renderSettings(container) {
|
|||
}
|
||||
};
|
||||
|
||||
// Share-image background: pick a photo, downscale, store as a data URL in localStorage.
|
||||
let sbgBtn = container.querySelector("#s-share-bg");
|
||||
if (sbgBtn) sbgBtn.onclick = async () => {
|
||||
let prev = sbgBtn.textContent, path;
|
||||
if (/Android/i.test(navigator.userAgent)) {
|
||||
sbgBtn.disabled = true; sbgBtn.textContent = "Выбор...";
|
||||
try { path = await pickLocalFile("image/*"); } catch (e) { showToast("Ошибка выбора: " + (e.message || e)); }
|
||||
sbgBtn.disabled = false; sbgBtn.textContent = prev;
|
||||
} else {
|
||||
path = await appPrompt("Путь к фото-подложке:", "");
|
||||
}
|
||||
if (!path) return;
|
||||
sbgBtn.disabled = true; sbgBtn.textContent = "Загрузка...";
|
||||
try {
|
||||
let b64 = await window.__TAURI__.core.invoke("read_file_base64", { path });
|
||||
let mime = /\.png$/i.test(path) ? "image/png" : "image/jpeg";
|
||||
await new Promise((res, rej) => {
|
||||
let img = new Image();
|
||||
img.onload = () => {
|
||||
let s = Math.min(1, 1080 / img.width);
|
||||
let c = document.createElement("canvas"); c.width = Math.round(img.width * s); c.height = Math.round(img.height * s);
|
||||
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
|
||||
try { localStorage.setItem("baldav_share_bg", c.toDataURL("image/jpeg", 0.85)); res(); } catch (e) { rej(e); }
|
||||
};
|
||||
img.onerror = () => rej(new Error("не удалось прочитать изображение"));
|
||||
img.src = "data:" + mime + ";base64," + b64;
|
||||
});
|
||||
showToast("Подложка сохранена");
|
||||
} catch (e) { showToast("Подложка: " + (e.message || e)); }
|
||||
sbgBtn.disabled = false; sbgBtn.textContent = prev;
|
||||
};
|
||||
let sbgClear = container.querySelector("#s-share-bg-clear");
|
||||
if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); };
|
||||
|
||||
// Download tiles from router — the list comes from the router's directory
|
||||
// listing, so a newly uploaded .db shows up without rebuilding the app.
|
||||
container.querySelector("#s-download-tiles").onclick = async () => {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,20 @@
|
|||
window.BRouter = {
|
||||
_servers: ["http://192.168.1.18:17777", "http://mirror.invalid:17777"],
|
||||
|
||||
async _routeServer(pts, profile) {
|
||||
// nogos: [{lat,lon,radius}] — impassable circles the router must avoid.
|
||||
_nogoParam(nogos) {
|
||||
if (!nogos || !nogos.length) return "";
|
||||
// RouteServer expects nogos=lon,lat,radiusMeters|... (radius in metres).
|
||||
let s = nogos.filter(n => n && n.lat != null && n.lon != null)
|
||||
.map(n => n.lon + "," + n.lat + "," + Math.round(n.radius || 25)).join("|");
|
||||
return s ? "&nogos=" + encodeURIComponent(s) : "";
|
||||
},
|
||||
|
||||
async _routeServer(pts, profile, nogos) {
|
||||
let lonlats = pts.map(p => p[0] + "," + p[1]).join("|");
|
||||
for (let base of this._servers) {
|
||||
try {
|
||||
let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx";
|
||||
let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx" + this._nogoParam(nogos);
|
||||
let gpx = await window.__TAURI__.core.invoke("http_request", { url, method: "GET" });
|
||||
if (gpx && gpx.indexOf("<gpx") >= 0) return gpx;
|
||||
} catch (e) { /* try next server */ }
|
||||
|
|
@ -23,25 +32,26 @@ window.BRouter = {
|
|||
return null;
|
||||
},
|
||||
|
||||
async route(points, profile) {
|
||||
async route(points, profile, nogos) {
|
||||
let pts = (points || []).map(p => [p[0], p[1]]);
|
||||
profile = profile || "trekking";
|
||||
nogos = (nogos || []).filter(n => n && n.lat != null && n.lon != null);
|
||||
let isAndroid = /Android/i.test(navigator.userAgent || "");
|
||||
// Desktop has no embedded engine (that's Kotlin/Android) — the native command would
|
||||
// just time out; go straight to the PC RouteServer. Android uses the engine, and
|
||||
// falls back to the RouteServer (reachable at home) only if the engine errors.
|
||||
if (!isAndroid) {
|
||||
let gpx = await this._routeServer(pts, profile);
|
||||
let gpx = await this._routeServer(pts, profile, nogos);
|
||||
if (gpx) return gpx;
|
||||
throw new Error("brouter: RouteServer unreachable");
|
||||
}
|
||||
try {
|
||||
let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile });
|
||||
let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile, nogos: JSON.stringify(nogos) });
|
||||
let res = JSON.parse(raw);
|
||||
if (res.ok) return res.gpx;
|
||||
throw new Error(res.error || "engine error");
|
||||
} catch (e) {
|
||||
let gpx = await this._routeServer(pts, profile);
|
||||
let gpx = await this._routeServer(pts, profile, nogos);
|
||||
if (gpx) return gpx;
|
||||
throw new Error("brouter: " + (e.message || e));
|
||||
}
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.451"
|
||||
version = "0.1.457"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.451"
|
||||
version = "0.1.457"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import javax.net.ssl.HttpsURLConnection
|
|||
class MainActivity : TauriActivity() {
|
||||
private var commandObserver: FileObserver? = null
|
||||
private var pendingSaveContent: String? = null
|
||||
private var pendingImageBytes: ByteArray? = null
|
||||
private var backWebView: WebView? = null
|
||||
|
||||
// Disable WryActivity's generated back handler: its mWebView.goBack() does NOT
|
||||
|
|
@ -66,6 +67,33 @@ class MainActivity : TauriActivity() {
|
|||
pendingSaveContent = null
|
||||
}
|
||||
|
||||
// SAF "create document" for the share PNG (bytes, image/png mime).
|
||||
private val saveImageLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("image/png")
|
||||
) { uri: Uri? ->
|
||||
if (uri != null && pendingImageBytes != null) {
|
||||
try { contentResolver.openOutputStream(uri)?.use { it.write(pendingImageBytes!!) } }
|
||||
catch (e: Exception) { Log.e("BalDav", "Failed to save image", e) }
|
||||
}
|
||||
pendingImageBytes = null
|
||||
}
|
||||
|
||||
// Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND).
|
||||
private fun shareImage(bytes: ByteArray, filename: String) {
|
||||
try {
|
||||
val dir = File(cacheDir, "shared"); dir.mkdirs()
|
||||
val f = File(dir, if (filename.isBlank()) "baldav.png" else filename)
|
||||
f.writeBytes(bytes)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.fileprovider", f)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "image/png"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(send, "Поделиться").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION))
|
||||
} catch (e: Exception) { Log.e("BalDav", "share image failed", e) }
|
||||
}
|
||||
|
||||
// SAF "open document" picker. A content:// URI has no filesystem path, so the
|
||||
// picked file is copied into the app dir and its path is written to
|
||||
// open_file_result.txt, which the JS side polls via the Rust get_picked_file.
|
||||
|
|
@ -302,9 +330,16 @@ class MainActivity : TauriActivity() {
|
|||
val pair = arr.getJSONArray(i)
|
||||
pts.add(doubleArrayOf(pair.getDouble(0), pair.getDouble(1)))
|
||||
}
|
||||
// Optional nogos: [{lat,lon,radius}] impassable circles to avoid → [lon,lat,radius].
|
||||
val nogos = ArrayList<DoubleArray>()
|
||||
val na = req.optJSONArray("nogos")
|
||||
if (na != null) for (i in 0 until na.length()) {
|
||||
val o = na.getJSONObject(i)
|
||||
nogos.add(doubleArrayOf(o.getDouble("lon"), o.getDouble("lat"), o.optDouble("radius", 25.0)))
|
||||
}
|
||||
val profilePath = File(filesDir, "brouter/profiles2/$profile.brf").absolutePath
|
||||
val segmentsDir = File(filesDir, "brouter/segments4")
|
||||
val gpx = routeBrouter(segmentsDir, profilePath, pts)
|
||||
val gpx = routeBrouter(segmentsDir, profilePath, pts, nogos)
|
||||
out.put("ok", true)
|
||||
out.put("gpx", gpx)
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -320,11 +355,28 @@ class MainActivity : TauriActivity() {
|
|||
// RoutingContext pointing at the profile (.brf; the engine parses it via ProfileCache
|
||||
// and loads lookups.dat from the same dir), waypoints as OsmNodeNamed (ilon/ilat are
|
||||
// micro-degrees), run the engine, return the track as GPX.
|
||||
private fun routeBrouter(segmentsDir: File, profilePath: String, pts: List<DoubleArray>): String {
|
||||
private fun routeBrouter(segmentsDir: File, profilePath: String, pts: List<DoubleArray>, nogos: List<DoubleArray> = emptyList()): String {
|
||||
val rc = btools.router.RoutingContext()
|
||||
rc.memoryclass = 128
|
||||
rc.localFunction = profilePath
|
||||
rc.setAlternativeIdx(0)
|
||||
// Nogo circles (impassable): each a hard block (nogoWeight = NaN). prepareNogoPoints
|
||||
// converts the metre radius to the engine's internal units before routing.
|
||||
if (nogos.isNotEmpty()) {
|
||||
val nogoList = ArrayList<btools.router.OsmNodeNamed>()
|
||||
for (n in nogos) {
|
||||
val nn = btools.router.OsmNodeNamed()
|
||||
nn.ilon = ((n[0] + 180.0) * 1000000.0 + 0.5).toInt()
|
||||
nn.ilat = ((n[1] + 90.0) * 1000000.0 + 0.5).toInt()
|
||||
nn.radius = n[2]
|
||||
nn.isNogo = true
|
||||
nn.nogoWeight = Double.NaN
|
||||
nn.name = "nogo"
|
||||
nogoList.add(nn)
|
||||
}
|
||||
btools.router.RoutingContext.prepareNogoPoints(nogoList)
|
||||
rc.nogopoints = nogoList
|
||||
}
|
||||
val wps = ArrayList<btools.router.OsmNodeNamed>()
|
||||
for (p in pts) {
|
||||
val n = btools.router.OsmNodeNamed()
|
||||
|
|
@ -448,6 +500,21 @@ class MainActivity : TauriActivity() {
|
|||
Log.d("BalDav", "Opening file picker for mime=$mime")
|
||||
openFileLauncher.launch(arrayOf(mime))
|
||||
}
|
||||
cmd.startsWith("exportimg:") -> {
|
||||
// exportimg:<save|share>:<filename> — decode export_image.b64, then SAF-save or share.
|
||||
val rest = cmd.substringAfter("exportimg:")
|
||||
val action = rest.substringBefore(":")
|
||||
val filename = rest.substringAfter(":")
|
||||
val b64File = File(appDataDir, "export_image.b64")
|
||||
if (b64File.exists()) {
|
||||
val bytes = try { android.util.Base64.decode(b64File.readText().trim(), android.util.Base64.DEFAULT) } catch (e: Exception) { null }
|
||||
b64File.delete()
|
||||
if (bytes != null) {
|
||||
if (action == "save") { pendingImageBytes = bytes; saveImageLauncher.launch(filename) }
|
||||
else shareImage(bytes, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -264,8 +264,11 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
|
|||
let d_lat: f64 = 0.000018;
|
||||
let d_lon: f64 = 0.000027;
|
||||
|
||||
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
|
||||
// activity — so pushed-across-a-field bits don't become a rideable road.
|
||||
let mut read_stmt = db.prepare(
|
||||
"SELECT p.lat, p.lon, s.activity_type FROM gps_points p
|
||||
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END
|
||||
FROM gps_points p
|
||||
JOIN gps_sessions s ON s.id = p.session_id
|
||||
WHERE p.speed_ms >= ?1
|
||||
ORDER BY p.session_id, p.seq"
|
||||
|
|
@ -338,6 +341,70 @@ fn get_road_points(app: tauri::AppHandle, offset: u32, batch_size: u32) -> Resul
|
|||
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))
|
||||
}
|
||||
|
||||
// Build a routable graph from the user's RIDDEN tracks (excludes walk=1 points), so the planner
|
||||
// can prefer real roads before public OSM data. Points are snapped to a ~20 m grid (nodes);
|
||||
// consecutive points of the same session make an edge (weight = metres). Returns compact JSON
|
||||
// { "nodes": [[lat,lon],...], "edges": [[a,b,dist],...] } (node index = position in nodes).
|
||||
#[tauri::command]
|
||||
fn get_ride_graph(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, String> {
|
||||
use std::collections::HashMap;
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let db_path = {
|
||||
let p1 = data_dir.join("../../baldav.db");
|
||||
let p2 = data_dir.join("baldav.db");
|
||||
let p3 = std::path::PathBuf::from(data_dir.to_str().unwrap_or("").replace("/files/dev.baldav.app", "")).join("baldav.db");
|
||||
if p1.exists() { p1 } else if p2.exists() { p2 } else { p3 }
|
||||
};
|
||||
let db = rusqlite::Connection::open(&db_path).map_err(|e| format!("open: {e}"))?;
|
||||
fn hav(la1: f64, lo1: f64, la2: f64, lo2: f64) -> f64 {
|
||||
let r = 6371000.0_f64;
|
||||
let dla = (la2 - la1).to_radians();
|
||||
let dlo = (lo2 - lo1).to_radians();
|
||||
let a = (dla / 2.0).sin().powi(2) + la1.to_radians().cos() * la2.to_radians().cos() * (dlo / 2.0).sin().powi(2);
|
||||
r * 2.0 * a.sqrt().asin()
|
||||
}
|
||||
let d_lat = 0.00018_f64; // ~20 m
|
||||
let d_lon = 0.00027_f64;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT lat, lon, session_id, seq FROM gps_points
|
||||
WHERE COALESCE(walk,0)=0 AND speed_ms >= ?1
|
||||
ORDER BY session_id, COALESCE(seq,999999), ts"
|
||||
).map_err(|e| format!("prepare: {e}"))?;
|
||||
let rows = stmt.query_map(rusqlite::params![min_speed_ms], |r| {
|
||||
Ok((r.get::<_, f64>(0)?, r.get::<_, f64>(1)?, r.get::<_, i64>(2)?))
|
||||
}).map_err(|e| format!("query: {e}"))?;
|
||||
|
||||
let mut node_idx: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
let mut nodes: Vec<(f64, f64)> = Vec::new();
|
||||
let mut edges: HashMap<(usize, usize), f64> = HashMap::new();
|
||||
let mut prev: Option<(i64, usize, f64, f64)> = None; // (session_id, node, lat, lon)
|
||||
for row in rows {
|
||||
let (lat, lon, sid) = row.map_err(|e| format!("row: {e}"))?;
|
||||
let c = ((lat / d_lat).round() as i64, (lon / d_lon).round() as i64);
|
||||
let ni = match node_idx.get(&c) {
|
||||
Some(&i) => i,
|
||||
None => { let i = nodes.len(); nodes.push((lat, lon)); node_idx.insert(c, i); i }
|
||||
};
|
||||
if let Some((psid, pn, _plat, _plon)) = prev {
|
||||
if psid == sid && pn != ni {
|
||||
// Edge weight = distance between the two NODES (not the consecutive points),
|
||||
// else intra-cell travel is lost and the path is badly under-weighted.
|
||||
let (nla, nlo) = nodes[pn];
|
||||
let d = hav(nla, nlo, lat, lon);
|
||||
if d <= 200.0 { // skip absurd jumps (GPS gap) between consecutive points
|
||||
let key = if pn < ni { (pn, ni) } else { (ni, pn) };
|
||||
let e = edges.entry(key).or_insert(f64::INFINITY);
|
||||
if d < *e { *e = d; }
|
||||
}
|
||||
}
|
||||
}
|
||||
prev = Some((sid, ni, lat, lon));
|
||||
}
|
||||
let nodes_json = nodes.iter().map(|(la, lo)| format!("[{:.6},{:.6}]", la, lo)).collect::<Vec<_>>().join(",");
|
||||
let edges_json = edges.iter().map(|((a, b), d)| format!("[{},{},{:.1}]", a, b, d)).collect::<Vec<_>>().join(",");
|
||||
Ok(format!("{{\"nodes\":[{}],\"edges\":[{}]}}", nodes_json, edges_json))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AppInfo {
|
||||
version: String,
|
||||
|
|
@ -398,16 +465,19 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
|
|||
// FileObserver watches, then poll for brouter_result_<id>.json. `lonlats` is a JSON
|
||||
// array of [lon,lat] pairs; returns the Kotlin result JSON ({ok,gpx}|{ok:false,error}).
|
||||
#[tauri::command]
|
||||
fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String) -> Result<String, String> {
|
||||
fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String, nogos: Option<String>) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
let id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?
|
||||
.as_millis().to_string();
|
||||
// nogos is a JSON array string [{lat,lon,radius}] from JS; default to empty array.
|
||||
let nogos_json = nogos.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[]".to_string());
|
||||
let req = format!(
|
||||
"{{\"profile\":{},\"lonlats\":{}}}",
|
||||
"{{\"profile\":{},\"lonlats\":{},\"nogos\":{}}}",
|
||||
serde_json::to_string(&profile).map_err(|e| e.to_string())?,
|
||||
lonlats
|
||||
lonlats,
|
||||
nogos_json
|
||||
);
|
||||
let result_name = format!("brouter_result_{}.json", id);
|
||||
// Write request + command to app_data_dir AND app root (where Kotlin watches).
|
||||
|
|
@ -530,6 +600,30 @@ fn save_file(app: tauri::AppHandle, filename: String, content: String) -> Result
|
|||
Ok("save_requested".to_string())
|
||||
}
|
||||
|
||||
// Export a PNG (base64) via the file bridge → Kotlin decodes + SAF-saves ("save") or shares it
|
||||
// via a chooser ("share"). Mirrors save_file but carries binary as base64.
|
||||
#[tauri::command]
|
||||
fn export_image(app: tauri::AppHandle, filename: String, base64: String, action: String) -> Result<String, String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf());
|
||||
let write_both = |name: &str, content: &str| {
|
||||
let _ = std::fs::write(data_dir.join(name), content);
|
||||
if let Some(r) = &root { let _ = std::fs::write(r.join(name), content); }
|
||||
};
|
||||
write_both("export_image.b64", &base64);
|
||||
write_both("gps_command.txt", &format!("exportimg:{}:{}", action, filename));
|
||||
Ok("export_requested".to_string())
|
||||
}
|
||||
|
||||
// Read any file and return its bytes base64-encoded (for loading a picked image into the WebView).
|
||||
#[tauri::command]
|
||||
fn read_file_base64(path: String) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||
}
|
||||
|
||||
// Read tiles from a SQLite file in batches for import into IndexedDB
|
||||
#[tauri::command]
|
||||
fn import_tiles(path: String, offset: u32, batch_size: u32) -> Result<String, String> {
|
||||
|
|
@ -994,7 +1088,7 @@ pub fn run() {
|
|||
.add_migrations("sqlite:baldav.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
|
||||
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_ride_graph, get_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
|
||||
"productName": "BalDav",
|
||||
"version": "0.1.451",
|
||||
"version": "0.1.457",
|
||||
"identifier": "dev.baldav.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue