Nogo areas: routing avoids impassable spots (planner + engine + RouteServer)

Stage 1 of the nogo/reroute work. Mark impassable circles that BRouter routes
around, end to end:
- DB nogo_areas table + CRUD; drawNogoAreas() red circles (tap to remove).
- BRouter.route(points, profile, nogos): &nogos=lon,lat,r on the RouteServer and
  nogos in the embedded-engine request; Rust brouter_route passes it through;
  Kotlin builds OsmNodeNamed(radius,isNogo,nogoWeight=NaN) -> rc.nogopoints +
  prepareNogoPoints (needs an APK rebuild for offline use).
- Planner: ⛔ button toggles nogo-placement (tap map/line), each leg re-routes
  around nogos. Recording map: ⛔ marks the current spot impassable. Detail map
  shows the circles.
Verified the RouteServer detours around a nogo (2873m -> 3329m).
This commit is contained in:
4aiman 2026-09-12 22:55:51 +03:00
commit 1c326ab19c
9 changed files with 310 additions and 16 deletions

View file

@ -22,12 +22,12 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап карты=точка в конец, тап линии=вставка в сегмент, тащить=двигать, тап точки=удалить; `makeMarker` возвращает маркер для push/splice). `appConfirm`/`appPrompt`/`appAlert` — in-app диалоги (нативных `alert/prompt/confirm` не осталось; `<select>` оставлены). `orient-dbg` (сенсор-оверлей на карте) — только при `window.__BALDAV_DEBUG`. `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled` |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints`/`addRoute(...,waypoints)` — плотная геометрия + `waypoints_json` {waypoints,legMode} для правки планировщиком, `getRoutes.hasEle/.wpts`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера. `saveGapFill`/`revertGapFill` — обратимый гэп-филл: вставка достроенных точек с `estimated=1` (seq = seq предыдущей реальной точки) / удаление их; колонки `gps_points.estimated`, `gps_sessions.gap_filled`. Nogo: таблица `nogo_areas` (lat/lon/radius_m) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea` |
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `fillGaps(points)` → `{points, gaps}` — дорожная достройка GPS-дропаутов (BRouter shortest) с интерполяцией ts и флагом `estimated`; `gaps[].status` = filled/skipped-long/skipped-noroute для UI-предпросмотра. `config.gpxzKey` — ключ GPXZ (localStorage) |
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
| `js/router.js` | ~80 | Оффлайн-роутинг BRouter. `BRouter.route(points,[profile])`: Android → встроенный движок (`invoke("brouter_route")`), иначе/при ошибке → PC-RouteServer по HTTP (`http_request`, мимо CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Профили `trekking` (планирование) / `shortest` (гэпы). Докачка сегментов `.rd5`: `downloadTile/downloadStatus/listTiles/downloadTileAndWait` |
| `js/router.js` | ~80 | Оффлайн-роутинг BRouter. `BRouter.route(points,[profile])`: Android → встроенный движок (`invoke("brouter_route")`), иначе/при ошибке → PC-RouteServer по HTTP (`http_request`, мимо CSP; LAN 192.168.1.18:17777 / WAN mirror.invalid:17777). Профили `trekking` (планирование) / `shortest` (гэпы). `BRouter.route(points, profile, nogos)` — nogos `[{lat,lon,radius}]` → `&nogos=lon,lat,r` в RouteServer и в запрос встроенного движка (Kotlin `rc.nogopoints`). Докачка сегментов `.rd5`: `downloadTile/downloadStatus/listTiles/downloadTileAndWait` |
| `js/poi.js` | ~200 | POI-оверлей на картах: круглые контрастные бейджи (эмодзи в круге, не пины), фильтр категорий (`POI.attach(map)`, данные `data/pois.json`, localStorage `baldav_poi_cats/shown`) |
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
| `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим |

View file

@ -20,6 +20,7 @@
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, **тап по линии — вставить точку** (широкая зона попадания), долгий тап по линии — переключить отрезок дорога/прямая, тащить точку — двигать, тап по точке — удалить. Роутинг **по-сегментный**: каждый отрезок между опорными точками отдельно либо по дорогам (BRouter, профиль trekking, высоты из BRouter), либо прямой; по дорогам по умолчанию, где дороги/сегментов нет — молча прямая; кнопка **«🚲 По дорогам»** задаёт режим сразу всем. Маршрут в редакторе рисуется **оковкой с раскраской уклон/высота** как в детали; тумблеры день/ночь, уклон/высота и кнопка «моё место» на карте. Сохраняются и **опорные точки** (5-6, для правки — при повторном открытии столько же), и плотная дорожная геометрия с высотами (для показа и профиля уклона).
- **Оффлайн-роутинг (BRouter)** — движок BRouter встроен в приложение (Android): маршруты строятся по дорогам без сети, сегменты `.rd5` региона качаются с домашнего зеркала прямо в приложении (экран дорог → «Сегменты»). Используется в редакторе маршрутов («По дорогам») и для заполнения GPS-пропусков. На десктопе/дома движка нет — те же вызовы уходят на домашний BRouter-RouteServer по HTTP.
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами.
- **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.

View file

@ -0,0 +1,109 @@
# План: непроезжие места (nogo) + объезд с возвратом на маршрут на ходу
## Context (зачем)
Пользователь поехал по маршруту, импортированному из web-BRouter, и в одном месте дороги по
факту не оказалось. Отсюда две связанные потребности:
1. **Помечать непроезжие места** так, чтобы роутинг их ОБЪЕЗЖАЛ. В web-BRouter это уже есть
(BR.Nogo, `nogos=`) — там ничего не строим. В BalDav nogo-роутинга НЕТ: `BRouter.route`
не принимает nogo ни для RouteServer, ни для встроенного движка. Решение — полное избегание
(согласовано).
2. **Объезд на ходу с возвратом на маршрут.** Активный маршрут ведёт через непроезжее место.
Нужно от точки затыка отступить ~1-2 км ВПЕРЁД по сохранённому маршруту и построить объезд
туда от текущей позиции (в обход непроезжего места); не вышло — отступать дальше. Сохранённый
маршрут НЕ менять — только показать навигацию, как вернуться на траекторию без больших потерь
и разворотов на 180° (если возможно). Результат — навигационный оверлей поверх карты записи.
Итог решений: nogo — полное избегание (RouteServer + встроенный движок, Kotlin + пересборка);
цель объезда — точка НА сохранённом маршруте за местом затыка; результат — навигация, без
сохранения и без правки маршрута.
## Часть A — Nogo (непроезжие места) с полным избеганием
### A1. БД (`frontend/js/db.js`)
- Таблица `CREATE TABLE IF NOT EXISTS nogo_areas (id, lat, lon, radius_m, note, created_at)`
(по образцу `goal_markers`, db.js:1628). CRUD: `getNogoAreas`/`addNogoArea(lat,lon,radius,note)`/
`deleteNogoArea(id)`; экспорт в `window.DB`.
### A2. Роутинг с nogo (`frontend/js/router.js`)
- `BRouter.route(points, profile, nogos)` — `nogos` = `[{lat,lon,radius}]`.
- RouteServer-ветка: добавить `&nogos=lon,lat,radius|...` в URL (`_routeServer(pts, profile, nogos)`).
- Встроенная: класть `nogos` в `brouter_request.json` (invoke `brouter_route`).
- Все текущие вызовы `route(pts, profile)` продолжают работать (nogos по умолчанию — из
`DB.getNogoAreas()`, подтягивать в вызывающих местах: планировщик, достройка, объезд).
### A3. Встроенный движок (`.../MainActivity.kt`) + пересборка APK
- В `routeCommand`/`routeBrouter` прочитать `nogos` из запроса; на каждый — `OsmNodeNamed`
(ilon/ilat в микроградусах, `radius` в метрах, `isNogo=true`), собрать в список и передать
в `rc.nogopoints` + `rc.prepareNogoPoints(...)` ДО `RoutingEngine`. Зеркалить `btools.server.
request.ServerHandler` (парсинг nogos там же в jar). Пересобрать: `./build.sh android -d -i`.
### A4. UI nogo (`frontend/js/main.js`, `styles.css`)
- Показ: красные полупрозрачные круги (`L.circle`) на картах планировщика, записи и детали
трека — новый хелпер `drawNogoAreas(map)` (по аналогии с `POI.attach`/`drawActiveRouteOverlay`).
- Добавление:
- Карта записи: кнопка в стопке (`rec-map-btn`) «нет проезда» — ставит nogo в `lastMovingPoint`
(радиус по умолчанию, напр. 25 м; настраиваемый позже).
- Планировщик: режим «тап — поставить nogo» (переключатель), тап ставит круг.
- Удаление: тап по кругу → подтверждение → `deleteNogoArea`.
- Планировщик (`showRouteEditor`, `routeDirty`): передавать nogos в каждый вызов `BRouter.route`,
чтобы построенные ноги обходили непроезжее. Показывать круги на карте редактора.
## Часть B — Объезд с возвратом на маршрут на ходу (карта записи)
### B1. Алгоритм `rejoinReroute(cur, bearing, routePts, nogos)` (main.js)
Возвращает связующий путь от текущей позиции до точки на маршруте за затыком.
- Опорная точка затыка `ref`: ближайший к текущей позиции nogo, попавший на маршрут (в пределах
~150 м от линии); если nogo нет — проекция текущей позиции на маршрут.
- `refIdx` — индекс на `routePts`, ближайший к `ref`; накопленная дистанция `cum[]` вдоль маршрута.
- Скип `skip` от 1500 м, шаг +1500 м до конца маршрута (несколько попыток):
- `target` = точка маршрута на дистанции `cum[refIdx] + skip` (ВПЕРЁД, к концу маршрута).
- вейпоинты: `[cur, target]`, а «без разворота» — при наличии курса добавлять помощник в
~30-50 м по `bearing` первым (`[cur, helper, target]`), чтобы старт шёл вперёд.
- `gpx = BRouter.route(wps, "trekking", nogos)`; принять, если проложился и длина разумна
(< ~3× прямой до target). Иначе увеличить `skip`.
- Курс `bearing`: из `GPS.recording` (последние точки / `smoothedHeading` компаса).
### B2. UI объезда (карта записи, `showRecordScreen` ~3956+)
- Кнопка в стопке `rec-map-btn` «объехать» (доступна при активном маршруте). По тапу:
запускает `rejoinReroute`, рисует связующий путь навигационным оверлеем (отдельный слой,
ярко, поверх; НЕ трогает `baldav_active_route` и запись). Точку возврата пометить.
- Если объезд не найден даже у конца маршрута — тост «объезд не найден, отступаю дальше не вышло».
- Оверлей держать до ручного сброса/новой перестройки; запись трека не прерывается.
## Файлы
- `frontend/js/db.js` — таблица `nogo_areas` + CRUD.
- `frontend/js/router.js` — `route(...,nogos)`, `_routeServer(...,nogos)`.
- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt` — nogo в движке.
- `frontend/js/main.js` — `drawNogoAreas`, кнопки nogo (запись/планировщик), передача nogos в
роутинг, `rejoinReroute` + кнопка «объехать» на карте записи.
- `frontend/css/styles.css` — стиль круга nogo / панели.
## Переиспользование (не изобретать)
- `goal_markers` CRUD (db.js:1059) — образец для `nogo_areas`.
- `drawActiveRouteOverlay`/`drawRoutePolyline` (main.js:3163/3196) — образец слоя-оверлея.
- `BRouter.route` (router.js), `GPS.distance`, `GPS.recording.lastMovingPoint`, компас
(`smoothedHeading` в `showRecordScreen`).
- Kotlin `routeBrouter` (MainActivity.kt:323) — точка вставки nogopoints; зеркалить
`btools.server.request.ServerHandler` из jar.
- `POI.attach` (poi.js) — образец «слой + добавление/удаление на карте».
## Верификация
Десктоп (Xvfb :99 + домашний RouteServer):
1. Nogo: поставить круг на дороге в планировщике → нога маршрута обходит его (сравнить с/без).
2. `rejoinReroute` в изоляции: задать активный маршрут, «текущую позицию» рядом с затыком и
nogo на маршруте → построенный объезд ведёт к точке на маршруте за nogo, не через nogo;
при неудаче отступает дальше. Скрин.
Устройство (реальная поездка / симуляция):
3. `./build.sh android -d -i` (нужно для nogo во встроенном движке офлайн). На теле: пометить
«нет проезда», «объехать» → навигационный оверлей к точке возврата; сохранённый маршрут и
запись не меняются.
Регрессии: планировщик и достройка (используют `BRouter.route`) работают и без nogo; экран
записи и его кнопки целы.
## Стадии (инкрементально, коммит на стадию)
1. A1+A2+A3 (nogo сквозь весь роутинг) + A4-показ/добавление → планировщик обходит nogo.
2. B (объезд с возвратом) поверх готового nogo-роутинга.
## Не трогаем
- Web-BRouter (nogo там уже есть).
- Сохранённые маршруты не меняем при объезде; запись трека не прерываем.

View file

@ -0,0 +1,33 @@
# Сессия 2026-09-12 — непроезжие места (nogo) + объезд на ходу
## Контекст
После реальной поездки по маршруту из web-BRouter в одном месте дороги не оказалось. Две идеи
(план `docs/plans/2026-09-12-nogo-reroute.md`, согласовано):
1. Помечать непроезжие места так, чтобы роутинг их ОБЪЕЗЖАЛ (полное избегание). В web-BRouter
nogo уже есть — там ничего не строим.
2. На ходу строить объезд с ВОЗВРАТОМ на сохранённый маршрут за местом затыка (отступ ~1-2 км
вперёд, растёт при неудаче), не меняя сохранённый маршрут; показывать навигацией, без
разворотов на 180 если можно.
Разведка: web-бандл уже с nogo (BR.Nogo, `nogos=`). В BalDav nogo-роутинга не было. API
встроенного движка (javap): `RoutingContext.nogopoints` + static `prepareNogoPoints(list)`,
`OsmNodeNamed.radius/isNogo/nogoWeight`.
## Сделано — Стадия 1 (nogo сквозь роутинг + показ/добавление)
- БД: таблица `nogo_areas` (lat/lon/radius_m/note) + `getNogoAreas`/`addNogoArea`/`deleteNogoArea`.
- `router.js`: `BRouter.route(points, profile, nogos)` → `&nogos=lon,lat,r` в RouteServer и
`nogos` в запрос встроенного движка.
- Rust `brouter_route`: параметр `nogos` (Option<String>) → в `brouter_request.json`.
- Kotlin `routeBrouter`: парсит nogos → `OsmNodeNamed`(radius, isNogo, nogoWeight=NaN) →
`prepareNogoPoints` + `rc.nogopoints`. Нужна пересборка APK для офлайн-движка.
- UI: хелпер `drawNogoAreas` (красные круги, тап — убрать). Планировщик: кнопка ⛔ (режим),
тап по карте/линии ставит nogo, каждая нога роутится с nogos; круги показываются. Карта
записи: кнопка ⛔ «здесь нет проезда» (ставит в текущей позиции) + показ. Деталь трека: показ.
## Проверка
- Синтаксис db/router/main OK. RouteServer с `&nogos`: маршрут 2873м → 3329м (объезжает круг 300м).
- ДЕСКТОП-ТЕСТ планировщика (nogo обходится визуально) — следующий шаг.
- Встроенный движок (офлайн) — после `./build.sh android -d -i`, на теле.
## Дальше
- Стадия 2: объезд с возвратом на маршрут на карте записи (`rejoinReroute` + кнопка «объехать»).

View file

@ -1072,6 +1072,27 @@ async function deleteGoalMarker(id) {
await execute("DELETE FROM goal_markers WHERE id = ?1", [id]);
}
// ---------------------------------------------------------------------------
// Nogo areas — impassable spots the router must avoid (circle: lat/lon + radius)
// ---------------------------------------------------------------------------
async function getNogoAreas() {
return select("SELECT * FROM nogo_areas ORDER BY created_at DESC");
}
async function addNogoArea(lat, lon, radiusM, note) {
await execute(
"INSERT INTO nogo_areas (lat, lon, radius_m, note) VALUES (?1, ?2, ?3, ?4)",
[lat, lon, radiusM ?? 25, note ?? null]
);
let rows = await select("SELECT CAST(id AS TEXT) AS id FROM nogo_areas ORDER BY id DESC LIMIT 1");
return rows.length ? rows[0].id : null;
}
async function deleteNogoArea(id) {
await execute("DELETE FROM nogo_areas WHERE id = ?1", [id]);
}
// ---------------------------------------------------------------------------
// Saved routes (hand-drawn reference paths overlaid on the map)
// ---------------------------------------------------------------------------
@ -1288,6 +1309,9 @@ window.DB.getBikeMaintenanceDue = getBikeMaintenanceDue;
window.DB.getGoalMarkers = getGoalMarkers;
window.DB.addGoalMarker = addGoalMarker;
window.DB.deleteGoalMarker = deleteGoalMarker;
window.DB.getNogoAreas = getNogoAreas;
window.DB.addNogoArea = addNogoArea;
window.DB.deleteNogoArea = deleteNogoArea;
window.DB.getRoutes = getRoutes;
window.DB.getRoute = getRoute;
window.DB.addRoute = addRoute;
@ -1634,6 +1658,16 @@ async function runMigrations() {
created_at TEXT DEFAULT (datetime('now'))
)`);
// --- Nogo areas (impassable spots the router avoids) ---
await execute(`CREATE TABLE IF NOT EXISTS nogo_areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lon REAL NOT NULL,
radius_m REAL DEFAULT 25,
note TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`);
// --- Saved routes (hand-drawn reference paths overlaid on the map) ---
await execute(`CREATE TABLE IF NOT EXISTS routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View file

@ -3169,6 +3169,33 @@ async function drawActiveRouteOverlay(map, prev) {
return drawRoutePolyline(map, route.points, route.color);
}
// Draw the impassable "nogo" circles (DB.nogo_areas) as red translucent circles. Tapping a
// circle offers to remove it; onChange() (if given) fires after a removal so the caller can
// re-fetch nogos and re-route. Returns the layer group (removed via the `prev` arg on redraw).
async function drawNogoAreas(map, prev, onChange) {
if (prev) { try { map.removeLayer(prev); } catch {} }
let areas = await DB.getNogoAreas();
let group = L.layerGroup();
for (let a of areas) {
let c = L.circle([a.lat, a.lon], { radius: a.radius_m || 25, color: "#e53935", weight: 2, fillColor: "#e53935", fillOpacity: 0.22 });
((area) => c.on("click", async (e) => {
L.DomEvent.stop(e);
if (await appConfirm("Убрать пометку «нет проезда»?", "Убрать")) {
await DB.deleteNogoArea(area.id);
if (onChange) onChange();
}
}))(a);
c.addTo(group);
}
group.addTo(map);
return group;
}
// nogos for the router: [{lat,lon,radius}] from the nogo_areas table.
async function getNogosForRouter() {
return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 }));
}
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
// kept — one colour per segment): green -> yellow -> red -> brown -> black.
// <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude.
@ -3505,6 +3532,9 @@ async function showRouteEditor(container, routeId) {
let defaultMode = "road";
let routeTimer = null;
let dirty = new Set();
let nogos = []; // [{lat,lon,radius}] impassable circles the route must avoid
let nogoLayer = null; // red circles overlay
let nogoMode = false; // when on, a map tap drops a nogo instead of a waypoint
container.innerHTML = `
<div class="draw-screen">
@ -3582,7 +3612,7 @@ async function showRouteEditor(container, routeId) {
let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]),
{ color: "#000", weight: 26, opacity: 0, interactive: true, className: "re-hit" }).addTo(map);
(function (idx) {
line.on("click", (e) => { L.DomEvent.stop(e); insertInLeg(idx, e.latlng); }); // tap → insert
line.on("click", (e) => { L.DomEvent.stop(e); if (nogoMode) placeNogo(e.latlng); else insertInLeg(idx, e.latlng); }); // tap → insert (or nogo)
line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight
})(i);
hitLines.push(line);
@ -3596,7 +3626,7 @@ async function showRouteEditor(container, routeId) {
for (let i of idxs) {
if (i >= points.length - 1 || legMode[i] !== "road") continue;
try {
let gpx = await BRouter.route([[points[i].lon, points[i].lat], [points[i+1].lon, points[i+1].lat]], "trekking");
let gpx = await BRouter.route([[points[i].lon, points[i].lat], [points[i+1].lon, points[i+1].lat]], "trekking", nogos);
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope
} catch (e) { legGeom[i] = null; } // silent fallback to straight (no road / no segments)
@ -3691,6 +3721,37 @@ async function showRouteEditor(container, routeId) {
};
locCtl.addTo(map);
// Nogo (impassable) circles: toggle the ⛔ button, then a map/line tap drops a red circle
// the routing avoids. Persist in DB; every road leg re-routes to go around them.
async function refreshNogos(reroute) {
nogos = await getNogosForRouter();
nogoLayer = await drawNogoAreas(map, nogoLayer, () => refreshNogos(true));
if (reroute) {
for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); }
redraw(); updateStats(); scheduleRoute();
}
}
async function placeNogo(latlng) {
await DB.addNogoArea(latlng.lat, latlng.lng, 25, null);
await refreshNogos(true);
}
let nogoCtl = L.control({ position: "topleft" });
nogoCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = "⛔";
d.title = "Пометить непроезжее: тап по карте";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
nogoMode = !nogoMode;
d.classList.toggle("active", nogoMode);
showToast(nogoMode ? "Тап по карте — пометить непроезжее" : "Режим отметки выключен");
};
return d;
};
nogoCtl.addTo(map);
refreshNogos(false);
// Existing route: load waypoints. Planner-built → its saved leg modes (re-route road legs
// to get fresh geometry + elevations). Imported/legacy dense → all legs straight.
if (points.length) {
@ -3708,7 +3769,7 @@ async function showRouteEditor(container, routeId) {
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
map.on("click", e => addPoint(e.latlng));
map.on("click", e => { if (nogoMode) placeNogo(e.latlng); else addPoint(e.latlng); });
document.querySelector("#re-undo").onclick = () => {
if (!points.length) return;
@ -4244,6 +4305,24 @@ async function renderRecordingInline(container) {
darkBtn.onclick = () => toggleDarkMap();
btnWrap.appendChild(darkBtn);
// Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute
// will avoid it). Shows existing nogo circles too.
let recNogoLayer = null;
async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); }
refreshRecNogos();
let recNogoBtn = document.createElement("button");
recNogoBtn.className = "rec-map-btn";
recNogoBtn.textContent = "⛔";
recNogoBtn.title = "Здесь нет проезда (отметить текущее место)";
recNogoBtn.onclick = async () => {
let p = GPS.recording.lastMovingPoint;
if (!p) { showToast("Нет позиции"); return; }
await DB.addNogoArea(p.lat, p.lon, 25, null);
await refreshRecNogos();
showToast("Отмечено: нет проезда");
};
btnWrap.appendChild(recNogoBtn);
POI.attach(map); // POI overlay + category filter on the recording map
// The default-ON draw is deferred to the END of this function (after all the
// critical wiring), where it awaits whenMapReady — so it can't delay setup.
@ -4868,6 +4947,7 @@ async function showSessionDetail(sessionId) {
new CachedLayer({ maxZoom: 19 }).addTo(map);
addDarkMapControl(map); // dark-tiles toggle on the track view
POI.attach(map); // POI overlay + category filter
{ let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles
// Gap-fill: route GPS dropouts along roads (BRouter) and PREVIEW only the filled gaps
// (not the whole track); then save (reversible, in-place) or discard. If the track is

View file

@ -11,11 +11,20 @@
window.BRouter = {
_servers: ["http://192.168.1.18:17777", "http://mirror.invalid:17777"],
async _routeServer(pts, profile) {
// nogos: [{lat,lon,radius}] — impassable circles the router must avoid.
_nogoParam(nogos) {
if (!nogos || !nogos.length) return "";
// RouteServer expects nogos=lon,lat,radiusMeters|... (radius in metres).
let s = nogos.filter(n => n && n.lat != null && n.lon != null)
.map(n => n.lon + "," + n.lat + "," + Math.round(n.radius || 25)).join("|");
return s ? "&nogos=" + encodeURIComponent(s) : "";
},
async _routeServer(pts, profile, nogos) {
let lonlats = pts.map(p => p[0] + "," + p[1]).join("|");
for (let base of this._servers) {
try {
let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx";
let url = base + "/brouter?lonlats=" + encodeURIComponent(lonlats) + "&profile=" + profile + "&alternativeidx=0&format=gpx" + this._nogoParam(nogos);
let gpx = await window.__TAURI__.core.invoke("http_request", { url, method: "GET" });
if (gpx && gpx.indexOf("<gpx") >= 0) return gpx;
} catch (e) { /* try next server */ }
@ -23,25 +32,26 @@ window.BRouter = {
return null;
},
async route(points, profile) {
async route(points, profile, nogos) {
let pts = (points || []).map(p => [p[0], p[1]]);
profile = profile || "trekking";
nogos = (nogos || []).filter(n => n && n.lat != null && n.lon != null);
let isAndroid = /Android/i.test(navigator.userAgent || "");
// Desktop has no embedded engine (that's Kotlin/Android) — the native command would
// just time out; go straight to the PC RouteServer. Android uses the engine, and
// falls back to the RouteServer (reachable at home) only if the engine errors.
if (!isAndroid) {
let gpx = await this._routeServer(pts, profile);
let gpx = await this._routeServer(pts, profile, nogos);
if (gpx) return gpx;
throw new Error("brouter: RouteServer unreachable");
}
try {
let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile });
let raw = await window.__TAURI__.core.invoke("brouter_route", { lonlats: JSON.stringify(pts), profile, nogos: JSON.stringify(nogos) });
let res = JSON.parse(raw);
if (res.ok) return res.gpx;
throw new Error(res.error || "engine error");
} catch (e) {
let gpx = await this._routeServer(pts, profile);
let gpx = await this._routeServer(pts, profile, nogos);
if (gpx) return gpx;
throw new Error("brouter: " + (e.message || e));
}

View file

@ -302,9 +302,16 @@ class MainActivity : TauriActivity() {
val pair = arr.getJSONArray(i)
pts.add(doubleArrayOf(pair.getDouble(0), pair.getDouble(1)))
}
// Optional nogos: [{lat,lon,radius}] impassable circles to avoid → [lon,lat,radius].
val nogos = ArrayList<DoubleArray>()
val na = req.optJSONArray("nogos")
if (na != null) for (i in 0 until na.length()) {
val o = na.getJSONObject(i)
nogos.add(doubleArrayOf(o.getDouble("lon"), o.getDouble("lat"), o.optDouble("radius", 25.0)))
}
val profilePath = File(filesDir, "brouter/profiles2/$profile.brf").absolutePath
val segmentsDir = File(filesDir, "brouter/segments4")
val gpx = routeBrouter(segmentsDir, profilePath, pts)
val gpx = routeBrouter(segmentsDir, profilePath, pts, nogos)
out.put("ok", true)
out.put("gpx", gpx)
} catch (e: Exception) {
@ -320,11 +327,28 @@ class MainActivity : TauriActivity() {
// RoutingContext pointing at the profile (.brf; the engine parses it via ProfileCache
// and loads lookups.dat from the same dir), waypoints as OsmNodeNamed (ilon/ilat are
// micro-degrees), run the engine, return the track as GPX.
private fun routeBrouter(segmentsDir: File, profilePath: String, pts: List<DoubleArray>): String {
private fun routeBrouter(segmentsDir: File, profilePath: String, pts: List<DoubleArray>, nogos: List<DoubleArray> = emptyList()): String {
val rc = btools.router.RoutingContext()
rc.memoryclass = 128
rc.localFunction = profilePath
rc.setAlternativeIdx(0)
// Nogo circles (impassable): each a hard block (nogoWeight = NaN). prepareNogoPoints
// converts the metre radius to the engine's internal units before routing.
if (nogos.isNotEmpty()) {
val nogoList = ArrayList<btools.router.OsmNodeNamed>()
for (n in nogos) {
val nn = btools.router.OsmNodeNamed()
nn.ilon = ((n[0] + 180.0) * 1000000.0 + 0.5).toInt()
nn.ilat = ((n[1] + 90.0) * 1000000.0 + 0.5).toInt()
nn.radius = n[2]
nn.isNogo = true
nn.nogoWeight = Double.NaN
nn.name = "nogo"
nogoList.add(nn)
}
btools.router.RoutingContext.prepareNogoPoints(nogoList)
rc.nogopoints = nogoList
}
val wps = ArrayList<btools.router.OsmNodeNamed>()
for (p in pts) {
val n = btools.router.OsmNodeNamed()

View file

@ -398,16 +398,19 @@ fn gps_service_command(app: tauri::AppHandle, cmd: String) -> Result<String, Str
// FileObserver watches, then poll for brouter_result_<id>.json. `lonlats` is a JSON
// array of [lon,lat] pairs; returns the Kotlin result JSON ({ok,gpx}|{ok:false,error}).
#[tauri::command]
fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String) -> Result<String, String> {
fn brouter_route(app: tauri::AppHandle, lonlats: String, profile: String, nogos: Option<String>) -> Result<String, String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
std::fs::create_dir_all(&data_dir).ok();
let id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?
.as_millis().to_string();
// nogos is a JSON array string [{lat,lon,radius}] from JS; default to empty array.
let nogos_json = nogos.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "[]".to_string());
let req = format!(
"{{\"profile\":{},\"lonlats\":{}}}",
"{{\"profile\":{},\"lonlats\":{},\"nogos\":{}}}",
serde_json::to_string(&profile).map_err(|e| e.to_string())?,
lonlats
lonlats,
nogos_json
);
let result_name = format!("brouter_result_{}.json", id);
// Write request + command to app_data_dir AND app root (where Kotlin watches).