Compare commits

...

16 commits

Author SHA1 Message Date
6dfe262801 Docs: ridden-roads router, share field selection, segments accordion 2026-09-14 13:44:09 +03:00
d79a9f217a Bump version to 0.1.457 (build.sh output) 2026-09-14 13:28:11 +03:00
4f31088af9 Track detail: fold the segment list into an accordion so action buttons stay reachable 2026-09-14 13:27:29 +03:00
0be980849c Bump version to 0.1.456 (build.sh output) 2026-09-14 13:08:17 +03:00
d9a3254ffb Share image: pick which fields go on the picture (remembered), add max speed 2026-09-14 13:07:32 +03:00
999361a2e9 Bump version to 0.1.455 (build.sh output) 2026-09-14 12:58:33 +03:00
eb2aab3e6b Share modal joins the window stack so back closes it, not the screens behind 2026-09-14 12:57:52 +03:00
a2cc150298 Bump version to 0.1.454 (build.sh output) 2026-09-14 12:47:04 +03:00
a30c742bc6 Ridden-roads-preferred router: plan over your own tracks first
Planner now tries a route over roads you have actually ridden before
falling back to BRouter public data:
- Rust get_ride_graph(min_speed): nodes snapped to ~20m grid (walk=1
  points excluded), edges between consecutive same-session points with
  weight = node-to-node distance (consecutive-point distance loses
  intra-cell travel and makes Dijkstra underestimate).
- JS loadRideGraph cache, grid-indexed nearest-node, Dijkstra, rideRoute
  (snap <=60m, drop if >2.5x straight line).
- routeDirty: rideRoute first, BRouter(+nogos) in the gaps. Ridden leg
  has no elevation -> thin line; BRouter -> casing (visually distinct).
- formatDistance now rounds metres (was 966.145...m).
2026-09-14 12:35:39 +03:00
c564c419db Walk detection in rides: colour it, keep it out of the ridden roads
Stage B of the ridden-roads work. GPS.detectWalk() flags contiguous low-speed
runs (< walkDetectKmh, or the single-speed 30-rpm push speed, for >= walkMinRunSec)
as walking inside a bike/other ride. Persisted per point (gps_points.walk, via
DB.setWalkFlags in recalculateSession + recomputeAllStats). rebuild_roads maps a
walk point to atype 'walk' so it colours as walking and is not treated as a
rideable road. Track detail colours walk runs and shows 'пешком: N мин / X км'.
Verified detectWalk on real tracks: 98% on a mostly-pushed 4km ride vs 1% on a
normal 16km ride.
2026-09-14 12:14:04 +03:00
abdcb94f55 Bump version to 0.1.453 (build.sh output) 2026-09-14 11:53:11 +03:00
5fe30399ab Share image Stage 2: native save/share + settings background picker
Rust export_image(filename,base64,action) + read_file_base64(path) (registered).
Kotlin exportimg: branch — base64-decode, then SAF-save the PNG (CreateDocument
image/png) or share it via FileProvider + ACTION_SEND chooser. Settings gets a
'Фон для картинки' row: pick a photo (pickLocalFile image/*), read it as base64,
downscale to <=1080 and store as a data URL in localStorage for the 'С фото'
variant. Rust compiles in tauri dev; native paths need an APK rebuild to test.
2026-09-14 10:38:29 +03:00
69f6fdac18 Share image (Похвастаться): render + preview modal + detail button
Stage 1 (pure JS). buildShareImage() draws a 9:16 story PNG: track shape (colored
glowing line), big Distance/Time/Speed + small elevation/kcal/date, on a
transparent or photo background. showShareModal() previews it with a
transparent/photo toggle and Share/Save buttons; a 'Похвастаться' button in the
track detail opens it. Native export (SAF save + share intent) and the settings
background picker come in Stage 2 (needs an APK rebuild); on desktop the export
buttons toast an error.
2026-09-14 10:25:34 +03:00
7bb764d2ca Bump version to 0.1.452 (build.sh output) 2026-09-12 23:15:52 +03:00
e40f6e25b7 Reroute: rejoin the active route past a blockage from the current position
Stage 2. Recording-map button ↪ builds a detour from the current GPS position
back ONTO the active route past the blockage: reference = nearest nogo on the
route (else current pos), target = a route point ~1.5km further (steps further on
failure to the route end), avoiding nogos and 180° turns (bearing helper). Drawn
as a transient magenta navigation overlay with the rejoin point; long-press the
button clears it. Does not modify the saved route or interrupt recording.
2026-09-12 23:05:58 +03:00
1c326ab19c Nogo areas: routing avoids impassable spots (planner + engine + RouteServer)
Stage 1 of the nogo/reroute work. Mark impassable circles that BRouter routes
around, end to end:
- DB nogo_areas table + CRUD; drawNogoAreas() red circles (tap to remove).
- BRouter.route(points, profile, nogos): &nogos=lon,lat,r on the RouteServer and
  nogos in the embedded-engine request; Rust brouter_route passes it through;
  Kotlin builds OsmNodeNamed(radius,isNogo,nogoWeight=NaN) -> rc.nogopoints +
  prepareNogoPoints (needs an APK rebuild for offline use).
- Planner: ⛔ button toggles nogo-placement (tap map/line), each leg re-routes
  around nogos. Recording map: ⛔ marks the current spot impassable. Detail map
  shows the circles.
Verified the RouteServer detours around a nogo (2873m -> 3329m).
2026-09-12 22:55:51 +03:00
18 changed files with 1255 additions and 27 deletions

View file

@ -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)

View file

@ -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`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.

View 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 там уже есть).
- Сохранённые маршруты не меняем при объезде; запись трека не прерываем.

View 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.

View 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`) остаётся.

View 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`.
## Дальше
- Сборка/установка на тело — по явной просьбе. Реальная поездка: пометить непроезжее, «↪».

View 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` на десктопе не заполнен (не пересчитывал) - граф пока включает все точки; на реальном
устройстве после «Пересчитать всё» пешие исключатся.

View 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), выбор подложки →
вариант «С фото». По явной просьбе.

View file

@ -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;

View file

@ -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,

View file

@ -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++; }
}

View file

@ -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 () => {

View file

@ -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
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.451"
version = "0.1.457"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.451"
version = "0.1.457"
edition = "2021"
[lib]

View file

@ -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)
}
}
}
}
}

View file

@ -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");
}

View file

@ -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"