diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..488bb11 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,79 @@ +# IMPLEMENTATION_PLAN: встраивание BRouter в BalDav + +Основано на `docs/brouter-findings.md`. Цель — оффлайн-роутинг внутри BalDav (движок BRouter +встроен в Android-модуль, `btools.router.RoutingEngine` в процессе, MIT). Две прикладные фичи: +идея 1 (заполнение GPS-пропусков по дороге) и идея 2 (оффлайн-планирование маршрута по точкам). + +Ключевые решения: встраивание (вариант B); сегменты `.rd5` качаются по проводу внутри BalDav в +app data dir; профиль trekking (идея 2) и shortest (идея 1); лицензия MIT (положить `LICENSE-brouter`). + +Мост как у GPS-сервиса: JS → Rust `invoke` → Kotlin (движок) → результат обратно. Данные: +профили — assets в apk; сегменты — app data dir (скачиваются). Движок зовём напрямую (без RouteServer). + +--- + +## Stage 1 — Встраивание движка + спайк «проложи 2 точки» +**Goal:** движок компилируется в составе BalDav и возвращает маршрут по паре точек на телефоне. +**Changes:** +- Извлечь `brouter-1.6.3-all.jar` в `src-tauri/gen/android/app/libs/`, подключить в gradle + (`implementation files('libs/brouter-1.6.3-all.jar')`), включить desugaring при необходимости. +- Профили в assets: `assets/brouter/profiles2/{lookups.dat,trekking.brf,shortest.brf}`. +- Kotlin `BRouterService`/функция: копирует профили из assets в files, зовёт + `btools.router.RoutingEngine(segmentsDir, profilesDir, profile, waypoints, outGpx)`, читает результат. +- Rust-команда `brouter_route(lonlats, profile) -> String(geojson/gpx)` в plugin bridge. +- JS-обёртка (новый `frontend/js/router.js` или в gps.js): `BRouter.route(points, profile)`. +- `LICENSE-brouter` (MIT-нотис) в корень. +**Success:** на телефоне с вручную положенным тайлом региона (adb push для спайка) вызов +`BRouter.route([[lon,lat],[lon,lat]], "shortest")` возвращает дорожный GPX/GeoJSON, а не ошибку. +**Tests:** ручной вызов из DevTools (CDP) + лог движка; сверить, что трек идёт по дорогам. +**Status:** not started + +## Stage 2 — Экран загрузки сегментов по проводу +**Goal:** пользователь скачивает тайлы `.rd5` региона внутри BalDav в app data dir. +**Changes:** +- Экран/настройка «Сегменты дорог»: список нужных тайлов по региону (карта/выбор), кнопка скачать + с `brouter.de/brouter/segments4/.rd5`, прогресс, размер, дата. Хранить в app data dir + (путь, который читает Stage 1). +- Управление: список скачанного, удалить/обновить. +**Success:** дома по проводу тайлы региона (E35_N45/E40_N45 — уточнить) скачиваются и лежат так, +что Stage 1 их читает; в поле сеть не нужна. +**Tests:** скачать тайл, проложить маршрут офлайн (сеть выключить) — работает. +**Status:** not started + +## Stage 3 — Идея 2: оффлайн-планирование по опорным точкам +**Goal:** тап опорных точек → дорожный маршрут trekking → существующий предпросмотр/профиль. +**Changes:** +- В `showRouteEditor` (frontend/js/main.js): между опорными точками звать `BRouter.route(..., + "trekking")` вместо прямых отрезков; результат (GPX/GeoJSON) парсить существующим `parseRouteGPX`, + дальше работает `showRouteDetail`/`routeElevStats`/`drawRoutePolyline`. +- Фолбэк на прямую линию, если движок/сегмент недоступны. +**Success:** тап точек даёт маршрут по дорогам с профилем высоты; сохраняется как маршрут. +**Tests:** спланировать знакомый маршрут офлайн, сверить с онлайн-BRouter (та же геометрия). +**Status:** not started + +## Stage 4 — Идея 1: заполнение GPS-пропусков по дороге +**Goal:** GPS-разрыв заполняется дорожным путём (shortest), с фолбэком на хорду. +**Changes:** +- В ветке `isGap` `computeStats`/при обработке трека: проложить `BRouter.route(предгэп, послегэп, + "shortest")`; если длина по графу > ~3× хорды (константа, подобрать на практике) — оставить хорду. +- Точки-заполнители помечать `estimated`; решить зачёт дорожной дистанции (по умолчанию — считать + дорожную длину, но с лимитом гэпа). +**Success:** записанный трек с дропаутом GPS показывает дорожный фолловинг вместо хорды, кроме +случая «дорога в разы длиннее». +**Tests:** трек с известным дропаутом на дороге → заполнение по дороге; дропаут на поле → хорда. +**Status:** not started + +## Stage 5 — Полировка (опционально) +**Goal:** удобство и корректность. +**Changes:** настройка «избегать подъёмов» (крутилка `uphillcost` в trekking); обновление сегментов; +доки (README/PROJECT_MAP), лог сессии; проверка размера apk. +**Status:** not started + +--- + +## Потенциальные проблемы +- Desugaring/`java.lang.invoke` при dex — проверить сборку (AGP обычно тянет). +- Размер/имена тайлов региона на brouter.de — уточнить при Stage 2. +- Время роутинга на телефоне на больших `.rd5` — замерить (может понадобиться таймаут/поток). +- Формат вывода: GeoJSON проще парсить, чем GPX; выбрать при Stage 1. +- Заполнение гэпов не должно ломать существующие фильтры высоты/скорости (estimated-флаг). diff --git a/LICENSE-brouter b/LICENSE-brouter new file mode 100644 index 0000000..e8a0d0e --- /dev/null +++ b/LICENSE-brouter @@ -0,0 +1,30 @@ +BalDav embeds the BRouter routing engine (brouter-1.6.3-all.jar, +src-tauri/gen/android/app/libs/) and its routing profiles +(src-tauri/gen/android/app/src/main/assets/brouter/profiles2/). + +BRouter is a third-party component distributed under the MIT License. +Source: https://github.com/abrensch/brouter + +--- + +MIT License + +Copyright (c) 2019 BRouter contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index e198b9e..752765a 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -84,6 +84,13 @@ | `ion_products.json` | 1111 продуктов ION (сырые данные скрейпера) | | `5ka_products_fresh.json` | 3103 продукта 5ka (сырые данные) | +### Разборы (docs/) + +| Файл | Назначение | +|------|------------| +| `docs/gps-model-findings.md` | Находки по расчётной модели GPS (Crr/ккал/высота/ветер) — часть внедрена, см. лог 2026-09-07 | +| `docs/brouter-findings.md` | Разбор BRouter 1.6.3 (оффлайн-роутинг): данные `.rd5`, профили, малый набор высоты, 3 способа вызова + встраивание; под идеи «заполнение GPS-пропусков» и «оффлайн-планирование по точкам». Рекомендация — встраивание движка (jar 356 КБ) в Android-модуль | + ## База данных ### Таблицы (Rust v1 миграция + JS миграции) @@ -103,7 +110,7 @@ | `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m | | `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы | | `supplement_log` | Приём добавок | date, supplement_id, dose_g | -| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер) | +| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо») | | `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw | | `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json (`{lat,lon,ele?}`), created_at | | `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active | @@ -177,17 +184,29 @@ ``` Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2) m = rider_kg + bike_kg; gradient из СГЛАЖЕННОЙ высоты - Crr = 0.004 (асфальт) / 0.006 (смешанный) / 0.008 (грунт) + Crr (ручной surface_type): 0.004 асфальт / 0.006 смешанный / 0.008 грунт + Crr по акселерометру (crrFromAccel, если есть accel_rms >10 точек): <3=0.004, 3-7=0.006, + 7-11=0.008, 11-15=0.012 (плотный грунт), >15=0.020 (трава/колея/щебень). Прежний потолок 0.008 + занижал грунты ~3x (замер: трава требует 0.020-0.031). Мапится и медиана сессии, и каждая точка (ptCrr). CdA = 0.6, rho = 1.225, efficiency = 0.25 Педалирование (power>0): kcal = max(физика, MET-нетто), физика = power*dt/(0.25*4184) MET по скорости: <16км/ч=6.0, <19=6.8, <22=8.0, <25=10.0, <28=12.0, иначе 14.0 MET-нетто = (dt/60)*((MET-1)*3.5*weight)/200 (−1 MET покоя, он уже в BMR) Floor только при v≥5км/ч (ниже — дрожание GPS, не езда) + ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ (по решению не правим, нужна калибровка): max() не СКЛАДЫВАЕТ — + на подъёмах физика выигрывает, а накладные (баланс/стабилизация корпуса, без внешней работы, + их ловит MET-этаж) теряются. Направление ошибки известно: подъёмы недосчитаны (по поездке ~1.34x + MET/физика, но равнина 1.85x, подъём 0.41x). Честный фикс = физика/КПД + накладные, отложен. Накат / freewheel (power≤0, «велик катился бы сам»): kcal = базальный метаболизм (детектор кручу/накат по IMU-каденсу проверен на 50Hz — сигнал слишком слаб, не используется; хук cadence_rpm — под BLE-датчик шатуна) Набор высоты: кумулятивный гистерезис (elevRef, порог 1м) — на плотных треках per-point разница <1м даёт 0 + ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ (по решению не правим): абсолютный набор по сырому GPS-альтиметру расходится + с независимым пересчётом в ОБЕ стороны (медиана разброса высоты между проездами одной точки ~10м). + Без модели рельефа (DEM) не разрешается. Реальный фикс — предзагрузка DEM по району дома по проводу + (кнопка «уточнить высоты» → GPXZ работает, но требует сети, а РЭБ/РКН глушат в поле). Пока: набор + имеет точность порядка десятков процентов, на расчёт мощности влияет слабо (там уклон по окну, ограничен сверху). ``` ### Дистанция по сегментам (computeStats, gps.js ~189-235) @@ -198,6 +217,23 @@ segMaxMs = maxSpeedKmh/3.6 (вело) или walkMaxSpeedKmh/3.6 (пеш); gapTh норма (иначе) → segDist = хорда (haversine) distance += segDist; высота/maxSpeed — только на «норме»; ккал в разрыве/estimated = walk-MET по времени Зависит только от ts+speed (не от флага estimated) → live == «Стоп» == пересчёт. gapThreshold настраивается, применяется при пересчёте. + +Ручная пауза (opts.pauses = [{startTs,endTs}] мс): сегмент, пересекающий паузу (prevMsstartTs), + исключается ЦЕЛИКОМ — ни дистанции, ни высоты, ни ккал, ни времени (pausedSec вычитается из duration), elevRef=null + (перепад, набранный пешком за паузу, не банкуется на первом сегменте после резюма). Авто-GPS-гэп (без паузы) — считается как раньше. +Глюк-гард высоты: соседний сглаженный перепад с грейдом > maxRealGradePct (1.0 = 100% = 45°) не банкуется и не двигает elevRef + (скачок/ступенька, в т.ч. Android no-fix 0.0). Плавный приход к любому значению (реальный 0 у реки) проходит. + Kotlin пишет "alt":null без фикса высоты (hasAltitude()), честный 0 с фиксом сохраняется. Паузы: колонка gps_sessions.pauses (JSON). +Пеший пуш велосипеда (только односкоростной, gear_count 1 или пусто + есть звезда/трещотка/колесо): + на фиксированной передаче каденс <30 об/мин = велик не едет, его ведут. Порог скорости walkBikeMs = 30*wheelCirc*gearRatio/60. + Сегмент медленнее порога (нормальный фикс, не гэп) исключается из ездовой дистанции/скорости/высоты (elevRef=null — пеший + подъём не склеивается с ездовым), усилие считается как ходьба (walk-MET нетто), не по физике. #7: исключается КАЖДАЯ такая точка + без оглядки на длительность (краткие торможения тоже, безвредно ~0м); в отчёт как «пешком» идут только сплошные пробеги ≥30с. + Возврат computeStats: walkBikeSec (время пуша ≥30с-пробегами), walkBikeMeters (пешая дистанция). opts.gearCount прокинут во все вызовы. +Метки ветра (разметка, НЕ расчёт): кнопка «Ветер в лицо» на экране записи — тап открывает интервал встречного ветра, + тап закрывает. GPS.toggleWindMark()/_closeOpenWindMark(); GPS.recording.windMarks=[{startTs,endTs}] мс, персист в localStorage, + запись в БД (DB.setSessionWindMarks) на финише + восстановление в recovery. Колонка gps_sessions.wind_marks (JSON). Это ground + truth для проверки оценки ветра по треку (tools/track/track.py, wind_by_passes) — на distance/kcal/power НЕ влияет. Голос — потом. ``` ### Ходьба/бег — MET-таблица diff --git a/README.md b/README.md index e5e43a2..24db5ac 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). - **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. -- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м). Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. +- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. - **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. - **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. @@ -19,6 +19,7 @@ - **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон дискретными полосами по 1% (зелёный→жёлтый→красный→коричневый→чёрный, ≥10% чёрный) или гипсометрия по высоте (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека и во время записи; маршрут рисуется ПОД треком. Высоты (`` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM). - **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, тап по линии — вставить точку в это место, тащить точку — двигать, тап по точке — удалить. Высоты подтягиваются онлайн после отрисовки (из предпросмотра). - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. +- **Метки ветра** — кнопка «Ветер в лицо» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее. - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. diff --git a/docs/brouter-findings.md b/docs/brouter-findings.md new file mode 100644 index 0000000..ad86cf7 --- /dev/null +++ b/docs/brouter-findings.md @@ -0,0 +1,191 @@ +# Разбор BRouter 1.6.3 под две идеи BalDav (2026-09-07) + +Получено разбором `brouter-1.6.3.zip` (read-only: листинг архива, чтение текстовых +доков и профилей через stdout, инспекция `brouter-1.6.3-all.jar` в памяти — распаковки +на диск и запуска Java не было). Цель — понять, как BRouter «знает, где дорога» и строит +маршруты с малым набором высоты, и оценить под это две идеи пользователя: + +1. **Заполнение GPS-пропусков по дороге** — когда GPS пропадает и появляется, пробел + заполнять кратчайшим путём по реальным дорогам, а не хордой/скорость×время. +2. **Оффлайн-планирование маршрута по опорным точкам** в BalDav — замена онлайн-BRouter. + +Обе идеи требуют оффлайн-роутинга; сеть в поле недоступна (РЭБ/РКН — как в обсуждениях +ветра и DEM), поэтому годятся только оффлайн-схемы. + +--- + +## 1. Что внутри дистрибутива + +| Часть | Что это | +|---|---| +| `BRouterApp.1.6.3-api19-release.apk`, `...api30-release.apk` | Android-приложение `btools.routingapp` (одно и то же, под разные min-SDK). Считает маршрут, карту НЕ рисует. | +| `brouter-1.6.3-all.jar` (356 КБ) | Standalone-движок на чистой Java. Main-Class `btools.server.BRouter` (HTTP-сервер). Тот же движок, что в apk. | +| `profiles2/*.brf` | Профили маршрутизации (текстовые скрипты стоимости). `lookups.dat` — таблица тегов/значений. | +| `readmes/` | Документация: установка, `profile_developers_guide.txt`, `mapcreation.md`, интеграция с OsmAnd, особенности Android 11. | + +BRouter — **маршрутизатор без карты**: на вход опорные точки + профиль, на выход трек +(GPX/GeoJSON). Отрисовка и навигация — на стороне карт-тула (OsmAnd/Locus/…), либо, в нашем +случае, на стороне BalDav. + +## 2. Где дорога: данные `.rd5` + +- Граф дорог — **сегментные файлы `.rd5`**, тайлы **5°×5°** по широте/долготе. Внутри — + OSM-ways и nodes, закодированные в бинарный битстрим по таблице `lookups.dat` (для каждого + тега — набор значений с частотами и алиасами). +- Готовые тайлы качаются с **brouter.de/brouter/segments4** (пересобираются еженедельно) или + строятся самому из OSM-dump + SRTM (`mapcreation.md`, скрипт `process_pbf_planet.sh`, + требуется Osmosis/pbfparser). +- **Высоты** в сегментах берутся из **SRTM-90м** (CGIAR). Именно они дают BRouter'у профиль + рельефа, по которому он считает набор высоты (см. §4). Без SRTM сегменты соберутся, но без + высот — маршруты «с малым набором» не получатся. +- Для нашего региона (Ростовская обл., ~47N/40E) нужны тайлы, чьи SW-углы кратны 5: + вероятно **`E35_N45.rd5`** (35–40E, 45–50N) и **`E40_N45.rd5`** (40–45E) — Новочеркасск ~40.1E + попадает во второй. Точные имена и размер (обычно десятки МБ на тайл) — **проверить при + загрузке**. Загрузка одноразовая по проводу — тот же паттерн «скачать дома», что для DEM/прогноза. + +## 3. Как строится маршрут (движок) + +- Класс `btools.router.RoutingEngine`. Ключевые шаги (по строкам байткода): + `matchWaypointsToNodes` — **привязка каждой опорной точки к ближайшему дорожному узлу графа** + (это и есть ответ «где дорога, а где нет»: точка вне дорог подтягивается к дороге), затем поиск + пути по графу между привязанными узлами. +- Стоимость ребра считает **интерпретатор профиля** (`btools.expressions`) по тегам ребра из + `.rd5`. Поиск минимизирует суммарную стоимость (по сути A*/Dijkstra по сегментному графу с + кэшами профиля). +- На выход движок отдаёт трек и метрики: `track-length`, `filtered ascend` и `plain-ascend` + (набор высоты — сглаженный и сырой), `cost`, `energy`, `time` (по кинематике профиля). + Формат — GPX (`formatAsGpx`/`writeGpx`) или GeoJSON/KML. + +## 4. Малый набор высоты — как именно (основной режим) + +Это чисто вопрос **профиля**, а не движка. В `trekking.brf` (context:global): + +``` +assign consider_elevation = true +assign downhillcost = 60 # штраф за спуск +assign downhillcutoff = 1.5 # уклоны меньше 1.5% не штрафуются +assign uphillcost = 0 # (в trekking по умолчанию 0; крутить вверх — здесь) +assign uphillcutoff = 1.5 +``` + +Плюс **кинематическая модель времени/энергии** — прямой аналог модели мощности BalDav: + +``` +assign totalMass = 90 # масса байк+седок, кг +assign maxSpeed = 45 # км/ч +assign S_C_x = 0.225 # CdA (площадь×коэф. лобового сопротивления) +assign C_r = 0.01 # коэффициент качения +assign bikerPower = 100 # средняя мощность, Вт +``` + +Чтобы получить маршрут **с малым набором высоты**, поднимают `uphillcost` (и/или `downhillcost`): +движок начинает предпочитать обходные, но более пологие дороги. `uphillcutoff`/`downhillcutoff` +задают порог уклона, ниже которого рельеф игнорируется. То есть «основной режим» пользователя в +онлайн-BRouter — это trekking с ненулевым `uphillcost`. + +Отдельно есть **`shortest.brf`**: `downhillcost=0`, `uphillcost=0`, `turncost=0` — чистейший +кратчайший путь по дорогам, без учёта рельефа. Это ровно то, что нужно для **идеи 1** (заполнить +GPS-пропуск — важно «просто чтобы была дорога», кратчайшая). + +## 5. Как вызвать оффлайн — три поверхности + +**(а) Standalone-jar на компе (HTTP-сервер).** Подтверждённая строка usage: + +``` +java -cp brouter-1.6.3-all.jar btools.server.RouteServer [bindaddress] +``` + +Запрос (тот же API, что у brouter.de/brouter-web): + +``` +GET http://127.0.0.1:/brouter?lonlats=39.7,47.2|40.1,47.4&profile=shortest&format=geojson&timode=0&alternativeidx=0 +``` + +Параметры (из байткода `RouteServer`): `lonlats` (пары lon,lat через `|`), `profile`, `format` +(gpx/geojson/kml), `timode` (turnInstructionMode), `alternativeidx`, `nogos`. Нужен JRE + каталог +сегментов `.rd5` + каталог профилей. Годится для инструментов `tools/track/` (проложить маршрут по +`lonlats`, что уже выдаёт `tools/steep_segments.js`). + +**(б) Приложение BRouter на телефоне — Server-Mode.** Приложение поднимает локальный HTTP-сервер +(порт 17777 по умолчанию); любой клиент на устройстве, включая WebView BalDav, шлёт тот же +`GET /brouter?lonlats=...`. Требует установленного приложения BRouter и скачанных сегментов. + +**(в) Приложение BRouter — Android-intent.** С v1.6.2 у приложения есть «интерфейс без файлового +доступа»: BalDav (Kotlin) шлёт intent к `btools.routingapp` с опорными точками и профилем, получает +GPX/GeoJSON обратно. Данные приложения с Android 11 лежат в +`Android/media/btools.routingapp/brouter/segments4`. + +**(г) Встраивание движка в BalDav.** `brouter-1.6.3-all.jar` — чистая Java (356 КБ), пакеты +`router`/`mapaccess`/`expressions`/`codec`/`util`. Классы движка можно добавить в Android-модуль +BalDav (`src-tauri/gen/android`) и звать из Kotlin, а из JS — через существующий Rust↔Kotlin-мост +(как GPS-сервис). Полностью самодостаточно и оффлайн, второе приложение не нужно. + +## 6. Идея 1 — заполнение GPS-пропусков по дороге + +- **Как:** при разрыве взять последнюю точку до пропуска и первую после, проложить между ними + маршрут профилем `shortest.brf`, вставить дорожную полилинию как заполнение гэпа. +- **Стык с `computeStats` (frontend/js/gps.js, ветка `isGap`):** сейчас гэп заполняется хордой или + `скорость×время`. Дорожный путь точнее (по реальным дорогам). Точки-заполнители помечать + `estimated` (как dead-reckoning), чтобы они не влияли на глюк-фильтры высоты и были видимо + «достроенными». **Решить:** идёт ли дорожная дистанция в зачёт трека или только рисуется (сейчас + гэп считается по скорости; дорожная длина может быть и точнее, и длиннее реального пути). +- **Риски/границы:** без сегментов на регион заполнить нечем; дорога-кратчайшая может отличаться от + фактически проеханной; на бездорожье (поля, где пользователь реально ездит) кратчайший путь по + графу дорог может увести. Нужен разумный лимит длины гэпа, при котором достраиваем. + +## 7. Идея 2 — оффлайн-планирование маршрута по точкам + +- **Что уже есть в BalDav** (frontend/js/main.js): `showRouteEditor` (тап по карте = точка, + тап по линии = вставка, тащить/удалять), `showRouteDetail` (предпросмотр + профиль высоты + `routeElevStats` + фуллскрин), `drawRoutePolyline`, «Сохранённые маршруты», топо/уклон-обводка. + Парсер `parseRouteGPX` уже читает GPX/``. +- **Чего не хватает:** оффлайн-роутера между опорными точками. Сейчас линия между тапами — прямая + (ручное рисование). BRouter вернёт дорожный GPX/GeoJSON, который **уже парсится** `parseRouteGPX`, + и дальше работает существующий предпросмотр/профиль высоты. +- То есть UI-обвязка в основном готова; предметная работа — вызов BRouter и подмена прямых отрезков + дорожным маршрутом (+ выбор профиля: trekking с `uphillcost` для «поменьше в горку»). + +## 8. Варианты интеграции и рекомендация + +| Вариант | Плюсы | Минусы | +|---|---|---| +| **A. Зависимость от приложения BRouter** (intent или localhost HTTP) | Быстро; роутинга в BalDav нет; BRouter сам качает/обновляет сегменты | Второе приложение ставить и настраивать; управление сегментами вне BalDav; связанность | +| **B. Встраивание движка** (jar в Android-модуль + Rust/JS-команда) | Самодостаточно, полностью оффлайн, один apk; сегменты кладём куда хотим | Бандлинг Java-движка; доставка `.rd5` в приложение; **проверить лицензию** (GitHub abrensch/brouter); управление хранилищем сегментов | +| **C. Порт на Rust/JS** | Нет внешних зависимостей | Огромный объём (движок + формат `.rd5` + интерпретатор профилей) — **отклонить** | + +**Решение — B (встраивание)** (подтверждено пользователем). Встраиваемость проверена по байткоду: +движок (150 классов) использует только `java.io/net/util/lang/text/security/zip` — ни AWT/Swing/ +RMI/ImageIO/servlet/sun.*, всё есть на Android ART. `RouteServer` — на голых `ServerSocket`/`Socket` +(контейнер не нужен). Значит в процессе можно звать напрямую `btools.router.RoutingEngine` +(без HTTP-сервера): дать каталог сегментов + профиль + опорные точки, получить GPX/GeoJSON. +Для инструментов `tools/track/` (анализ, не телефон) сразу годится **вариант (а)** — jar на компе. + +## 9. Решения и открытые вопросы + +Решено (2026-09-07): +1. **Лицензия — MIT** («Copyright (c) 2019 BRouter contributors», github.com/abrensch/brouter). + Встраивание разрешено без ограничений; при распространении сохранить копирайт-нотис MIT + (положить `LICENSE-brouter` в репозиторий рядом с движком). +2. **Интеграция — вариант B (встраивание движка)**. Код BalDav открыт, MIT это позволяет. +3. **Профиль — trekking** (для обеих идей). Идея 1 всё же считается `shortest` для чистого + кратчайшего пути (см. §6); trekking — для планирования (идея 2). +4. **Идея 1, порог хорды:** прокладывать по графу, НО если дорожная длина в разы больше хорды — + рисовать хорду (граф увёл не туда, напр. вокруг поля). Коэффициент подобрать на практике + (старт ~3×, вынести в конфиг/константу). +5. **«Крутилка» `uphillcost`** (см. §4) — пользователь про неё не знал. План: начать со + стандартного trekking; если маршруты слишком в горку — добавить настройку «избегать подъёмов» + (поднимать `uphillcost`). Пока не крутим. + +6. **Доставка сегментов — загрузка по проводу внутри BalDav** (решено). Экран в BalDav: дома на + Wi-Fi/проводе скачать готовые тайлы региона с brouter.de/segments4 в папку приложения + (app data dir, аналог seed-БД); в поле сеть не нужна. Тот же паттерн, что задумывался для DEM/ + прогноза. Свою сборку сегментов из OSM+SRTM не делаем. +7. **Встраиваемость подтверждена** (см. §8): движок без десктоп-зависимостей, зовём + `btools.router.RoutingEngine` в процессе из Kotlin. + +## 10. Проверка (PoC, отдельным шагом, по согласию) + +Требует JRE и загрузки сегментов региона (сеть) — вне текущей задачи. Когда разрешишь: +запустить `RouteServer` на компе с каталогом `.rd5` и `profiles2/`, дёрнуть +`GET /brouter?lonlats=...&profile=shortest&format=geojson` по паре точек из +`tools/steep_segments.js`, убедиться, что возвращается дорожный маршрут с `track-length`/`ascend`. diff --git a/docs/gps-model-findings.md b/docs/gps-model-findings.md new file mode 100644 index 0000000..bf9f629 --- /dev/null +++ b/docs/gps-model-findings.md @@ -0,0 +1,353 @@ +# Находки по расчётной модели GPS (2026-09-07) + +Получено при независимом разборе базы `baldav.db` (156 поездок, 366 тыс. точек) +сторонним инструментом `tools/track/`. Константы там взяты из `GPS.computeStats`, +поэтому расхождения означают разницу в модели, а не в настройках. + +Проверка сходимости: дистанция расходится с базой на **0.2%**, калории по формуле +приложения -- на **4%** (трек 4 сентября). То есть база и формула воспроизводятся, +и нижеперечисленное -- не ошибки чтения. + +Пункты про **нулевую отметку высоты** и **паузу на подъёме** сюда не включены: +они уже обсуждены отдельно. + +Файл состоит из двух частей. **Первая -- находки:** измеренные расхождения, +каждое с замером и по возможности с контрольной проверкой. **Вторая -- +предположения:** варианты отслеживания ветра, составленные по просьбе +пользователя, с вопросами к вам в конце. Смешивать их не стоит: первая часть +проверена на данных, вторая -- это перечень возможностей, а не рекомендация. + +--- + +## 1. Потолок качения 0.008 занижен втрое + +`crr_from_accel`: при `accel_rms > 7` возвращается 0.008 и выше не поднимается. +0.008 -- это разбитый асфальт. Реальные грунты дают втрое больше. + +**Замер.** Трек 6 сентября (`id 1788683393199`), километры 36.5-38.5: 1996 м, +ровно (уклон −0.1%), `accel_rms` медиана **15.1**, скорость 13.8 км/ч. +Пользователь описал место как траву и тракторную колею с ямами. +Обратное решение уравнения мощности относительно Crr: + +| если мощность была | требуемый Crr | +|---|---| +| 90 Вт | 0.0198 | +| 110 Вт | 0.0252 | +| 130 Вт | 0.0307 | + +Справочные значения: плотный грунт 0.012, щебень 0.020, трава и песок 0.035. +Замер попадает ровно в диапазон, соответствующий описанию. + +**Контрольная проверка обратным знаком.** Те же вычисления на километрах 9-11 +(25 км/ч, тряска 5.2) дают Crr отрицательным при 90-110 Вт -- значит там +мощность была около 150 Вт. Сплиты на тех километрах показывают 148-172 Вт. +Метод не завышает и не занижает систематически. + +**Предлагаемая шкала** (взята нижняя граница замера -- лучше недосчитать): + + < 3 -> 0.004 гладкий асфальт (как сейчас) + 3-7 -> 0.006 смешанное (как сейчас) + 7-11 -> 0.008 разбитый асфальт (как сейчас) + 11-15 -> 0.012 плотный грунт (новое) + > 15 -> 0.020 трава, колея, щебень (новое) + +**Влияние на трек 6 сентября:** средняя мощность 89 -> 108 Вт, +механическая работа 1233 -> 1585 кДж. То есть на грунтах модель +недосчитывала примерно четверть работы. + +## 2. `max(physics, MET)` никогда не складывает, и подъёмы от этого страдают + +На одних и тех же отрезках трека 6 сентября: + +| участки | физика | MET | MET/физика | +|---|---|---|---| +| вся поездка | 1318 | 1764 | 1.34 | +| равнина, \|уклон\| < 1% | 727 | 1344 | **1.85** | +| в горку круче 2% | 400 | 165 | **0.41** | + +Комментарий в коде говорит про «~2x занижение физики» -- на равнине это верно +(1.85), но по поездке в целом всего 1.34, потому что на подъёмах физика +выигрывает с большим запасом. + +Изъян в том, что оценки не складываются. Биологические накладные расходы +(размахивание ногами, равновесие, гашение тряски корпусом) никуда не деваются +на подъёме, но там берётся физика и табличная оценка отбрасывается целиком. +**Направление ошибки известно: подъёмы недосчитываются.** + +Осмысленная альтернатива, если решите менять: не максимум, а +`физика/КПД + накладные`, где накладные оцениваются табличным минимумом +для медленной езды. Требует калибровки, поэтому просто как направление. + +## 3. Ходьба с велосипедом засчитывается как езда + +На односкоростном передача даёт точный признак: 40/18 на колесе 24" -- +это 4.26 м за оборот шатунов. Скорость ниже **7.7 км/ч** означает каденс +ниже 30 об/мин, а на таком каденсе велосипед не едет, он падает. Значит +человек идёт и ведёт или несёт его. + +На треке 6 сентября таких кусков **0.66 км за 8:44 в движении** -- пользователь +подтвердил, что нёс велосипед руками в горку. Участок: км 56.31, длина 289 м, +набор 47.8 м, **уклон 16.6%**, 4.3 км/ч. + +Побочный эффект для статистики: если этот кусок не отделять, он склеивается +с последующим ездовым подъёмом (км 56.60, 624 м, 3.8%, 12.3 км/ч) в один +«подъём 915 м, 7.8%, 7.7 км/ч», которого в природе не было. + +Признак применим только когда `bikes.gear_count` пусто или 1. На многоскоростном +низкая скорость ничего не доказывает. + +**По акселерометру это не различается, проверено:** нести велосипед в гору 3.24, +ехать по гладкому асфальту 2.88, идти пешком (сессия walk) 3.98. `accel_rms` +показывает дорогу, а не походку. + +## 4. Набор высоты расходится с независимым пересчётом в обе стороны + +| трек | в базе | независимый пересчёт | +|---|---|---| +| 6 сентября | 328 м | 528 м | +| 4 сентября | 314 м | 264 м | + +Знак разный, значит это не простое смещение. Без модели рельефа проверить +нечем: попытка построить карту высот по своим же 156 поездкам провалилась -- +перекрытие маршрутов всего 26% ячеек, а разброс высоты между поездками +в одной и той же точке имеет медиану **10 метров**. + +**Поправка после уточнения у пользователя.** Изначально я счёл пустую +`altitude_srtm_m` (заполнена в одной сессии из 156) недоработкой. Это не так: +поле заполняется вручную кнопкой «уточнить высоты», которая ходит в GPXZ +и подобные сервисы, и пользователь жмёт её редко. Полагаться на сервисы +в поле нельзя -- РЭБ и РКН глушат, устойчиво работает преимущественно +проводной интернет. + +Отсюда следует не «починить SRTM», а вопрос по существу: **расхождение +в наборе высоты остаётся неразрешённым, и разрешить его без внешнего рельефа +нечем.** Если это важно -- имеет смысл думать в сторону предварительной +загрузки рельефа по району дома, по проводу, один раз. Если не важно -- +достаточно знать, что цифра набора имеет точность порядка десятков процентов, +и не строить на ней выводов. На расчёт мощности это влияет слабо: там работает +уклон, а он считается по окну и ограничен сверху. + +## 5. Пульса в точках нет, и это ожидаемо + +`gps_points.heart_rate` равен нулю во всех 156 сессиях. **Это не дефект +записи:** пользователь потерял браслет и пульсометр не носит, писать в поле +попросту нечего. `ble_hr_data.json` и запись устройства в `profile` +остались от прежнего использования. + +Упомянуто только затем, чтобы никто не искал ошибку там, где её нет, +и чтобы разбор треков не обещал анализ по пульсу. + +## 6. Ветер вычисляется из `bearing_deg` + +Побочная находка, в приложении такого нет. Ветер виден по тому, что на ровных +однотипных дорогах скорость зависит от курса. Берутся только точки с ровным +уклоном, невысокой тряской и в седле, считается медианная скорость по восьми +секторам курса, и подбирается ветер, при котором подразумеваемая мощность +во всех секторах одинакова. + +Трек 6 сентября: **6.3 км/ч из направления 345°**. Разброс мощности +по направлениям падает с 33% до 11%. Пользователь независимо подтвердил, +что ветер часто дул в лицо. + +Медианная скорость по курсу на этом треке -- 23.6 км/ч на юго-восток +против 15.7 км/ч на северо-запад, при одинаковых дорогах. + +Второй, независимый метод -- по встречным проездам одного места (758 пар) +-- даёт 3.6 км/ч из 325°. Направление сходится в пределах 20°, сила -- +в пределах множителя 1.8. Это и есть достижимая точность оценки ветра +без датчика. + +Разбор вариантов отслеживания ветра, включая идею пользователя про голосовые +отметки, -- во второй части этого файла. Там же перечислены вопросы, +по которым пользователь просит ваше мнение. + +## 7. Ловушка при отделении ходьбы: порог по длительности не нужен + +Найдено после замечания пользователя, что встречные проезды одного места +случались и пешком. + +Первая реализация признака ходьбы требовала, чтобы медленный кусок длился +дольше 30 секунд подряд. Это оказалось неверно. На треке 6 сентября всего +**14.7 минут** движения медленнее 7.7 км/ч, из них сплошными кусками -- +только 8.7 минуты. Оставшиеся **6 минут** -- короткие эпизоды, и они попадали +в статистику как педалирование. + +Видно это по корзине каденса: «0-40 об/мин» занимала 21 минуту, и **6.4 минуты +из неё приходилось на каденс ниже 30**, которого при езде не бывает вовсе. +После исправления корзина стала 9 минут, а средний каденс поднялся с 64 до 67. + +**Правильное разделение двух вопросов:** + +- *Исключать из статистики* нужно **любую** точку ниже порога, без оглядки + на длительность: на этой передаче ниже 30 об/мин не едут, и точка. Сюда же + попадут обычные торможения до нуля, но они короткие и маломощные, вреда нет. +- *Называть кусок ходьбой в отчёте* стоит только при длительности дольше + 30 секунд подряд -- иначе отчёт будет объявлять ходьбой каждое торможение + перед ямой. + +На оценку ветра эта примесь, к счастью, не повлияла: там отдельный порог +9 км/ч, и пешие точки не проходили. Проверено прямым сравнением -- с ними +3.5 км/ч из 326°, без них 3.6 км/ч из 325°. + + +--- +--- + +# ЧАСТЬ ВТОРАЯ: предложения по отслеживанию ветра + +Ниже -- **не находки, а предположения**, составленные по просьбе пользователя. +Это перечень способов с замерами там, где замеры удалось сделать. +Решение принимает сессия разработки. + +## Зачем + +Ветер -- крупнейший неучтённый источник ошибки в расчёте мощности на равнине. +На треке 6 сентября медианная скорость на одинаковых дорогах составила +**23.6 км/ч на юго-восток против 15.7 км/ч на северо-запад**. Разброс +подразумеваемой мощности по направлениям -- **33%**. Пока ветра в модели нет, +эти 33% списываются на «просел» и «разогнался». + +## Ограничения, названные пользователем + +- **Сетевые сервисы в поле недоступны.** РЭБ и РКН глушат, устойчиво работает + преимущественно проводной интернет. Любая схема, которой нужна сеть во время + поездки, нерабочая. +- **Метеостанция меряет в другой точке.** Стандартное измерение -- на высоте + 10 м в открытом месте, усреднение за 10 минут. Велосипедист едет на высоте + 1.5 м среди лесополос, домов и насыпей, где ветер и слабее, и порывистее. + Среднее по области пользователю не поможет. + +Оба возражения справедливы и отсекают наивное «дёргать погодный API». + +## Вариант 1. Считать ветер из самого трека (без железа и без сети) + +Реализовано и проверено в `tools/track/track.py`. Два независимых метода. + +**1а. Подбор по секторам курса.** Берутся только точки на ровном участке, +с невысокой тряской, в седле. Медианная скорость считается по восьми секторам +направления. Подбирается такой ветер, при котором подразумеваемая мощность +во всех секторах одинакова. + +**1б. Встречные проезды одного места.** Ищутся пары точек в одной ячейке 30 м, +разнесённые по времени, с противоположными курсами, одинаковым покрытием +и зеркальным уклоном. На одной и той же дороге качение и уклон сокращаются, +и разница скоростей при равной мощности задаёт составляющую ветра вдоль дороги. +Составляющие по разным дорогам сводятся в вектор методом наименьших квадратов. + +**Результат на треке 6 сентября:** + +| метод | скорость | направление | +|---|---|---| +| по секторам курса | 6.3 км/ч | 345° | +| по встречным проездам (758 пар) | 3.6 км/ч | 325° | + +Направление сходится в пределах 20°, сила -- в пределах множителя 1.8. + +**Это и есть достижимая точность способа без железа: направление уверенно, +сила -- с точностью до двух раз.** Любой вариант с датчиком должен +эту планку превосходить, иначе он не нужен. + +Ограничения: работает только задним числом, требует разнообразия курсов +(кольцевой маршрут или «туда-обратно» -- идеально, прямая в одну сторону -- +бесполезно), и предполагает, что ветер за поездку не менялся. Последнее +лечится подгонкой в скользящем окне 20-30 минут, но точность падает. + +## Вариант 2. Прогноз, скачанный ЗАРАНЕЕ по проводу + +Обходит запрет на сеть в поле: перед выездом, на домашнем проводном интернете, +скачивается почасовой прогноз ветра по району и кладётся в базу. В поездке сеть +не нужна вообще. + +Прогноз при этом используется **не как истина, а как начальное приближение** +для варианта 1: он задаёт правдоподобный диапазон, а трек его уточняет. +Возражение про «меряют в другой точке» остаётся в силе, но начальное +приближение с известным смещением лучше, чем поиск вслепую. + +Спорный момент: прогноз может оказаться хуже, чем ничего, если приземный ветер +в лесополосах отличается от станционного в разы. Стоит проверить на нескольких +треках, прежде чем закладывать. + +## Вариант 3. Трубка Пито на руле (единственный способ мерить по-настоящему) + +Так устроены настоящие аэродинамические измерители для велосипеда (Notio, +Aeropod). Трубка, направленная вперёд, плюс датчик разности давлений дают +**скорость набегающего воздуха напрямую**, с частотой в десятки герц. + +Разность между воздушной скоростью и путевой скоростью с GPS, вместе с курсом, +даёт вектор ветра непрерывно, включая порывы. Сети не требуется вообще. + +- датчик разности давлений класса MPXV7002DP плюс микроконтроллер с BLE +- порядок цены комплектующих -- полторы-три тысячи рублей +- BLE в приложении уже есть, канал знакомый +- нужна калибровка (сравнением с вариантом 1 на кольцевом маршруте в штиль) + и защита от дождя и грязи +- трубка на руле уязвима при падении и при переноске велосипеда + +**Это единственный из перечисленных способов, который измеряет, а не оценивает.** +И единственный, который видит порывы, а не среднее. + +## Вариант 4. Флюгер на руле + +Дешевле Пито: измеряется только **угол** набегающего потока относительно +велосипеда, обычным поворотным датчиком. Скорости ветра не даёт, но сильно +сужает решение в варианте 1 -- направление перестаёт быть неизвестным, +остаётся подобрать одну величину вместо двух. + +На велосипеде хрупок и чувствителен к завихрениям от самого седока. + +## Вариант 5. Голосовой ввод по словам-триггерам (идея пользователя) + +Седок произносит «в лицо», «в спину», «боковой», «порыв», приложение ставит +отметку времени. Распознавание на Android умеет работать без сети. + +**Замечание, которое считаю важным.** Как измерение это слабо: оценка +субъективна, отметки редкие, а говорить на тяжёлом дыхании и в шуме ветра +неудобно -- причём шум ветра в микрофоне мешает именно тогда, когда ветер +и надо отметить. + +Но у этого есть куда более сильное применение: **не как измерение, +а как разметка для проверки автоматической оценки.** Десяток отметок +«в лицо» и «в спину» за поездку -- это готовый ответ, по которому можно +проверить и откалибровать вариант 1, не покупая железа. Если оценка по треку +совпадает с отметками -- ей можно верить и без отметок. + +При таком применении редкость и субъективность отметок почти не мешают: +проверяется знак и порядок величины, а не точное значение. + +## Вариант 6. Кнопка вместо голоса + +То же самое, но нажатием: выносная кнопка на руле по BLE либо кнопка +на гарнитуре. Надёжнее голоса на тяжёлом дыхании и в шуме, но выражает меньше +-- реально различимы два-три состояния, не больше. + +## Вариант 7. Шум ветра в микрофоне + +Обдув микрофона даёт характерный низкочастотный шум, растущий со скоростью +потока. Теоретически из него извлекается воздушная скорость. + +Практически: в кармане бесполезно, нужен вынесенный микрофон; шум дороги +и шин мешает; калибровка своя для каждого телефона и чехла. Дёшево проверить, +но рассчитывать не стоит. Упомянуто для полноты. + +--- + +## О чём хотелось бы услышать мнение сессии разработки + +1. **Стоит ли вообще ветер в модели**, или проще честно писать «мощность + на равнине оценена с точностью ±30%» и не усложнять. Разброс 33% измерен, + вопрос в том, окупается ли его устранение. +2. **Вариант 5 в роли разметки** (а не измерения) -- насколько это ложится + на текущий UI и на запись событий с отметкой времени. По цене внедрения + это самый дешёвый из осмысленных шагов, и он не требует решать заранее, + покупать ли датчик. +3. **Оправдан ли вариант 3.** Планка задана: без железа выходит направление + с точностью около 20° и сила с точностью до двух раз. Датчик за + несколько тысяч рублей должен дать заметно больше, иначе он не нужен. +4. **Не ломает ли вариант 2 принцип автономности.** Скачивание заранее + по проводу формально сеть в поле не требует, но привязывает точность + расчёта к тому, вспомнил ли пользователь нажать кнопку перед выездом. + Это тот же изъян, что и у кнопки «уточнить высоты». + +Готовый код обоих методов варианта 1 -- в `tools/track/track.py` +(функция `wind_by_passes` и секция «ветер» в `report`), выше описан +алгоритмически. diff --git a/docs/plans/2026-09-07-manual-pauses-altitude-glitches.md b/docs/plans/2026-09-07-manual-pauses-altitude-glitches.md new file mode 100644 index 0000000..b5e7356 --- /dev/null +++ b/docs/plans/2026-09-07-manual-pauses-altitude-glitches.md @@ -0,0 +1,56 @@ +# План: ручные паузы и глюки высоты в расчёте статистики трека + +## Context (зачем) +Две ошибки в `GPS.computeStats` (frontend/js/gps.js): +1. **Ручная пауза считается как езда.** Пользователь внизу подъёма ставит паузу руками, + идёт пешком, наверху снимает. `computeStats` через гэп всё равно считает дистанцию + по позиции, а `elevRef` переживает гэп → первый сегмент после резюма сравнивается с + высотой ДО паузы → перепад подъёма утекает в elevGain. Нужно: **ручную паузу + исключать** (ни дистанции, ни перепада, ни времени, ни ккал), а **автоматический + GPS-гэп в движении — считать как сейчас** (интерполяция). +2. **Глюки высоты, включая честный 0.** Android при отсутствии фикса высоты пишет + `location.altitude=0.0` → в данных 146 точек `altitude_gps_m=0`, которые как «реальная + высота» дают ложные перепады. НО плавный приход к 0 у реки — реальная высота + (пользователь часто у рек). Правило: отбрасывать **нереальный уклон** (скачок / + грейд >45° = >100%), а плавные значения (в т.ч. 0) — оставлять. + +Решения (по ответам): **только для НОВЫХ треков** (ручного «разрезания» старых не делаем). +Паузы отслеживаются в `GPS.recording.pauses` (`[{startTs,endTs,startLat,...}]`, мс epoch), +но в БД НЕ сохранялись — начали сохранять. Данные разбираются офлайн через `tools/track/`. + +## Изменения (финал, как реализовано) +1. **Persist пауз (БД).** Колонка `pauses TEXT` в `gps_sessions` тем же JS-путём ADD COLUMN + под `PRAGMA table_info(gps_sessions)`-гардом (db.js ~1540). `finishGpsSession(...pauses)` + получил аргумент `pauses`: `hasPauses = pauses !== undefined` → пишет `JSON.stringify(pauses||[])`, + `undefined` оставляет колонку как есть. `getGpsSession` парсит `pauses` из JSON. +2. **`computeStats(points, wKg, opts)` — исключение ручных пауз.** `opts.pauses` (мс epoch) → + `inManualPause(aMs,bMs) = pauses.some(z => aMsz.s)`. В сегментном цикле: + `if (inManualPause(prevMs,pMs)) { pausedSec += dt; elevRef = null; continue; }` — целиком + исключает сегмент (дистанция/скорость/высота/ккал), сбрасывает elevRef. `duration -= pausedSec`. +3. **Фильтр высоты по уклону.** В банковании перепада: `glitch = prevAlt!=null && segDist>0 && + |smoothedAlt[i]-prevAlt|/segDist > MAX_GRADE` (`MAX_GRADE = config.maxRealGradePct ?? 1.0` + = 100% = 45°). При glitch — не банковать и не двигать elevRef. Плавные изменения (0 у реки) проходят. + Существующие фильтры (выброс >4м, «застревание», сглаживание) оставлены. +4. **Запись высоты (Kotlin `GpsRecorderService.kt` ~465).** `altStr = if (location.hasAltitude()) + location.altitude.toString() else "null"` → `"alt":${altStr}`. Нет фикса → null (не 0). Идёт вперёд. +5. **Проброс пауз через вызовы `computeStats`/`finishGpsSession`:** live-стоп (recPauses), + `recalculateSession` recorded-ветка (sessPauses), recovery (`s.pauses||[]`), + `recomputeAllStats` bulk (bulkPauses из session.pauses), `showSessionDetail` (main.js: + `{pauses: session.pauses||[]}` + вычет pauseMs из duration). + Ручная-маршрут ветка recalculateSession паузы не передаёт (у нарисованных маршрутов пауз нет). + +## Файлы +- `frontend/js/gps.js` — computeStats (паузы + фильтр уклона), проброс в finish/recalculate/recovery/stop/bulk. +- `frontend/js/db.js` — миграция `pauses`, finishGpsSession, getGpsSession. +- `frontend/js/main.js` — showSessionDetail: передать `session.pauses`, вычесть pauseMs. +- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt` — alt=null без фикса. + +## Верификация +1. `node --check` на трёх JS — чисто. +2. `./build.sh -d -i android` → короткая поездка с ручной паузой на склоне → в деталях трека + участок паузы не учтён (нет дистанции/перепада), в БД `pauses` = JSON; треки с 0-глюками не раздуты, + плавный 0 у реки — профиль цел. + +## Не трогаем +- Ручной инструмент «выделить/исключить участок» — отложено. +- Старые треки ретроактивно не правим (паузы не были сохранены). Seed/продукты не трогаем. diff --git a/docs/session-logs/2026-09-07_brouter-findings.md b/docs/session-logs/2026-09-07_brouter-findings.md new file mode 100644 index 0000000..bfbeafa --- /dev/null +++ b/docs/session-logs/2026-09-07_brouter-findings.md @@ -0,0 +1,39 @@ +# Сессия 2026-09-07: разбор BRouter под две идеи + +## Контекст +Пользователь спросил, разбирал ли я brouter apk. Не разбирал. В дереве оказался +`brouter-1.6.3.zip` (внутри два apk `btools.routingapp`, движок `brouter-1.6.3-all.jar`, +профили, доки) и `FitPro...apk` (не brouter). Цель разбора (уточнена пользователем): понять, +как BRouter строит маршруты с малым набором высоты, ради двух идей — +1) заполнять пропажу GPS кратчайшим путём по дороге; 2) оффлайн-планирование маршрута по точкам +в BalDav (замена онлайн-BRouter). + +## Действия +Работали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` (сохранён смысл ниже; +файл планов ротируется). Разбор read-only: листинг архива, чтение доков/профилей через stdout, +инспекция jar в памяти (python zipfile из BytesIO), извлечение строк байткода — распаковки на диск +и запуска Java не было. + +Выяснено: где дорога — сегменты `.rd5` (тайлы 5×5°, OSM в битстрим по lookups.dat, высоты из +SRTM-90м); роутинг — `btools.router.RoutingEngine` (matchWaypointsToNodes → поиск по графу, +стоимость из профиля `btools.expressions`); малый набор высоты — параметры профиля +uphillcost/downhillcost/cutoff + кинематика (totalMass/S_C_x=CdA/C_r/bikerPower, аналог модели +мощности BalDav); `shortest.brf` — чистый кратчайший путь (под идею 1). Вызов оффлайн: jar +`RouteServer ` + HTTP +`/brouter?lonlats=..&profile=..&format=geojson`; приложение BRouter (Server-Mode HTTP или intent); +встраивание jar (356 КБ) в Android-модуль BalDav. + +## Результат +Создан `docs/brouter-findings.md` — полный разбор: состав, данные, алгоритм, малый набор высоты, +3 способа вызова + встраивание, обе идеи со стыком к существующему коду (computeStats ветка isGap; +showRouteEditor/showRouteDetail/parseRouteGPX/routeElevStats в main.js), таблица вариантов +интеграции с рекомендацией (целевой — встраивание движка; сначала проверить ценность на +установленном приложении BRouter), открытые вопросы (лицензия abrensch/brouter, сегменты +качать/строить, хранилище на телефоне, профили). Реализацию идей НЕ делали — отдельные фичи. + +README не трогал: пользовательской фичи пока нет, это анализ. PROJECT_MAP — добавлен раздел +«Разборы (docs/)» с указателями на gps-model-findings и brouter-findings. + +## Следующие шаги +- Ответы пользователя на открытые вопросы §9 разбора → планировать идею 1 и/или идею 2. +- Опционально PoC: RouteServer на компе + сегменты региона (нужен JRE + загрузка, по согласию). diff --git a/docs/session-logs/2026-09-07_gps-model-findings-impl.md b/docs/session-logs/2026-09-07_gps-model-findings-impl.md new file mode 100644 index 0000000..1166631 --- /dev/null +++ b/docs/session-logs/2026-09-07_gps-model-findings-impl.md @@ -0,0 +1,47 @@ +# Сессия 2026-09-07: реализация находок по расчётной модели GPS + +## Контекст +Разобрали `docs/gps-model-findings.md` (вывод независимого инструмента `tools/track/track.py` +по 156 поездкам). Пользователь принял решения по каждой находке. Плана в plan-mode не было — +работали по прямым решениям пользователя, зафиксированным ниже. + +## Решения пользователя и что сделано +- **#1 Crr-потолок 0.008 занижен → внедряем.** `crrFromAccel` в gps.js расширен: 7-11=0.008, + 11-15=0.012 (плотный грунт), >15=0.020 (трава/колея). Мапится и медиана сессии, и per-point ptCrr. +- **#2 max(физика,MET) не складывает, подъёмы недосчитаны → только пометить.** Не меняем формулу: + добавлен комментарий у `Math.max(physicsKcal, metNetKcal)` (ИЗВЕСТНОЕ ОГРАНИЧЕНИЕ, нужна калибровка), + записано в README и PROJECT_MAP. +- **#3+#7 ходьба с велосипедом как езда → внедряем.** computeStats: для односкоростного + (`gearCount` 1/пусто + звезда/трещотка/колесо) порог walkBikeMs = 30 об/мин; сегмент медленнее + порога (нормальный фикс) исключается из ездовой дистанции/скорости/высоты (elevRef=null), + усилие = walk-MET нетто. #7: исключается каждая точка без гейта по длительности, в отчёт «пешком» + только пробеги ≥30с. Возврат: walkBikeSec/walkBikeMeters. `gearCount` прокинут во все вызовы + (live-stop, recalculateSession, recovery, bulk, main.js старт записи + detail). Время пуша ≥30с + вычитается из duration (средняя скорость остаётся чисто ездовой). +- **#4 набор высоты расходится в обе стороны → только пометить.** Не код: записано в README и + PROJECT_MAP (предел сырого GPS-альтиметра, реальный фикс — предзагрузка DEM по проводу). +- **#5/#6** — к сведению (пульс потерян браслет; ветер в track.py, не в приложении). + +## Ветер (часть 2 доки) +Пользователь: калории от неточного ветра не считать. Хочет простые отслеживаемые вещи — метки +«ветер в лицо», писать данные и делать выводы после набора километража. Это вариант 5 в роли +РАЗМЕТКИ, не измерения. Трубка Пито (вариант 3) — точно нет. Вариант 2 (предзагрузка прогноза) — +объяснил (скачать прогноз дома по проводу как подсказку-затравку для оценки по треку; изъян тот же, +что у «уточнить высоты» — надо не забыть нажать перед выездом). + +Решения по UX (AskUserQuestion): кнопка-тоггл, голос потом; только «в лицо» (бинарно). Реализовано: +- кнопка «Ветер в лицо» на экране записи (тап открыть интервал / тап закрыть, активная подсвечена); +- GPS.toggleWindMark()/_closeOpenWindMark(); windMarks=[{startTs,endTs}] мс, персист в localStorage, + запись DB.setSessionWindMarks на финише, восстановление в recovery (недозакрытый интервал закрывается); +- колонка gps_sessions.wind_marks (JSON), парсинг в getGpsSession. На distance/kcal/power НЕ влияет. +- Анализ (сравнение меток с оценкой ветра по треку) — позже в tools/track/track.py, после километража. + +## Результат +Модельные правки #1/#3/#7 внедрены, #2/#4 задокументированы. `node --check` чист по всем файлам. +Собрано и поставлено ранее build 436 (только правки пауз/высоты); эти правки требуют новой сборки. + +## Следующие шаги +- Собрать/поставить новую сборку, проверить: на грунтах ккал выше; пеший подъём на односкоростном + не в ездовой статистике и не склеивается с ездовым подъёмом; кнопка «Ветер в лицо» пишет + интервалы в gps_sessions.wind_marks. +- Ветер: добавить голосовые метки (отдельным шагом); анализ меток vs оценка по треку в track.py. diff --git a/docs/session-logs/2026-09-07_manual-pauses-altitude-glitches.md b/docs/session-logs/2026-09-07_manual-pauses-altitude-glitches.md new file mode 100644 index 0000000..5184ecd --- /dev/null +++ b/docs/session-logs/2026-09-07_manual-pauses-altitude-glitches.md @@ -0,0 +1,28 @@ +# Сессия 2026-09-07: ручные паузы и глюки высоты в статистике трека + +## Контекст +На последнем ~56км-треке подъём пройден пешком: внизу пауза руками, наверху снята. BalDav +мостил GPS-гэп (дистанция + перепад высоты через elevRef, переживший гэп) как езду. Плюс +Android пишет `altitude=0.0` без фикса высоты (146 точек в данных), что как «реальная высота» +раздувает набор высоты. Пользователь часто у рек, где 0 может быть честным — нельзя тупо +выкидывать 0. Правило: исключать нереальный уклон (>45°/>100%), плавные значения (в т.ч. 0) хранить. + +## Действия (по плану docs/plans/2026-09-07-manual-pauses-altitude-glitches.md) +Работали по утверждённому плану. Реализовано: +- db.js: колонка `gps_sessions.pauses TEXT` (JS ADD COLUMN миграция), `finishGpsSession(...,pauses)` + с `hasPauses`-гардом (undefined = не трогать колонку), `getGpsSession` парсит JSON. +- gps.js `computeStats`: `opts.pauses` → `inManualPause`; сегмент, пересекающий паузу, + исключается целиком (дистанция/скорость/высота/ккал), `elevRef=null`, `pausedSec` вычитается + из duration. Глюк-гард высоты: соседний перепад с грейдом > `maxRealGradePct` (1.0=45°) не + банкуется и не двигает elevRef. Проброс пауз во все вызовы: live-стоп, recalculateSession + (recorded), recovery, recomputeAllStats (bulk), showSessionDetail. +- main.js showSessionDetail: передаёт `session.pauses`, вычитает pauseMs из длительности. +- Kotlin GpsRecorderService: `alt=null` без `hasAltitude()`, честный 0 с фиксом сохраняется. + +## Результат +Только для НОВЫХ треков (старые паузы не сохранялись — ретро-фикса нет; UI ручного разрезания +отложен). Все 3 JS проходят `node --check`. PROJECT_MAP обновлён (computeStats-раздел + колонка pauses). + +## Следующие шаги +- `./build.sh -d -i android`, записать короткую поездку с ручной паузой на склоне, проверить: + участок паузы не в статистике, `pauses` в БД = JSON, треки с 0-глюками не раздуты. diff --git a/docs/session-logs/2026-09-07_rec-statusbar-backdrop.md b/docs/session-logs/2026-09-07_rec-statusbar-backdrop.md new file mode 100644 index 0000000..b075fa4 --- /dev/null +++ b/docs/session-logs/2026-09-07_rec-statusbar-backdrop.md @@ -0,0 +1,24 @@ +# Сессия 2026-09-07: статус-бар на экране записи + +## Проблема +При включённом трекинге не видно системный статус-бар. Пользователь: перекрывают его только +карты, остальное приложение ведёт себя нормально. Просьба — сделать бар видимым (не рисовать свой +индикатор батареи). + +## Причина +Карта на экране записи (`.rec-map-wrap`/`.rec-map`) рисуется edge-to-edge и заходит под системный +статус-бар. Контролы карты (компас, зум-кнопки leaflet) уже сдвинуты вниз на `--safe-area-top` +(строки 1155, 1161 styles.css) — то есть дизайн задумывался с видимым статус-баром, но у самого бара +нет подложки: иконки теряются на тайлах карты. На остальных экранах подложку даёт «шапка» +(`padding-top: calc(... + var(--safe-area-top)); background: var(--surface)`). + +## Решение (только CSS, без сдвига координат карты) +Псевдоэлемент `.rec-map-wrap::before` — сплошная полоса высотой `var(--safe-area-top)` цвета +`var(--surface)`, `z-index:1100`, `pointer-events:none`. Даёт статус-бару подложку как «шапка». +Контролы карты уже начинаются ниже полосы, наложения нет. В ландшафте (rot-90/270) полоса отключена +(`display:none`) — там карта повёрнута и инсет физически в другом месте. + +`--safe-area-top` ставится из нативных инсетов (main.js:189, get_safe_insets), на устройстве ненулевой. + +## Файлы +- frontend/css/styles.css — `.rec-map-wrap::before` + отключение в rot-90/270. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 89b5c49..ff48d88 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1159,6 +1159,21 @@ select { } /* Keep Leaflet zoom controls below the status bar on the recording map. */ .rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); } +/* Status-bar backdrop. The recording map is edge-to-edge (draws behind the system + status bar), so the bar had no backing and its icons were unreadable over the map + tiles. Paint a solid strip the height of the top inset — like the header on every + other screen — so the status bar stays visible. Controls already start below it. */ +.rec-map-wrap::before { + content: ""; + position: absolute; + top: 0; left: 0; right: 0; + height: var(--safe-area-top, 0px); + background: var(--surface); + z-index: 1100; + pointer-events: none; +} +.rec-screen.rot-90 .rec-map-wrap::before, +.rec-screen.rot-270 .rec-map-wrap::before { display: none; } .rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; } .rec-compass-arrow::before { content: "N"; position: absolute; top: 1px; left: 50%; transform: translateX(-50%); @@ -1262,6 +1277,7 @@ select { .rec-bottom { display: flex; align-items: center; + flex-wrap: wrap; gap: 12px; padding: 8px 12px; padding-bottom: calc(8px + var(--safe-area-bottom)); diff --git a/frontend/index.html b/frontend/index.html index a9d2326..8170a98 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,6 +23,7 @@ + diff --git a/frontend/js/db.js b/frontend/js/db.js index 8fbce5d..7968408 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -779,14 +779,19 @@ async function addGpsPointsBulk(sessionId, points, startSeq) { return inserted; } -async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) { +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; + // pauses: array of manual-pause intervals; store as JSON. undefined = leave column as-is. + const hasPauses = pauses !== undefined; + const pausesJson = hasPauses ? JSON.stringify(pauses || []) : null; await execute( `UPDATE gps_sessions SET end_time=?1, total_distance_m=?2, total_kcal=?3, - avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7 + avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7${hasPauses ? ", pauses=?9" : ""} WHERE id=?8`, - [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id] + hasPauses + ? [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id, pausesJson] + : [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id] ); } @@ -843,7 +848,18 @@ async function getGpsSessionsForDate(date) { async function getGpsSession(id) { const rows = await select("SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE id = ?1", [id]); - return rows.length ? rows[0] : null; + if (!rows.length) return null; + const s = rows[0]; + if (typeof s.pauses === "string") { try { s.pauses = JSON.parse(s.pauses); } catch { s.pauses = []; } } + if (typeof s.wind_marks === "string") { try { s.wind_marks = JSON.parse(s.wind_marks); } catch { s.wind_marks = []; } } + return s; +} + +// Save headwind labels for a session (JSON). Kept separate from finishGpsSession so +// recompute paths preserve marks (they never call this). Marks are ground truth for +// the wind estimate — they do not affect distance/kcal/power. +async function setSessionWindMarks(id, marks) { + await execute("UPDATE gps_sessions SET wind_marks=?1 WHERE id=?2", [JSON.stringify(marks || []), id]); } async function getGpsPoints(sessionId) { @@ -1188,6 +1204,7 @@ window.DB.createGpsSession = createGpsSession; window.DB.addGpsPoint = addGpsPoint; window.DB.addGpsPointsBulk = addGpsPointsBulk; window.DB.finishGpsSession = finishGpsSession; +window.DB.setSessionWindMarks = setSessionWindMarks; window.DB.saveSmoothedTrack = saveSmoothedTrack; window.DB.setSessionSmoothed = setSessionSmoothed; window.DB.getGpsSessions = getGpsSessions; @@ -1529,6 +1546,8 @@ async function runMigrations() { if (!gsCols.includes("smoothed")) await execute("ALTER TABLE gps_sessions ADD COLUMN smoothed INTEGER DEFAULT 0"); if (!gsCols.includes("smooth_window")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_window INTEGER"); if (!gsCols.includes("smooth_iters")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_iters INTEGER"); + if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses + if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate) // Add tire_pressure_bar to bikes (for Crr correction by tire pressure) const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name); diff --git a/frontend/js/gps.js b/frontend/js/gps.js index e21d4bd..c1c1c90 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -111,6 +111,16 @@ GPS.computeStats = function(points, weightKg, opts) { let elevRef = null; // running reference for cumulative elevation hysteresis const speeds = []; // m/s per segment + // Manual pauses (ms epoch): the rider hand-paused, so that stretch must NOT count + // (walked, not ridden) — unlike an automatic GPS gap in motion, which we keep. + // A segment that touches a pause interval is dropped and elevRef is reset (so the + // elevation delta across the pause is not banked as a climb). + const manualPauses = (opts && opts.pauses ? opts.pauses : []) + .map(z => ({ s: z.startTs, e: z.endTs })).filter(z => z.s != null && z.e != null); + const inManualPause = (aMs, bMs) => manualPauses.some(z => aMs < z.e && bMs > z.s); + const MAX_GRADE = GPS.config.maxRealGradePct ?? 1.0; // 1.0 = 100% = 45°; steeper adjacent alt step = glitch + let pausedSec = 0; + // 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise) // Step 1: Extract raw altitudes and filter outliers. // An outlier is a point that jumps far from its neighbors and returns back — @@ -171,15 +181,21 @@ GPS.computeStats = function(points, weightKg, opts) { const rho = GPS.config.airDensity ?? 1.225; const efficiency = (GPS.config.pedalEfficiency ?? 25) / 100; // Rolling resistance: base from surface type, corrected by tire pressure. - // If accelerometer data available, estimate Crr from vibration (accel RMS): - // RMS < 3 → smooth asphalt (0.004), 3-7 → mixed (0.006), > 7 → gravel (0.008) + // If accelerometer data available, estimate Crr from vibration (accel RMS). + // Scale (lower bound of a back-solve on measured tracks — better to undercount): + // RMS < 3 → 0.004 smooth asphalt | 11-15 → 0.012 packed dirt + // 3-7 → 0.006 mixed | > 15 → 0.020 grass / rut / gravel + // 7-11 → 0.008 broken asphalt + // The old ceiling of 0.008 (gravel) undercounted rough surfaces ~3x: measured + // grass+tractor-rut needs 0.020-0.031, packed dirt 0.012. crrFromAccel maps both + // the session median (default Crr) and each point (ptCrr below) through this scale. + const crrFromAccel = (rms) => rms < 3 ? 0.004 : rms < 7 ? 0.006 : rms < 11 ? 0.008 : rms < 15 ? 0.012 : 0.020; let baseCrr = opts?.surfaceCrr ?? 0.006; const accelVals = sorted.map(p => p.accel_rms).filter(v => v != null && v > 0); if (accelVals.length > 10) { accelVals.sort((a, b) => a - b); const medianAccel = accelVals[Math.floor(accelVals.length / 2)]; - const accelCrr = medianAccel < 3 ? 0.004 : medianAccel < 7 ? 0.006 : 0.008; - baseCrr = accelCrr; // override with sensor-based estimate + baseCrr = crrFromAccel(medianAccel); // override with sensor-based estimate } const tirePressure = opts?.tirePressureBar ?? 3.0; const Crr = baseCrr * Math.sqrt(3.0 / tirePressure); @@ -197,12 +213,31 @@ GPS.computeStats = function(points, weightKg, opts) { const wheelCirc = hasGearData ? Math.PI * opts.wheelDiameterInch * 0.0254 : 0; // meters const gearRatio = hasGearData ? opts.chainringTeeth / opts.cogTeeth : 0; + // Walking the bike (single-speed only). On a fixed ratio, below ~30 rpm cadence + // the bike can't stay upright — so a sustained sub-30-rpm speed means the rider + // is pushing it, not riding. speed = cadence * distance-per-crank-rev. + // distance-per-rev = wheelCirc * gearRatio; at 30 rpm → walkBikeMs m/s. + // Only for single-speed (gear_count 1 or unset): on a geared bike low speed proves + // nothing (accelerometer can't tell pushing from riding — measured). Sub-threshold + // segments are excluded from ride distance/speed/elevation so a pushed climb doesn't + // merge with the ridden one; the effort is counted as walking, not bike physics. + const gearCount = opts?.gearCount; + const singleSpeed = isBike && hasGearData && (gearCount == null || gearCount <= 1); + const walkBikeMs = singleSpeed ? (30 * wheelCirc * gearRatio) / 60 : 0; + let walkBikeSec = 0, walkBikeDist = 0; // reported total (only runs ≥30s, see #7) + let wbRunSec = 0, wbRunDist = 0; // current contiguous push run + for (let i = 1; i < sorted.length; i++) { let p = sorted[i], prev = sorted[i - 1]; let d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon); let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; let v = dt > 0 ? d / dt : 0; // m/s + // Manual pause: exclude entirely (walked, not ridden). Reset elevRef so the + // elevation gained during the pause isn't banked on the next real segment. + let prevMs = new Date(prev.ts).getTime(), pMs = new Date(p.ts).getTime(); + if (inManualPause(prevMs, pMs)) { pausedSec += Math.max(0, dt); elevRef = null; continue; } + let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); let gapThr = GPS.config.gapThresholdSec ?? 30; @@ -230,6 +265,24 @@ GPS.computeStats = function(points, weightKg, opts) { // altitude and max-speed stats; gap and spike segments do not. let normalMove = !isGap && !isSpike && dt > 0; + // Walking the bike (single-speed): a normal-fix segment slower than the 30-rpm + // speed. Exclude it from ride distance/speed/elevation (elevRef reset breaks the + // pushed-vs-ridden climb merge) and count the effort as walking, not bike physics. + // Per #7: exclude EVERY such point regardless of duration (brief brake-to-stops + // land here too — harmless, ~0 m and low cost); accumulate contiguous runs and + // only report those ≥30s as "walked" (a stop before a pothole isn't walking). + if (walkBikeMs > 0 && normalMove && v > 0 && v < walkBikeMs) { + wbRunSec += dt; wbRunDist += segDist; + elevRef = null; + const wKmh = v * 3.6; + const wmet = wKmh < 4 ? 2.5 : wKmh < 5.5 ? 3.5 : wKmh < 7 ? 5.0 : 8.0; + totalKcal += (dt / 60) * ((wmet - 1) * 3.5 * wKg) / 200; // NET (BMR booked in daily balance) + continue; + } + // Not (or no longer) pushing → flush a completed push run into the reported total. + if (wbRunSec >= 30) { walkBikeSec += wbRunSec; walkBikeDist += wbRunDist; } + wbRunSec = 0; wbRunDist = 0; + if (segDist > 0) distance += segDist; if (normalMove) { speeds.push(v); @@ -239,10 +292,17 @@ GPS.computeStats = function(points, weightKg, opts) { // climbs. Accumulate the rise/fall from the reference and only bank it (and // advance the reference) once it clears the 1 m dead zone. if (smoothedAlt[i] != null) { - if (elevRef == null) elevRef = smoothedAlt[i]; - let rise = smoothedAlt[i] - elevRef; - if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; } - else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; } + // Reject a physically impossible adjacent altitude step (glitch — incl. an + // Android no-fix 0.0): grade over the segment steeper than MAX_GRADE (45°). + // A smooth change to any value (e.g. a real 0 near a river) passes. + let prevAlt = smoothedAlt[i - 1]; + let glitch = prevAlt != null && segDist > 0 && Math.abs(smoothedAlt[i] - prevAlt) / segDist > MAX_GRADE; + if (!glitch) { + if (elevRef == null) elevRef = smoothedAlt[i]; + let rise = smoothedAlt[i] - elevRef; + if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; } + else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; } + } } } @@ -275,7 +335,7 @@ GPS.computeStats = function(points, weightKg, opts) { // Per-point Crr from accelerometer (if available), else session-wide Crr const ptAccel = sorted[i].accel_rms; const ptCrr = (ptAccel != null && ptAccel > 0) - ? (ptAccel < 3 ? 0.004 : ptAccel < 7 ? 0.006 : 0.008) * Math.sqrt(3.0 / tirePressure) + ? crrFromAccel(ptAccel) * Math.sqrt(3.0 / tirePressure) : Crr; // Power required (watts): gravity + rolling resistance + aerodynamic drag @@ -310,6 +370,12 @@ GPS.computeStats = function(points, weightKg, opts) { if (vKmh >= 5) { const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0; const metNetKcal = (dt / 60) * ((met - 1) * 3.5 * wKg) / 200; + // KNOWN LIMITATION (measured, left as-is by decision): max() never SUMS the + // two, so on climbs — where physics wins big — the biological overhead the + // MET floor captures (balancing, core stabilisation, no external work) is + // dropped. Direction of the error is known: climbs are undercounted (whole- + // ride ~1.34x MET/physics, but flat 1.85x and climb 0.41x). A principled fix + // (physics/eff + overhead) needs calibration, so it is deferred, not applied. totalKcal += Math.max(physicsKcal, metNetKcal); } else { totalKcal += physicsKcal; // creeping < 5 km/h: negligible @@ -340,6 +406,9 @@ GPS.computeStats = function(points, weightKg, opts) { } } + // Flush a push run still open at the track's end. + if (wbRunSec >= 30) { walkBikeSec += wbRunSec; walkBikeDist += wbRunDist; } + // 9-point smoothed speeds for maxSpeed (kills spikes from GPS noise) let maxSpeed = 0; for (let i = 0; i < speeds.length; i++) { @@ -353,7 +422,9 @@ GPS.computeStats = function(points, weightKg, opts) { const first = new Date(sorted[0].ts); const last = new Date(sorted[sorted.length - 1].ts); - const duration = (last - first) / 1000; + // Exclude manual-pause time and sustained bike-push time — both keep avgSpeed + // (distance/duration) a pure riding figure, consistent with the excluded distance. + const duration = Math.max(0, (last - first) / 1000 - pausedSec - walkBikeSec); const avgSpeed = duration > 0 ? (distance / duration) : 0; const avgSpeedKmh = avgSpeed * 3.6; const maxSpeedKmh = maxSpeed * 3.6; @@ -459,6 +530,8 @@ GPS.computeStats = function(points, weightKg, opts) { duration: Math.round(duration), avgCadence, segments, + walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs) + walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance) }; }; @@ -889,6 +962,32 @@ GPS._processIncomingPoint = async function(p, fromNative) { if (GPS.recording.onPoint) GPS.recording.onPoint(p); }; +// Headwind labelling (variant 5 as VALIDATION, not measurement): the rider taps +// to open a "wind in the face" interval and taps again to close it. Marks are +// stored per session ([{startTs, endTs}] ms epoch) and later compared against the +// from-track wind estimate — they are ground truth, they do NOT feed kcal/power. +// Returns true if a headwind interval is now open (button should show active). +GPS.toggleWindMark = function() { + if (!GPS.recording.windMarks) GPS.recording.windMarks = []; + if (GPS.recording._currentWind) { + GPS.recording._currentWind.endTs = Date.now(); + GPS.recording._currentWind = null; + } else { + GPS.recording._currentWind = { startTs: Date.now(), endTs: null }; + GPS.recording.windMarks.push(GPS.recording._currentWind); + } + GPS._persistState(); + return GPS.recording._currentWind != null; +}; + +// Close an interval left open (rider forgot to tap off before Stop / recovery). +GPS._closeOpenWindMark = function() { + if (GPS.recording._currentWind && GPS.recording._currentWind.endTs == null) { + GPS.recording._currentWind.endTs = Date.now(); + } + GPS.recording._currentWind = null; +}; + // Persist recording state to localStorage for crash recovery GPS._persistState = function() { try { @@ -900,6 +999,7 @@ GPS._persistState = function() { totalPauseMs: GPS.recording.totalPauseMs, manualPaused: GPS.recording.manualPaused, pauses: GPS.recording.pauses || [], + windMarks: GPS.recording.windMarks || [], pointCount: GPS.recording.points.length, bikeId: GPS.recording.bikeId ?? null, bikeWeightKg: GPS.recording.bikeWeightKg ?? null, @@ -908,6 +1008,7 @@ GPS._persistState = function() { chainringTeeth: GPS.recording.chainringTeeth ?? null, cogTeeth: GPS.recording.cogTeeth ?? null, wheelDiameterInch: GPS.recording.wheelDiameterInch ?? null, + gearCount: GPS.recording.gearCount ?? null, })); } catch {} }; @@ -991,10 +1092,17 @@ GPS.recoverSession = async function() { chainringTeeth: s.chainringTeeth, cogTeeth: s.cogTeeth, wheelDiameterInch: s.wheelDiameterInch, + gearCount: s.gearCount, + pauses: s.pauses || [], }; const stats = GPS.computeStats(pts, s.weightKg || 80, recoverOpts); await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5), - stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, s.pauses || []); + // Restore headwind marks captured before the crash (close a dangling open one) + if (Array.isArray(s.windMarks) && s.windMarks.length) { + for (const w of s.windMarks) if (w && w.endTs == null) w.endTs = new Date(pts[pts.length - 1].ts).getTime(); + try { await DB.setSessionWindMarks(s.sessionId, s.windMarks); } catch (e) { console.error("[GPS] wind marks restore failed:", e); } + } // Store bike_id if present if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId); console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points"); @@ -1028,6 +1136,8 @@ GPS.startRecording = async function(activityType, weightKg) { GPS.recording.manualPaused = false; GPS.recording.pauses = []; GPS.recording._currentPause = null; + GPS.recording.windMarks = []; // headwind labels: [{startTs, endTs}] ms epoch + GPS.recording._currentWind = null; // open headwind interval, if any GPS.recording.nativeService = false; GPS.recording._pollTimer = null; GPS.recording._lastProcessedTs = 0; @@ -1170,16 +1280,24 @@ GPS.stopRecording = async function() { chainringTeeth: GPS.recording.chainringTeeth, cogTeeth: GPS.recording.cogTeeth, wheelDiameterInch: GPS.recording.wheelDiameterInch, + gearCount: GPS.recording.gearCount, }; + const recPauses = GPS.recording.pauses || []; + recOpts.pauses = recPauses; const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts); const endTime = new Date().toTimeString().slice(0, 5); + // Close an open headwind interval and persist all marks with the session. + GPS._closeOpenWindMark(); + const recWindMarks = GPS.recording.windMarks || []; + try { await DB.finishGpsSession( GPS.recording.sessionId, endTime, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, - stats.elevGain, stats.elevLoss + stats.elevGain, stats.elevLoss, recPauses ); + try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); } // DB save confirmed — now safe to delete the points file if (nativePointsFile) { try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {} @@ -1207,6 +1325,9 @@ GPS.stopRecording = async function() { GPS.recording.chainringTeeth = null; GPS.recording.cogTeeth = null; GPS.recording.wheelDiameterInch = null; + GPS.recording.gearCount = null; + GPS.recording.windMarks = []; + GPS.recording._currentWind = null; GPS._clearPersistedState(); return { sessionId: sid, stats, pauses }; }; @@ -1710,6 +1831,8 @@ GPS.recalculateSession = async function(sessionId, opts) { const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; // Use cleaned sorted array, not original points + const sessPauses = Array.isArray(session.pauses) ? session.pauses + : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); const stats = GPS.computeStats(sorted, weightKg, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", @@ -1718,13 +1841,15 @@ GPS.recalculateSession = async function(sessionId, opts) { chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, + gearCount: bike?.gear_count, + pauses: sessPauses, }); - // Update session in DB + // Update session in DB (preserve pauses) await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, - stats.elevGain, stats.elevLoss + stats.elevGain, stats.elevLoss, sessPauses ); return stats; @@ -1760,6 +1885,8 @@ GPS.recomputeAllStats = async function(onProgress) { const points = await DB.getGpsPoints(sid); if (points.length < 2) { skipped++; continue; } const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); + const bulkPauses = Array.isArray(session.pauses) ? session.pauses + : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); const stats = GPS.computeStats(points, weightKg, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", @@ -1768,9 +1895,11 @@ GPS.recomputeAllStats = async function(onProgress) { chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, + gearCount: bike?.gear_count, + pauses: bulkPauses, }); await DB.finishGpsSession(sid, session.end_time ?? session.start_time, - stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses); updated++; } catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; } } diff --git a/frontend/js/main.js b/frontend/js/main.js index 620e29f..0b9bc82 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2774,6 +2774,7 @@ async function renderGpsScreen(container) { GPS.recording.chainringTeeth = bike.chainring_teeth; GPS.recording.cogTeeth = bike.cog_teeth; GPS.recording.wheelDiameterInch = bike.wheel_diameter_inch; + GPS.recording.gearCount = bike.gear_count; } } await GPS.startRecording(type, wkg); @@ -3840,6 +3841,7 @@ async function renderRecordingInline(container) {
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
+
@@ -4288,6 +4290,15 @@ async function renderRecordingInline(container) { fetchTemperature(); let weatherTimer = setInterval(fetchTemperature, 15 * 60 * 1000); + // Headwind label toggle: tap to open a "wind in the face" interval, tap to close. + // Marks are ground truth for validating the from-track wind estimate — not kcal. + let windBtn = container.querySelector("#rec-wind"); + if (windBtn) windBtn.onclick = () => { + let active = GPS.toggleWindMark(); + windBtn.style.background = active ? "#ffb74d" : ""; + windBtn.style.color = active ? "#1a1a1a" : ""; + }; + // Pause button container.querySelector("#rec-pause").onclick = () => { if (GPS.recording.manualPaused) { @@ -4541,14 +4552,16 @@ async function showSessionDetail(sessionId) { elevGain: Math.round(session.elevation_gain_m || 0), elevLoss: Math.round(session.elevation_loss_m || 0), duration: 0, - } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80); - // Compute duration from points regardless + } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + // Manual-pause total (ms) to exclude from displayed duration + let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0); + // Compute duration from points regardless (minus manual pauses) if (points.length >= 2) { - stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000); + stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000)); } // Always compute segments from points (needed for the Segments section) if (!stats.segments && points.length >= 3) { - let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80); + let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); stats.segments = fullStats.segments; } @@ -4600,6 +4613,7 @@ async function showSessionDetail(sessionId) { showModal(`

${actName}

+
ID: ${session.id}
Время
@@ -4773,6 +4787,7 @@ async function showSessionDetail(sessionId) { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", surfaceCrr: CRR[bike?.surface_type] ?? 0.006, tirePressureBar: bike?.tire_pressure_bar ?? 3.0, chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, + gearCount: bike?.gear_count, }); await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss); @@ -5158,6 +5173,10 @@ async function showSessionDetail(sessionId) { let exp = document.querySelector("#sd-export"); if (exp) exp.onclick = () => GPS.exportGPX(sessionId); + // Track ID — click to copy to clipboard + let idEl = document.querySelector("#sd-track-id"); + if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId); + // Chart fullscreen in modal — push to winStack, back collapses document.querySelectorAll(".chart-wrap").forEach(wrap => { wrap.onclick = () => { @@ -7561,6 +7580,23 @@ function showToast(msg, duration) { setTimeout(hide, duration || 6000); } +// Copy text to clipboard (navigator.clipboard with a hidden-textarea fallback for +// WebViews where the async API is unavailable). Optional toast on success. +function copyToClipboard(text, toastMsg) { + let ok = () => { if (toastMsg) showToast(toastMsg); }; + let fallback = () => { + try { + let ta = document.createElement("textarea"); + ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.focus(); ta.select(); + document.execCommand("copy"); ta.remove(); ok(); + } catch (e) { showToast("Не удалось скопировать"); } + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(ok).catch(fallback); + } else fallback(); +} + function esc(str) { return String(str ?? "") .replace(/&/g, "&").replace(/ [p[0], p[1]])); + let raw = await window.__TAURI__.core.invoke("brouter_route", { + lonlats, + profile: profile || "trekking", + }); + let res; + try { res = JSON.parse(raw); } catch (e) { throw new Error("brouter: bad result: " + raw); } + if (!res.ok) throw new Error("brouter: " + (res.error || "unknown error")); + return res.gpx; + }, +}; diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..d2b3818 --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,77 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "dev.baldav.app" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "dev.baldav.app" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + // BRouter offline routing engine (MIT, github.com/abrensch/brouter) — embedded so + // routes are computed on-device without network. Called in-process from Kotlin + // (btools.router.RoutingEngine). Segments (.rd5) live in app data dir; profiles in assets. + implementation(files("libs/brouter-1.6.3-all.jar")) + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + // Optional fused location provider (opt-in via settings). Guarded at runtime so + // the app still works if Google Play services are absent — see GpsRecorderService. + implementation("com.google.android.gms:play-services-location:21.3.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/src-tauri/gen/android/app/libs/brouter-1.6.3-all.jar b/src-tauri/gen/android/app/libs/brouter-1.6.3-all.jar new file mode 100644 index 0000000..4310c1c Binary files /dev/null and b/src-tauri/gen/android/app/libs/brouter-1.6.3-all.jar differ diff --git a/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/lookups.dat b/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/lookups.dat new file mode 100644 index 0000000..5a9599e --- /dev/null +++ b/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/lookups.dat @@ -0,0 +1,938 @@ +---lookupversion:10 +---minorversion:13 + +---context:way + +highway;0029035962 residential +highway;0010319731 service +highway;0007688809 track +highway;0007656124 unclassified +highway;0004141444 footway +highway;0003493551 tertiary +highway;0002852601 path +highway;0002185240 secondary +highway;0001447719 primary +highway;0000699577 cycleway +highway;0000608469 trunk +highway;0000568118 living_street +highway;0000515044 motorway +highway;0000451760 motorway_link +highway;0000442502 steps +highway;0000360177 road +highway;0000318426 pedestrian +highway;0000210535 trunk_link +highway;0000192461 primary_link +highway;0000120758 secondary_link +highway;0000079637 tertiary_link +highway;0000070238 construction +highway;0000058257 bridleway +highway;0000039003 platform +highway;0000037192 proposed planned virtual +highway;0000010307 raceway +highway;0000003152 rest_area +highway;0000002942 abandoned disused razed demolished dismantled +highway;0000002631 services +highway;0000002133 corridor +highway;0000002093 crossing +highway;0000001440 bus_stop +highway;0000001274 yes +highway;0000000679 unsurfaced +highway;0000000108 byway +highway;0000000037 driveway +highway;0000000021 mini_roundabout +highway;0000000020 turning_loop + +tracktype;0000887965 grade2 +tracktype;0000868414 grade3 +tracktype;0000595882 grade1 +tracktype;0000568372 grade4 +tracktype;0000405959 grade5 + +surface;0002497676 asphalt +surface;0001568957 paved +surface;0001562253 unpaved +surface;0000727427 gravel +surface;0000560191 ground +surface;0000350378 dirt +surface;0000237226 grass +surface;0000212587 concrete concrete:plates concrete:lanes +surface;0000188743 paving_stones paving_stones:30 paving_stones:20 +surface;0000113800 cobblestone cobblestone:flattened +surface;0000093164 compacted +surface;0000091171 sand dirt/sand +surface;0000023293 wood +surface;0000019915 pebblestone +surface;0000012866 fine_gravel +surface;0000010681 earth +surface;0000007331 sett +surface;0000005778 mud +surface;0000004549 grass_paver +surface;0000004398 clay +surface;0000003760 metal + +maxspeed;0001058313 50 30_mph 30mph +maxspeed;0000860780 30 20_mph 20mph +maxspeed;0000025232 10 5 7 15 +maxspeed;0000083989 20 10_mph 10mph 15_mph 15mph +maxspeed;0000195097 40 45 25_mph 25mph +maxspeed;0000204646 60 35_mph 35mph 40_mph 40mph +maxspeed;0000130108 70 45_mph 45mph +maxspeed;0000225071 80 50_mph 50mph +maxspeed;0000106719 90 55_mph 55mph +maxspeed;0000134522 100 60_mph 60mph 65_mph 65mph +maxspeed;0000025242 110 70_mph 70mph +maxspeed;0000038763 120 75_mph 75mph +maxspeed;0000026953 130 +maxspeed;0000138654 urban RO:urban RU:urban FR:urban IT:urban AT:urban DE:urban UA:urban +maxspeed;0000138654 rural RO:rural RU:rural FR:rural IT:rural AT:rural DE:rural UA:rural + +service;0001433919 parking_aisle +service;0001305879 driveway +service;0000382788 alley +service;0000018777 drive-through drive_through +service;0000008290 emergency_access +service;0000003138 bus +service;0000001250 parking +service;0000001159 logging + +lit;0000809223 yes + +lanes;0002838405 2 +lanes;0000718138 1 +lanes;0000259502 3 +lanes;0000141651 4 +lanes;0000018473 -1 +lanes;0000017934 5 +lanes;0000008241 6 +lanes;0000003643 1.5 +lanes;0000001087 7 + +access;0002688349 private +access;0000319927 yes +access;0000144799 no +access;0000140215 permissive +access;0000108802 destination +access;0000099899 agricultural forestry +access;0000039934 designated official +access;0000011813 customers +access;0000004007 delivery +access;0000000100 psv +access;0000000100 hov + +foot;0001659694 yes allowed Yes +foot;0000424847 designated official +foot;0000202364 no +foot;0000053031 permissive +foot;0000011661 destination +foot;0000007289 private +foot;0000000167 use_sidepath sidewalk + +bicycle;0001245560 yes allowed +bicycle;0000452059 no +bicycle;0000324902 designated official +bicycle;0000025707 dismount +bicycle;0000020440 permissive +bicycle;0000008286 private +bicycle;0000001553 destination +bicycle;0000000719 use_sidepath use_cycleway +bicycle;0000000385 mtb +bicycle;0000000117 opposite + +motorcar;0000135124 no +motorcar;0000045407 yes +motorcar;0000021494 agricultural forestry +motorcar;0000012090 destination +motorcar;0000008733 private +motorcar;0000005757 designated official +motorcar;0000004116 permissive +motorcar;0000000979 restricted +motorcar;0000000100 psv +motorcar;0000000100 hov + +motor_vehicle;0000212692 no +motor_vehicle;0000184982 yes +motor_vehicle;0000045128 private +motor_vehicle;0000032622 agricultural forestry agricultural;forestry agricultural,forestry +motor_vehicle;0000025396 designated official +motor_vehicle;0000025092 destination +motor_vehicle;0000010895 permissive +motor_vehicle;0000000175 emergency Emergency +motor_vehicle;0000000100 psv +motor_vehicle;0000000100 hov + +motorcycle;0000092079 no +motorcycle;0000027978 yes +motorcycle;0000014652 agricultural forestry +motorcycle;0000008862 destination +motorcycle;0000004877 designated official +motorcycle;0000003936 private +motorcycle;0000002209 permissive +motorcycle;0000000100 psv +motorcycle;0000000100 hov + +vehicle;0000030218 no +vehicle;0000013333 destination +vehicle;0000011692 yes +vehicle;0000007147 agricultural forestry agricultural;forestry +vehicle;0000006305 private +vehicle;0000001294 permissive +vehicle;0000000105 designated +vehicle;0000000100 psv +vehicle;0000000100 hov + +horse;0000227398 no +horse;0000144432 yes +horse;0000014566 designated +horse;0000007223 permissive +horse;0000004755 private +horse;0000000983 official +horse;0000000968 unknown +horse;0000000205 destination + +wheelchair;0000036603 no +wheelchair;0000028451 yes +wheelchair;0000002713 limited +wheelchair;0000001043 unknown +wheelchair;0000000439 designated official +wheelchair;0000000184 destination + +hgv;0000206836 designated +hgv;0000071222 yes +hgv;0000043783 no +hgv;0000019115 destination +hgv;0000005273 delivery +hgv;0000003055 local +hgv;0000001088 agricultural forestry +hgv;0000000461 private +hgv;0000000320 unsuitable +hgv;0000000306 permissive + +cycleway;0000137526 no +cycleway;0000124777 lane +cycleway;0000106948 track +cycleway;0000044652 opposite +cycleway;0000011237 shared +cycleway;0000007312 opposite_lane +cycleway;0000005737 shared_lane +cycleway;0000002533 yes +cycleway;0000002356 opposite_track +cycleway;0000001945 share_busway +cycleway;0000001883 none +cycleway;0000001705 crossing +cycleway;0000001560 unmarked_lane +cycleway;0000001542 right +cycleway;0000001291 segregated +cycleway;0000001065 both +cycleway;0000000892 left +cycleway;0000000399 street +cycleway;0000000344 shoulder +cycleway;0000000326 designated +cycleway;0000000247 proposed planned virtual +cycleway;0000000224 cyclestreet +cycleway;0000000172 path +cycleway;0000000154 sidewalk + +footway;0000104998 sidewalk +footway;0000065943 crossing +footway;0000012342 both +footway;0000008363 none +footway;0000005903 right +footway;0000004159 left +footway;0000003966 no +footway;0000001093 yes +footway;0000000558 separate + +segregated;0000224960 no +segregated;0000051124 yes + +sidewalk;0000194579 none +sidewalk;0000111468 both +sidewalk;0000052950 right +sidewalk;0000024489 left +sidewalk;0000012916 no +sidewalk;0000005725 separate +sidewalk;0000001950 yes + +mtb:scale;0000114272 0 +mtb:scale;0000068284 1 +mtb:scale;0000027311 2 +mtb:scale;0000011529 3 +mtb:scale;0000003666 4 +mtb:scale;0000001957 0+ +mtb:scale;0000001472 5 +mtb:scale;0000000498 1+ +mtb:scale;0000000478 1- +mtb:scale;0000000268 0- +mtb:scale;0000000177 2- +mtb:scale;0000000131 2+ +mtb:scale;0000000115 6 + +sac_scale;0000150704 hiking +sac_scale;0000070463 mountain_hiking +sac_scale;0000010993 demanding_mountain_hiking +sac_scale;0000004549 alpine_hiking +sac_scale;0000001620 demanding_alpine_hiking +sac_scale;0000000831 yes +sac_scale;0000000712 difficult_alpine_hiking +sac_scale;0000000265 T1-hiking + +noexit;0000118665 yes + +motorroad;0000056844 yes + +oneway;0005387257 yes +oneway;0001455407 no +oneway;0000139188 -1 +oneway;0000000892 reversible +oneway;0000000756 1 +oneway;0000000481 true + +junction;0000321066 roundabout +junction;0000002828 spui +junction;0000002134 jughandle +junction;0000001493 approach +junction;0000000100 circular + +bridge;0001842517 yes viaduct true suspension + +tunnel;0000247305 yes +tunnel;0000016890 building_passage +tunnel;0000004237 no +tunnel;0000000265 passage +tunnel;0000000241 culvert +tunnel;0000000122 avalanche_protector +tunnel;0000000114 covered + +lcn;0000073956 yes +lcn;0000002631 proposed + +oneway:bicycle;0000012034 no +oneway:bicycle;0000005217 yes +oneway:bicycle;0000000161 opposite + +cycleway:right;0000012522 lane Lane +cycleway:right;0000006644 track +cycleway:right;0000000971 share_busway +cycleway:right;0000000686 sidepath +cycleway:right;0000000410 shared_lane +cycleway:right;0000000104 opposite_lane +cycleway:right;0000000058 opposite_track +cycleway:right;0000000045 no none +cycleway:right;0000000037 yes +cycleway:right;0000000004 opposite + +cycleway:left;0000005134 lane Lane +cycleway:left;0000003169 track +cycleway:left;0000000656 share_busway +cycleway:left;0000000608 opposite_lane +cycleway:left;0000000475 sidepath +cycleway:left;0000000257 shared_lane +cycleway:left;0000000246 no none +cycleway:left;0000000130 opposite_track +cycleway:left;0000000053 opposite +cycleway:left;0000000014 yes + +incline;0000052784 up +incline;0000035413 down +incline;0000001628 yes +incline;0000000779 steep +incline;0000000861 3% 0 1 2 3 0% 1% 2% 3% +incline;0000000724 5% 4 5 4% +incline;0000000530 8% 6 7 8 6% 7% +incline;0000003109 10% 9 10 9% 10° +incline;0000001297 15% 11 12 13 14 15 11% 12% 13% 14% +incline;0000000997 20% 16 17 18 19 20 16% 17% 18% 19% +incline;0000000409 25% 21 22 23 24 25 21% 22% 23% 24% +incline;0000000263 30% 30 40 50 40% 50% +incline;0000000861 -3% -1 -2 -3 -1% -2% -3% +incline;0000000724 -5% -4 -5 -4% +incline;0000000530 -8% -6 -7 -8 -6% -7% +incline;0000001515 -10% -9 -10 -9% -10° +incline;0000001297 -15% -11 -12 -13 -14 -15 -11% -12% -13% -14% +incline;0000000997 -20% -16 -17 -18 -19 -20 -16% -17% -18% -19% +incline;0000000409 -25% -21 -22 -23 -24 -25 -21% -22% -23% -24% +incline;0000000172 -30% -30 -40 -50 -40% -50% + +toll;0000090536 yes true + +railway;0000157547 rail +railway;0000019316 abandoned +railway;0000016982 tram +railway;0000014387 platform +railway;0000011143 disused +railway;0000004623 light_rail +railway;0000002982 subway +railway;0000002422 narrow_gauge +railway;0000001960 razed +railway;0000001859 preserved + +seamark:type;0001564 recommended_track +seamark:type;0000522 fairway + +waterway;0000016046 river +waterway;0000009496 canal +waterway;0000007876 riverbank +waterway;0000002202 weir +waterway;0000001364 dam +waterway;0000000386 lock +waterway;0000000321 tidal_flat_slough +waterway;0000000179 wadi +waterway;0000000126 dock +waterway;0000000113 fish_pass +waterway;0000000086 boatyard +waterway;0000000071 fairway +waterway;0000000059 lock_gate + +boat;0000019888 no +boat;0000002718 yes +boat;0000000232 private +boat;0000000064 permissive +boat;0000000045 designated + +motorboat;0000001077 yes +motorboat;0000000808 no +motorboat;0000000025 private privat + +route;0000000850 ferry +route;0000000539 hiking +route;0000000505 bicycle +route;0000000454 ski +route;0000000413 mtb +route;0000000194 canoe +route;0000000151 road +route;0000000104 bus + +smoothness;0000068136 good +smoothness;0000042124 bad Bad +smoothness;0000040763 intermediate +smoothness;0000033941 excellent +smoothness;0000012683 very_bad +smoothness;0000009837 horrible terrible +smoothness;0000003515 very_horrible +smoothness;0000000919 impassable +smoothness;0000000251 robust_wheels +smoothness;0000000221 high_clearance +smoothness;0000000132 very_good +smoothness;0000000083 off_road_wheels +smoothness;0000000057 medium Medium average +smoothness;0000000048 poor +smoothness;0000000039 rough + +rcn;0000014518 yes +rcn;0000002862 proposed + +ncn;0000002941 yes +ncn;0000001036 proposed + +ford;0000020552 yes +ford;0000000289 stepping_stones + +trail_visibility;0000067438 good +trail_visibility;0000041280 intermediate +trail_visibility;0000039801 excellent +trail_visibility;0000023482 bad +trail_visibility;0000005853 horrible +trail_visibility;0000002222 no + +class:bicycle:mtb;0000002079 1 +1 +class:bicycle:mtb;0000001191 0 +class:bicycle:mtb;0000001089 2 +2 +class:bicycle:mtb;0000000703 -1 +class:bicycle:mtb;0000000234 -2 +class:bicycle:mtb;0000000140 3 +3 +class:bicycle:mtb;0000000068 -3 + +class:bicycle;0000002842 1 +1 +class:bicycle;0000000595 -1 +class:bicycle;0000000533 2 +2 +class:bicycle;0000000516 -2 +class:bicycle;0000000245 -3 +class:bicycle;0000000170 0 +class:bicycle;0000000108 3 +3 + +route_bicycle_icn;0000088753 yes +route_bicycle_icn;0000000001 proposed +route_bicycle_ncn;0000268180 yes +route_bicycle_ncn;00000000001 proposed +route_bicycle_rcn;0000718163 yes +route_bicycle_rcn;00000000001 proposed +route_bicycle_lcn;0000469215 yes +route_bicycle_lcn;00000000001 proposed + +route_bicycle_;0000024662 yes +route_bicycle_radweit;0000004604 yes + +route_hiking_iwn;0000056005 yes +route_hiking_nwn;0000315813 yes +route_hiking_rwn;0000343219 yes +route_hiking_lwn;0000359332 yes +route_hiking_;0000103733 yes + +route_foot_nwn;0000047923 yes +route_foot_lwn;0000135371 yes +route_foot_rwn;0000115325 yes +route_foot_;0000070583 yes + +route_mtb_;0000066263 yes +route_mtb_lcn;0000023718 yes +route_mtb_ncn;0000004853 yes +route_mtb_rcn;0000013321 yes +route_mtb_mtb;0000006853 yes +route_bicycle_mtb;0000002240 yes + +brouter_route_placeholder_dummy_01;0000000001 dummy +brouter_route_placeholder_dummy_02;0000000001 dummy +brouter_route_placeholder_dummy_03;0000000001 dummy +brouter_route_placeholder_dummy_04;0000000001 dummy +brouter_route_placeholder_dummy_05;0000000001 dummy +brouter_route_placeholder_dummy_06;0000000001 dummy +brouter_route_placeholder_dummy_07;0000000001 dummy +brouter_route_placeholder_dummy_08;0000000001 dummy +brouter_route_placeholder_dummy_09;0000000001 dummy +brouter_route_placeholder_dummy_10;0000000001 dummy +brouter_route_placeholder_dummy_11;0000000001 dummy +brouter_route_placeholder_dummy_12;0000000001 dummy +brouter_route_placeholder_dummy_13;0000000001 dummy +brouter_route_placeholder_dummy_14;0000000001 dummy +brouter_route_placeholder_dummy_15;0000000001 dummy +brouter_route_placeholder_dummy_16;0000000001 dummy +brouter_route_placeholder_dummy_17;0000000001 dummy +brouter_route_placeholder_dummy_18;0000000001 dummy +brouter_route_placeholder_dummy_19;0000000001 dummy +brouter_route_placeholder_dummy_20;0000000001 dummy +brouter_route_placeholder_dummy_21;0000000001 dummy + +ramp:bicycle;0000001305 yes both permissive right left +ramp:bicycle;0000000385 no + +ramp:stroller;0000001099 yes +ramp:stroller;0000000326 no + +ramp:wheelchair;0000000610 yes +ramp:wheelchair;0000000439 no + +ramp:luggage;0000000162 no +ramp:luggage;0000000054 yes automatic manual + +estimated_traffic_class;0000000001 1 +estimated_traffic_class;0000000001 2 +estimated_traffic_class;0000000001 3 +estimated_traffic_class;0000000001 4 +estimated_traffic_class;0000000001 5 +estimated_traffic_class;0000000001 6 +estimated_traffic_class;0000000001 7 + +mtb:scale:uphill;0000018869 0 0+ 0- +mtb:scale:uphill;0000015578 1 1+ 1- +mtb:scale:uphill;0000012338 2 2+ 2- +mtb:scale:uphill;0000009099 3 3+ 3- +mtb:scale:uphill;0000005825 4 4+ 4- +mtb:scale:uphill;0000004628 5 5+ 5- + +crossing;0000101049 zebra +crossing;0000017509 unmarked +crossing;0000013817 traffic_signals +crossing;0000011062 uncontrolled +crossing;0000001722 yes +crossing;0000001678 island +crossing;0000000457 marked +crossing;0000000131 pedestrian_signals +crossing;0000000122 no + +informal;0000002424 yes + +indoor;0000058418 yes +indoor;0000025038 room +indoor;0000005295 wall +indoor;0000004322 corridor +indoor;0000002410 area +indoor;0000000816 column +indoor;0000000568 no +indoor;0000000129 shop +indoor;0000000099 steps + +4wd_only;0000008129 yes Yes +4wd_only;0000000487 recommended +4wd_only;0000000041 no + +concrete;0000000043 plates +concrete;0000000013 lanes + +bus;0001178365 yes +bus;0000006419 designated +bus;0000005602 no +bus;0000001424 urban + +psv;0000072077 yes +psv;0000007456 no +psv;0000007428 designated official + +hov;0000006684 lane +hov;0000003258 designated +hov;0000002162 no +hov;0000001512 yes + +busway;0000000000 opposite opposite_lane opposite_track +busway:left;0000000000 opposite opposite_lane opposite_track +busway:right;0000000000 opposite opposite_lane opposite_track + +cycleway:left:oneway;0000000769 yes +cycleway:left:oneway;0000001595 no +cycleway:left:oneway;0000000927 -1 + +cycleway:right:oneway;0000003084 yes +cycleway:right:oneway;0000002499 no +cycleway:right:oneway;0000000017 -1 + +zone:maxspeed;0000001616 20 DE:20 FR:20 +zone:maxspeed;0000063721 30 DE:30 FR:30 BE:30 HU:30 NO:30 AT:30 ES:30 NL:30 + +cycleway:surface;0000002609 asphalt +cycleway:surface;0000000150 paved +cycleway:surface;0000000012 unpaved +cycleway:surface;0000000010 gravel +cycleway:surface;0000000157 concrete concrete:plates concrete:lanes +cycleway:surface;0000002239 paving_stones paving_stones:30 paving_stones:20 +cycleway:surface;0000000011 cobblestone cobblestone:flattened +cycleway:surface;0000000013 compacted +cycleway:surface;0000000006 fine_gravel +cycleway:surface;0000000011 sett + +maxspeed:backward;0001058313 50 30_mph 30mph +maxspeed:backward;0000860780 30 20_mph 20mph +maxspeed:backward;0000025232 10 5 7 15 +maxspeed:backward;0000083989 20 10_mph 10mph 15_mph 15mph +maxspeed:backward;0000195097 40 45 25_mph 25mph +maxspeed:backward;0000204646 60 35_mph 35mph 40_mph 40mph +maxspeed:backward;0000130108 70 45_mph 45mph +maxspeed:backward;0000225071 80 50_mph 50mph +maxspeed:backward;0000106719 90 55_mph 55mph +maxspeed:backward;0000134522 100 60_mph 60mph 65_mph 65mph +maxspeed:backward;0000025242 110 70_mph 70mph +maxspeed:backward;0000038763 120 75_mph 75mph +maxspeed:backward;0000026953 130 +maxspeed:backward;0000138654 urban RO:urban RU:urban FR:urban IT:urban AT:urban DE:urban UA:urban +maxspeed:backward;0000138654 rural RO:rural RU:rural FR:rural IT:rural AT:rural DE:rural UA:rural + +maxspeed:forward;0001058313 50 30_mph 30mph +maxspeed:forward;0000860780 30 20_mph 20mph +maxspeed:forward;0000025232 10 5 7 15 +maxspeed:forward;0000083989 20 10_mph 10mph 15_mph 15mph +maxspeed:forward;0000195097 40 45 25_mph 25mph +maxspeed:forward;0000204646 60 35_mph 35mph 40_mph 40mph +maxspeed:forward;0000130108 70 45_mph 45mph +maxspeed:forward;0000225071 80 50_mph 50mph +maxspeed:forward;0000106719 90 55_mph 55mph +maxspeed:forward;0000134522 100 60_mph 60mph 65_mph 65mph +maxspeed:forward;0000025242 110 70_mph 70mph +maxspeed:forward;0000038763 120 75_mph 75mph +maxspeed:forward;0000026953 130 +maxspeed:forward;0000138654 urban RO:urban RU:urban FR:urban IT:urban AT:urban DE:urban UA:urban +maxspeed:forward;0000138654 rural RO:rural RU:rural FR:rural IT:rural AT:rural DE:rural UA:rural + +embedded_rails;0000000928 tram +embedded_rails;0000000007 yes +embedded_rails;0000000003 rail + +living_street;0000000404 yes + +sidewalk:bicycle;0000000439 yes designated +sidewalk:left:bicycle;0000001722 yes designated +sidewalk:right:bicycle;0000002667 yes designated + +bicycle_road;0000006521 yes designated + +construction;0000144871 yes +construction;0000008214 minor +construction;0029035962 residential +construction;0010319731 service +construction;0007688809 track +construction;0007656124 unclassified +construction;0004141444 footway +construction;0003493551 tertiary +construction;0002852601 path +construction;0002185240 secondary +construction;0001447719 primary +construction;0000699577 cycleway +construction;0000608469 trunk +construction;0000568118 living_street +construction;0000515044 motorway +construction;0000451760 motorway_link +construction;0000442502 steps +construction;0000360177 road +construction;0000318426 pedestrian +construction;0000210535 trunk_link +construction;0000192461 primary_link +construction;0000120758 secondary_link +construction;0000079637 tertiary_link +construction;0000070238 construction +construction;0000058257 bridleway +construction;0000039003 platform +construction;0000037192 proposed +construction;0000010307 raceway +construction;0000003152 rest_area +construction;0000002942 abandoned +construction;0000002631 services +construction;0000002133 corridor +construction;0000002093 crossing +construction;0000001440 bus_stop +construction;0000001274 yes +construction;0000000679 unsurfaced +construction;0000000108 byway +construction;0000000037 driveway +construction;0000000021 mini_roundabout +construction;0000000020 turning_loop + + ---context:node + +highway;0001314954 bus_stop +highway;0001130090 crossing +highway;0001031274 turning_circle +highway;0000609262 traffic_signals +highway;0000306900 street_lamp +highway;0000136339 stop +highway;0000105097 motorway_junction +highway;0000058076 give_way +highway;0000049111 mini_roundabout +highway;0000030072 milestone +highway;0000017567 speed_camera +highway;0000013806 emergency_access_point +highway;0000009721 platform +highway;0000007369 passing_place +highway;0000005939 ford +highway;0000004831 rest_area +highway;0000003535 elevator +highway;0000002572 turning_loop +highway;0000002540 steps +highway;0000002493 services +highway;0000002133 emergency_bay +highway;0000001372 residential +highway;0000001324 street_light +highway;0000001147 incline_steep +highway;0000001101 stile +highway;0000000904 incline +highway;0000000819 service +highway;0000000817 traffic_calming +highway;0000000662 path +highway;0000000603 footway +highway;0000000438 track +highway;0000000436 no +highway;0000000353 door +highway;0000000283 level_crossing +highway;0000000267 yes +highway;0000000262 road +highway;0000000244 construction +highway;0000000214 unclassified +highway;0000000213 proposed +highway;0000000197 junction +highway;0000000176 distance_marker +highway;0000000158 noexit +highway;0000000155 unknown +highway;0000000134 traffic_sign +highway;0000000123 tertiary +highway;0000000115 trailhead +highway;0000000113 priority_to_right +highway;0000000113 culvert +highway;0000000100 +highway;0000000046 toll_bridge +highway;0000000037 city_entry +highway;0000002967 traffic_mirror +highway;0000001724 priority + +barrier;0000606512 gate +barrier;0000164120 bollard +barrier;0000112184 lift_gate +barrier;0000046779 stile +barrier;0000046255 cycle_barrier +barrier;0000038597 entrance +barrier;0000027579 block +barrier;0000023074 toll_booth +barrier;0000016782 cattle_grid +barrier;0000016154 kissing_gate +barrier;0000003182 turnstile +barrier;0000003160 fence +barrier;0000002701 border_control +barrier;0000002536 sally_port +barrier;0000002504 chain +barrier;0000002470 door +barrier;0000002089 swing_gate +barrier;0000001912 bump_gate +barrier;0000001856 yes +barrier;0000001683 hampshire_gate +barrier;0000000445 wall +barrier;0000000440 bus_trap +barrier;0000000435 ditch +barrier;0000000420 debris +barrier;0000000381 log +barrier;0000000336 chicane +barrier;0000000316 kerb +barrier;0000000270 sump_buster +barrier;0000000268 obstacle +barrier;0000000223 no +barrier;0000000213 horse_stile +barrier;0000000210 full-height_turnstile +barrier;0000000176 windfall +barrier;0000000168 spikes +barrier;0000000168 checkpoint +barrier;0000000166 hedge +barrier;0000000164 footgate +barrier;0000000141 tree +barrier;0000000133 guard_rail +barrier;0000000129 bar +barrier;0000000124 fallen_tree +barrier;0000000118 jersey_barrier +barrier;0000000114 motorcycle_barrier +barrier;0000000114 barrier +barrier;0000000108 rope +barrier;0000000095 stone +barrier;0000000069 traffic_crossing_pole + + +access;0000078544 private +access;0000014933 yes public +access;0000014456 no +access;0000008670 permissive +access;0000006316 destination customers +access;0000000973 agricultural forestry +access;0000000942 designated official + +foot;0000272169 yes +foot;0000036271 no +foot;0000001118 designated official +foot;0000000960 private +foot;0000000833 permissive +foot;0000000127 destination + +bicycle;0000267569 yes +bicycle;0000067796 no +bicycle;0000004075 designated official +bicycle;0000002596 dismount +bicycle;0000000498 permissive +bicycle;0000000359 private +bicycle;0000000075 destination + +motorcar;0000038901 yes +motorcar;0000009323 no +motorcar;0000000895 private +motorcar;0000000216 destination +motorcar;0000000214 permissive +motorcar;0000000093 agricultural forestry +motorcar;0000000084 designated official + +motor_vehicle;0000000066 yes +motor_vehicle;0000000001 permissive +motor_vehicle;0000000000 designated official +motor_vehicle;0000000030 destination +motor_vehicle;0000000073 agricultural forestry +motor_vehicle;0000000136 private +motor_vehicle;0000000469 no + +motorcycle;0000028697 yes +motorcycle;0000007061 no +motorcycle;0000000243 private +motorcycle;0000000237 designated +motorcycle;0000000117 destination +motorcycle;0000000080 permissive +motorcycle;0000000029 agricultural forestry + +vehicle;0000002176 no +vehicle;0000000576 yes +vehicle;0000000556 private +vehicle;0000000262 destination +vehicle;0000000138 agricultural forestry +vehicle;0000000105 permissive +vehicle;0000000003 designated official + +horse;0000021837 yes +horse;0000010811 no +horse;0000000105 private +horse;0000000065 permissive +horse;0000000053 designated official +horse;0000000009 critical +horse;0000000007 destination + +wheelchair;0000203478 yes true +wheelchair;0000100082 no false +wheelchair;0000080430 limited +wheelchair;0000002769 designated official +wheelchair;0000000005 private +wheelchair;0000000005 permissive +wheelchair;0000000139 bad +wheelchair;0000000031 half +wheelchair;0000000005 partial + +hgv;0000001141 no +hgv;0000000359 yes true +hgv;0000000111 destination +hgv;0000000108 designated official +hgv;0000000065 private +hgv;0000000053 delivery +hgv;0000000016 agricultural forestry +hgv;0000000010 permissive + +crossing;0000251032 uncontrolled +crossing;0000200387 traffic_signals +crossing;0000029717 unmarked +crossing;0000022119 island +crossing;0000006981 zebra +crossing;0000003897 no +crossing;0000002166 yes + +railway;0000312710 level_crossing +railway;0000078746 station +railway;0000067876 buffer_stop +railway;0000056184 switch +railway;0000049600 crossing +railway;0000037871 tram_stop +railway;0000024038 halt +railway;0000014285 subway_entrance +railway;0000010890 signal + +waterway;0000004698 weir +waterway;0000001647 lock_gate +waterway;0000000425 waterfall +waterway;0000000337 take_right_side +waterway;0000000332 take_left_side +waterway;0000000219 milestone +waterway;0000000187 depth +waterway;0000000170 lock + +noexit;0000195286 yes + +entrance;0000301732 yes +entrance;0000159853 main +entrance;0000025621 staircase +entrance;0000006666 home +entrance;0000005428 service +entrance;0000001853 emergency +entrance;0000001144 exit +entrance;0000000953 residence +entrance;0000000620 garage +entrance;0000000558 entrance +entrance;0000000504 main_entrance +entrance;0000000439 secondary_entrance +entrance;0000000285 shop +entrance;0000000258 private + +traffic_calming;0000045987 bump bump;choker +traffic_calming;0000040022 hump hump;choker +traffic_calming;0000012499 table table;choker +traffic_calming;0000006808 yes * +traffic_calming;0000005754 cushion cushion;choker +traffic_calming;0000005466 choker choker;cushion choker;hump choker;table choker;bump +traffic_calming;0000005305 island +traffic_calming;0000004686 chicane +traffic_calming;0000004032 rumble_strip +traffic_calming;0000000847 speed_bump +traffic_calming;0000000186 dip + +ford;0000037927 yes +ford;0000000310 stepping_stones + +direction;0000274642 forward +direction;0000249637 backward +direction;0000021634 both + +traffic_signals:direction;0000062645 forward +traffic_signals:direction;0000033961 backward +traffic_signals:direction;0000007309 both diff --git a/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/shortest.brf b/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/shortest.brf new file mode 100644 index 0000000..a4cc7f3 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/shortest.brf @@ -0,0 +1,149 @@ +---context:global # following code refers to global config + +# the elevation parameters + +assign downhillcost 0 +assign downhillcutoff 1.5 +assign uphillcost 0 +assign uphillcutoff 1.5 + +assign turnInstructionMode = 1 # 0=none, 1=auto-choose, 2=locus-style, 3=osmand-style, 4=comment-style, 5=gpsies-style, 6=orux-style +assign validForFoot 1 + +---context:way # following code refers to way-tags + +assign any_cycleroute or route_bicycle_icn=yes or route_bicycle_ncn=yes or route_bicycle_rcn=yes route_bicycle_lcn=yes +assign nodeaccessgranted or any_cycleroute lcn=yes + +assign turncost 0 + +assign initialclassifier = + if route=ferry then 1 + else 0 + +assign initialcost switch route=ferry 10000 0 + +# +# implicit access here just from the motorroad tag +# (implicit access rules from highway tag handled elsewhere) +# +assign defaultaccess + switch access= + not motorroad=yes + switch or access=private access=no + 0 + 1 + +# +# calculate logical bike access +# +assign bikeaccess + or any_cycleroute + switch bicycle= + switch bicycle_road=yes + 1 + switch vehicle= + defaultaccess + switch or vehicle=private vehicle=no + 0 + 1 + not or bicycle=private or bicycle=no bicycle=dismount + +# +# calculate logical foot access +# +assign footaccess + or bikeaccess + or bicycle=dismount + switch foot= + defaultaccess + not or foot=private foot=no + +assign accesspenalty switch or bikeaccess footaccess 0 100000 + +assign costfactor + add accesspenalty + + switch and highway= not route=ferry 100000 + + switch route=ferry 5.67 + switch or highway=motorway highway=motorway_link 100000 + switch or highway=proposed highway=abandoned 100000 + 1 + +# way priorities used for voice hint generation + +assign isresidentialorliving = or highway=residential|living_street living_street=yes + +assign priorityclassifier = + + if ( highway=motorway ) then 30 + else if ( highway=motorway_link ) then 29 + else if ( highway=trunk ) then 28 + else if ( highway=trunk_link ) then 27 + else if ( highway=primary ) then 26 + else if ( highway=primary_link ) then 25 + else if ( highway=secondary ) then 24 + else if ( highway=secondary_link ) then 23 + else if ( highway=tertiary ) then 22 + else if ( highway=tertiary_link ) then 21 + else if ( highway=unclassified ) then 20 + else if ( isresidentialorliving ) then 6 + else if ( highway=service ) then 6 + else if ( highway=cycleway ) then 6 + else if ( or bicycle=designated bicycle_road=yes ) then 6 + else if ( highway=track ) then if tracktype=grade1 then 6 else 4 + else if ( highway=bridleway|road|path|footway ) then 4 + else if ( highway=steps ) then 2 + else if ( highway=pedestrian ) then 2 + else 0 + +# some more classifying bits used for voice hint generation... + +assign isroundabout = junction=roundabout +assign islinktype = highway=motorway_link|trunk_link|primary_link|secondary_link|tertiary_link +assign isgoodforcars = if greater priorityclassifier 6 then true + else if ( or isresidentialorliving highway=service ) then true + else if ( and highway=track tracktype=grade1 ) then true + else false + + +# ... encoded into a bitmask + +assign classifiermask add multiply isroundabout 4 + add multiply islinktype 8 + multiply isgoodforcars 16 + +# include `smoothness=` tags in the response's WayTags for track analysis +assign dummyUsage = smoothness= + +---context:node # following code refers to node tags + +assign defaultaccess + switch access= + 1 # add default barrier restrictions here! + switch or access=private access=no + 0 + 1 + +assign bikeaccess + or nodeaccessgranted=yes + switch bicycle= + switch vehicle= + defaultaccess + switch or vehicle=private vehicle=no + 0 + 1 + switch or bicycle=private or bicycle=no bicycle=dismount + 0 + 1 + +assign footaccess + or bicycle=dismount + switch foot= + defaultaccess + switch or foot=private foot=no + 0 + 1 + +assign initialcost switch or bikeaccess footaccess 0 1000000 diff --git a/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/trekking.brf b/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/trekking.brf new file mode 100644 index 0000000..8a4b94d --- /dev/null +++ b/src-tauri/gen/android/app/src/main/assets/brouter/profiles2/trekking.brf @@ -0,0 +1,330 @@ +# *** The trekking profile is for slow travel +# *** and avoiding car traffic, but still with +# *** a focus on approaching your destination +# *** efficiently. + +---context:global # following code refers to global config + +# Bike profile +assign validForBikes = true + +# Use the following switches to change behaviour +assign allow_steps = true # %allow_steps% | Set to false to disallow steps | boolean +assign allow_ferries = true # %allow_ferries% | set to false to disallow ferries | boolean +assign ignore_cycleroutes = false # %ignore_cycleroutes% | Set to true for better elevation results | boolean +assign stick_to_cycleroutes = false # %stick_to_cycleroutes% | Set to true to just follow cycleroutes | boolean +assign avoid_unsafe = false # %avoid_unsafe% | Set to true to avoid standard highways | boolean +assign considerTurnRestrictions = true # %considerTurnRestrictions% | Set to true to take turn restrictions into account | boolean +assign processUnusedTags = false # %processUnusedTags% | Set to true to output unused tags in data tab | boolean + +# Change elevation parameters +assign consider_elevation = true # %consider_elevation% | Set to false to ignore elevation in routing | boolean +assign downhillcost = 60 # %downhillcost% | Cost for going downhill | number +assign downhillcutoff = 1.5 # %downhillcutoff% | Gradients below this value in percents are not counted. | number +assign uphillcost = 0 # %uphillcost% | Cost for going uphill | number +assign uphillcutoff = 1.5 # %uphillcutoff% | Gradients below this value in percents are not counted. | number + +assign downhillcost = if consider_elevation then downhillcost else 0 +assign uphillcost = if consider_elevation then uphillcost else 0 + +# Kinematic model parameters (travel time computation) +assign totalMass = 90 # %totalMass% | Mass (in kg) of the bike + biker, for travel time computation | number +assign maxSpeed = 45 # %maxSpeed% | Absolute maximum speed (in km/h), for travel time computation | number +assign S_C_x = 0.225 # %S_C_x% | Drag coefficient times the reference area (in m^2), for travel time computation | number +assign C_r = 0.01 # %C_r% | Rolling resistance coefficient (dimensionless), for travel time computation | number +assign bikerPower = 100 # %bikerPower% | Average power (in W) provided by the biker, for travel time computation | number + +# Turn instructions settings +assign turnInstructionMode = 1 # %turnInstructionMode% | Mode for the generated turn instructions | [0=none, 1=auto-choose, 2=locus-style, 3=osmand-style, 4=comment-style, 5=gpsies-style, 6=orux-style] +assign turnInstructionCatchingRange = 40 # %turnInstructionCatchingRange% | Within this distance (in m) several turning instructions are combined into one and the turning angles are better approximated to the general direction | number +assign turnInstructionRoundabouts = true # %turnInstructionRoundabouts% | Set to "false" to avoid generating special turning instructions for roundabouts | boolean + + +---context:way # following code refers to way-tags + +# classifier constants +assign classifier_none = 1 +assign classifier_ferry = 2 + +# +# pre-calculate some logical expressions +# + +assign any_cycleroute = + if route_bicycle_icn=yes then true + else if route_bicycle_ncn=yes then true + else if route_bicycle_rcn=yes then true + else if route_bicycle_lcn=yes then true + else false + +assign nodeaccessgranted = + if any_cycleroute then true + else lcn=yes + +assign is_ldcr = + if ignore_cycleroutes then false + else any_cycleroute + +assign isbike = or bicycle_road=yes or bicycle=yes or or bicycle=permissive bicycle=designated lcn=yes +assign ispaved = surface=paved|asphalt|concrete|paving_stones|sett +assign isunpaved = not or surface= or ispaved surface=fine_gravel|cobblestone +assign probablyGood = or ispaved and ( or isbike highway=footway ) not isunpaved + + +# +# this is the cost (in Meter) for a 90-degree turn +# The actual cost is calculated as turncost*cos(angle) +# (Suppressing turncost while following longdistance-cycleways +# makes them a little bit more magnetic) +# +assign turncost = if is_ldcr then 0 + else if junction=roundabout then 0 + else 90 + + +# +# for any change in initialclassifier, initialcost is added once +# +assign initialclassifier = + if route=ferry then classifier_ferry + else classifier_none + + +# +# calculate the initial cost +# this is added to the total cost each time the costfactor +# changed +# +assign initialcost = + if ( equal initialclassifier classifier_ferry ) then 10000 + else 0 + +# +# implicit access here just from the motorroad tag +# (implicit access rules from highway tag handled elsewhere) +# +assign defaultaccess = + if access= then not motorroad=yes + else if access=private|no then false + else true + +# +# calculate logical bike access +# +assign bikeaccess = + if any_cycleroute then true + else if bicycle= then + ( + if bicycle_road=yes then true + else if vehicle= then ( if highway=footway then false else defaultaccess ) + else not vehicle=private|no + ) + else not bicycle=private|no|dismount|use_sidepath + +# +# calculate logical foot access +# +assign footaccess = + if bikeaccess then true + else if bicycle=dismount then true + else if foot= then defaultaccess + else not foot=private|no + +# +# if not bike-, but foot-acess, just a moderate penalty, +# otherwise access is forbidden +# +assign accesspenalty = + if bikeaccess then 0 + else if footaccess then 4 + else 10000 + +# +# handle one-ways. On primary roads, wrong-oneways should +# be close to forbidden, while on other ways we just add +# 4 to the costfactor (making it at least 5 - you are allowed +# to push your bike) +# +assign badoneway = + if reversedirection=yes then + if oneway:bicycle=yes then true + else if oneway= then junction=roundabout + else oneway=yes|true|1 + else oneway=-1 + +assign onewaypenalty = + if ( badoneway ) then + ( + if ( cycleway=opposite|opposite_lane|opposite_track ) then 0 + else if ( oneway:bicycle=no ) then 0 + else if ( highway=primary|primary_link ) then 50 + else if ( highway=secondary|secondary_link ) then 30 + else if ( highway=tertiary|tertiary_link ) then 20 + else 4.0 + ) + else 0.0 + +# +# calculate the cost-factor, which is the factor +# by which the distance of a way-segment is multiplied +# to calculate the cost of that segment. The costfactor +# must be >=1 and it's supposed to be close to 1 for +# the type of way the routing profile is searching for +# +assign isresidentialorliving = or highway=residential|living_street living_street=yes +assign costfactor + + # + # exclude rivers, rails etc. + # + if ( and highway= not route=ferry ) then 10000 + + # + # exclude motorways and proposed roads + # + else if ( highway=motorway|motorway_link ) then 10000 + else if ( highway=proposed|abandoned ) then 10000 + + # + # all other exclusions below (access, steps, ferries,..) + # should not be deleted by the decoder, to be available + # in voice-hint-processing + # + else min 9999 + + # + # apply oneway-and access-penalties + # + add max onewaypenalty accesspenalty + + # + # steps and ferries are special. Note this is handled + # before the cycleroute-switch, to be able + # to really exlude them be setting cost to infinity + # + if ( highway=steps ) then ( if allow_steps then 40 else 10000 ) + else if ( route=ferry ) then ( if allow_ferries then 5.67 else 10000 ) + + # + # handle long-distance cycle-routes. + # + else if ( is_ldcr ) then 1 # always treated as perfect (=1) + else + add ( if stick_to_cycleroutes then 0.5 else 0.05 ) # everything else somewhat up + + # + # some other highway types + # + if ( highway=pedestrian ) then 3 + else if ( highway=bridleway ) then 5 + else if ( highway=cycleway ) then 1 + else if ( isresidentialorliving ) then ( if isunpaved then 1.5 else 1.1 ) + else if ( highway=service ) then ( if isunpaved then 1.6 else 1.3 ) + + # + # tracks and track-like ways are rated mainly be tracktype/grade + # But note that if no tracktype is given (mainly for road/path/footway) + # it can be o.k. if there's any other hint for quality + # + else if ( highway=track|road|path|footway ) then + ( + if ( tracktype=grade1 ) then ( if probablyGood then 1.0 else 1.3 ) + else if ( tracktype=grade2 ) then ( if probablyGood then 1.1 else 2.0 ) + else if ( tracktype=grade3 ) then ( if probablyGood then 1.5 else 3.0 ) + else if ( tracktype=grade4 ) then ( if probablyGood then 2.0 else 5.0 ) + else if ( tracktype=grade5 ) then ( if probablyGood then 3.0 else 5.0 ) + else ( if probablyGood then 1.0 else 5.0 ) + ) + + # + # When avoiding unsafe ways, avoid highways without a bike hint + # + else add ( if ( and avoid_unsafe not isbike ) then 2 else 0 ) + + # + # actuals roads are o.k. if we have a bike hint + # + if ( highway=trunk|trunk_link ) then ( if isbike then 1.5 else 10 ) + else if ( highway=primary|primary_link ) then ( if isbike then 1.2 else 3 ) + else if ( highway=secondary|secondary_link ) then ( if isbike then 1.1 else 1.6 ) + else if ( highway=tertiary|tertiary_link ) then ( if isbike then 1.0 else 1.4 ) + else if ( highway=unclassified ) then ( if isbike then 1.0 else 1.3 ) + + # + # default for any other highway type not handled above + # + else 2.0 + + +# way priorities used for voice hint generation + +assign priorityclassifier = + + if ( highway=motorway ) then 30 + else if ( highway=motorway_link ) then 29 + else if ( highway=trunk ) then 28 + else if ( highway=trunk_link ) then 27 + else if ( highway=primary ) then 26 + else if ( highway=primary_link ) then 25 + else if ( highway=secondary ) then 24 + else if ( highway=secondary_link ) then 23 + else if ( highway=tertiary ) then 22 + else if ( highway=tertiary_link ) then 21 + else if ( highway=unclassified ) then 20 + else if ( isresidentialorliving ) then 6 + else if ( highway=service ) then 6 + else if ( highway=cycleway ) then 6 + else if ( or bicycle=designated bicycle_road=yes ) then 6 + else if ( highway=track ) then if tracktype=grade1 then 6 else 4 + else if ( highway=bridleway|road|path|footway ) then 4 + else if ( highway=steps ) then 2 + else if ( highway=pedestrian ) then 2 + else 0 + +# some more classifying bits used for voice hint generation... + +assign isbadoneway = not equal onewaypenalty 0 +assign isgoodoneway = if reversedirection=yes then oneway=-1 + else if oneway= then junction=roundabout else oneway=yes|true|1 +assign isroundabout = junction=roundabout +assign islinktype = highway=motorway_link|trunk_link|primary_link|secondary_link|tertiary_link +assign isgoodforcars = if greater priorityclassifier 6 then true + else if ( or isresidentialorliving highway=service ) then true + else if ( and highway=track tracktype=grade1 ) then true + else false + +# ... encoded into a bitmask + +assign classifiermask add isbadoneway + add multiply isgoodoneway 2 + add multiply isroundabout 4 + add multiply islinktype 8 + multiply isgoodforcars 16 + +# include `smoothness=` tags in the response's WayTags for track analysis +assign dummyUsage = smoothness= + +---context:node # following code refers to node tags + +assign defaultaccess = + if ( access= ) then true # add default barrier restrictions here! + else if ( access=private|no ) then false + else true + +assign bikeaccess = + if nodeaccessgranted=yes then true + else if bicycle= then + ( + if vehicle= then defaultaccess + else not vehicle=private|no + ) + else not bicycle=private|no|dismount + +assign footaccess = + if bicycle=dismount then true + else if foot= then defaultaccess + else not foot=private|no + +assign initialcost = + if bikeaccess then 0 + else ( if footaccess then 100 else 1000000 ) diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt index 987b07e..eadaf89 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt @@ -460,7 +460,10 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener { pointCount++ val bearing = if (location.hasBearing()) location.bearing else -1f - val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}""" + // No altitude fix -> write null, not 0.0: Android returns 0.0 when hasAltitude() + // is false, and a fake 0 corrupts elevation stats. A real fixed 0 is kept. + val altStr = if (location.hasAltitude()) location.altitude.toString() else "null" + val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${altStr},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}""" synchronized(bufferLock) { pointBuffer.add(line) } diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index fb0a005..0469e4f 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -104,10 +104,26 @@ class MainActivity : TauriActivity() { if (!cmdFile.exists()) cmdFile.createNewFile() val bleCmdFile = File(appDataDir, "ble_command.txt") if (!bleCmdFile.exists()) bleCmdFile.createNewFile() + val brouterCmdFile = File(appDataDir, "brouter_command.txt") + if (!brouterCmdFile.exists()) brouterCmdFile.createNewFile() + + // Copy BRouter routing profiles (+ lookups.dat) from assets to files/brouter/profiles2. + // The embedded engine (btools.router) reads them by path alongside the segments. + try { + val profDir = File(filesDir, "brouter/profiles2") + profDir.mkdirs() + for (f in (assets.list("brouter/profiles2") ?: arrayOf())) { + assets.open("brouter/profiles2/$f").use { i -> File(profDir, f).outputStream().use { o -> i.copyTo(o) } } + } + Log.d("BalDav", "BRouter profiles copied to ${profDir.absolutePath}") + } catch (e: Exception) { + Log.e("BalDav", "Failed to copy BRouter profiles", e) + } setupCommandWatcher(appDataDir) processGpsCommand(appDataDir) processBleCommand(appDataDir) + processBrouterCommand(appDataDir) Log.d("BalDav", "Command watcher started on ${appDataDir.absolutePath}") // Diagnostic HTTP endpoint on localhost — GET /gps makes the app request a live @@ -161,6 +177,8 @@ class MainActivity : TauriActivity() { processGpsCommand(appDataDir) processBleCommand(appDataDir) } + } else if (path == "brouter_command.txt") { + runOnUiThread { processBrouterCommand(appDataDir) } } } } @@ -168,6 +186,69 @@ class MainActivity : TauriActivity() { Log.d("BalDav", "FileObserver started on ${appDataDir.absolutePath}") } + // Offline routing via the embedded BRouter engine (btools.router). Request/response + // over files like the GPS bridge: Rust writes brouter_request.json + brouter_command.txt + // ("route:"), we route on a background thread (routing is heavy — never the UI + // thread) and write brouter_result_.json ({ok, gpx} | {ok:false, error}). + private fun processBrouterCommand(appDataDir: File) { + val cmdFile = File(appDataDir, "brouter_command.txt") + if (!cmdFile.exists()) return + val cmd = cmdFile.readText().trim() + if (cmd.isEmpty()) return + cmdFile.writeText("") + if (!cmd.startsWith("route:")) return + val id = cmd.substringAfter("route:") + Thread { + val resultFile = File(appDataDir, "brouter_result_$id.json") + val out = org.json.JSONObject() + try { + val req = org.json.JSONObject(File(appDataDir, "brouter_request.json").readText()) + val profile = req.getString("profile") + val arr = req.getJSONArray("lonlats") + val pts = ArrayList() + for (i in 0 until arr.length()) { + val pair = arr.getJSONArray(i) + pts.add(doubleArrayOf(pair.getDouble(0), pair.getDouble(1))) + } + val profilePath = File(filesDir, "brouter/profiles2/$profile.brf").absolutePath + val segmentsDir = File(filesDir, "brouter/segments4") + val gpx = routeBrouter(segmentsDir, profilePath, pts) + out.put("ok", true) + out.put("gpx", gpx) + } catch (e: Exception) { + Log.e("BalDav", "BRouter route failed", e) + out.put("ok", false) + out.put("error", e.message ?: e.toString()) + } + try { resultFile.writeText(out.toString()) } catch (e: Exception) { Log.e("BalDav", "brouter result write failed", e) } + }.start() + } + + // Minimal embedding recipe (mirrors btools.server.request.ServerHandler): build a + // 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): String { + val rc = btools.router.RoutingContext() + rc.memoryclass = 128 + rc.localFunction = profilePath + rc.setAlternativeIdx(0) + val wps = ArrayList() + for (p in pts) { + val n = btools.router.OsmNodeNamed() + n.ilon = ((p[0] + 180.0) * 1000000.0 + 0.5).toInt() + n.ilat = ((p[1] + 90.0) * 1000000.0 + 0.5).toInt() + n.name = "wp" + wps.add(n) + } + val re = btools.router.RoutingEngine(null, null, segmentsDir, wps, rc) + re.doRun(0) + val err = re.getErrorMessage() + if (err != null && err.isNotEmpty()) throw RuntimeException(err) + val track = re.getFoundTrack() ?: throw RuntimeException("no track found") + return track.formatAsGpx() + } + private fun processGpsCommand(appDataDir: File) { val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) return diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84a9229..c7a6db0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -393,6 +393,48 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result" command where MainActivity's +// FileObserver watches, then poll for brouter_result_.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 { + 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(); + let req = format!( + "{{\"profile\":{},\"lonlats\":{}}}", + serde_json::to_string(&profile).map_err(|e| e.to_string())?, + lonlats + ); + let result_name = format!("brouter_result_{}.json", id); + // Write request + command to app_data_dir AND app root (where Kotlin watches). + 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("brouter_request.json", &req); + write_both("brouter_command.txt", &format!("route:{}", id)); + // Poll for the result (routing can take a few seconds; cap ~60s). + let mut candidates = vec![data_dir.join(&result_name)]; + if let Some(r) = &root { candidates.push(r.join(&result_name)); } + for _ in 0..600 { + for p in &candidates { + if p.exists() { + if let Ok(s) = std::fs::read_to_string(p) { + let _ = std::fs::remove_file(p); + return Ok(s); + } + } + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err("brouter: timeout waiting for result".into()) +} + // Read GPS points from service's JSONL file #[tauri::command] fn gps_service_read_points(app: tauri::AppHandle) -> Result { @@ -904,7 +946,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, 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_app_info, gps_service_command, gps_service_read_points, brouter_route, 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]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }