Route editor + 1% slope bands + debug-only diagnostics + rotate-map route fix

- Route editor (showRouteEditor): create ("Новый маршрут") or edit an existing
  route's geometry in-app — tap to add, drag a point to move, tap a point to
  delete. Save: new prompts a name (addRoute), edit rewrites points_json
  (updateRoutePoints; drops stale elevations, re-fetch from the preview).
- Slope colouring in discrete 1% bands (green→yellow→red→brown→black, ≥10% black)
  instead of degree buckets; colour-strip legend (slopeLegendHtml).
- Diagnostic logs compiled-in but silenced in release: get_app_info now returns
  debug = cfg!(debug_assertions); JS installs a console.log filter that drops
  lines starting with a diagnostic tag ([poll]/[recover]/[stop]/[GPS]/[HR]/[SD])
  in release only. No more hand-adding/removing logs.
- Fix: saved-route overlay was invisible on the recording (rotating) map — it
  flashed on pinch-zoom then vanished. leaflet-rotate keeps tile/overlay panes
  inside _rotatePane, but the commented-out createPane override meant our custom
  routePane attached to mapPane (outside rotation). Now parent routePane to
  map._rotatePane when present; non-rotating maps unchanged.
This commit is contained in:
4aiman 2026-08-16 17:15:42 +03:00
commit fd6cf62fc0
7 changed files with 174 additions and 17 deletions

View file

@ -21,7 +21,7 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `drawRoutePolyline`/`routeSlopeColor`/`routeElevColor` — топо/уклон-обводка в отдельной панели `routePane` (маршрут ПОД треком), тоггл уклон/высота. `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData |
| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев). `showRouteDetail` — предпросмотр маршрута (карта+профиль высоты+тап-высоты+фуллскрин), `showRouteEditor` — создание/правка маршрута (тап=точка, тащить=двигать, тап точки=удалить). `drawRoutePolyline`/`routeSlopeColor` (полосы уклона по 1%)/`routeElevColor` — топо/уклон-обводка в панели `routePane` (маршрут ПОД треком; на rotate-карте панель вешается внутрь `_rotatePane`, иначе маршрут не виден при записи). `fetchElevations` — цепочка GPXZ→open-elevation→open-meteo→OpenTopoData. Диагностические `console.log("[тег]...")` глушатся в release по `get_app_info().debug` (cfg!(debug_assertions)) |
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints` — сохранить высоты, `getRoutes.hasEle`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера |
| `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` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `config.gpxzKey` — ключ GPXZ (localStorage) |
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
@ -153,7 +153,7 @@
| `get_points_filename` | Имя активного файла точек (по указателю `gps_live_points.jsonl`) |
| `debug_native_paths` | Диагностика путей нативных файлов |
| `save_file` | SAF диалог сохранения (GPX экспорт) |
| `get_app_info` | Версия, git hash, дата сборки |
| `get_app_info` | Версия, git hash, дата сборки, `debug` (cfg!(debug_assertions) — для глушения диагностики в release) |
| `get_update_info` | Проверка OTA обновления (BUILD_NUMBER с VPS) |
| `ble_scan` | Сканирование BLE устройств |
| `ble_scan_results` | Результаты скана (Android) |

View file

@ -16,7 +16,8 @@
- **OTA обновления** — проверка BUILD_NUMBER на роутере (LAN) или VPS, скачивание через браузер.
- **Ручные маршруты** — рисование трека на карте тапами, elevation из прошлых треков или open-meteo API.
- **Сохранённые маршруты** — импорт GPX-маршрутов, нарисованных заранее (например, в конструкторе Яндекс.Карт: формат `<rtept>`), выбор активного, наложение цветной линией поверх карты во время записи И на экране просмотра прошлого трека — для визуальной навигации. Управление на отдельной странице (импорт/переименование/цвет/удаление, один активный за раз), переключение линии кнопкой-тогглом. Цвета «проезженных дорог» настраиваются раздельно для вело/пеших/бега.
- **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон по градусам (салатовый <2°, жёлтый 2–4°, красный 4–6°, чёрный >6°) или гипсометрия (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека; маршрут рисуется ПОД треком. Высоты (`<ele>` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM).
- **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон дискретными полосами по 1% (зелёный→жёлтый→красный→коричневый→чёрный, ≥10% чёрный) или гипсометрия по высоте (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека и во время записи; маршрут рисуется ПОД треком. Высоты (`<ele>` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM).
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку, тащить точку — двигать, тап по точке — удалить. Высоты подтягиваются онлайн после отрисовки (из предпросмотра).
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД для будущего пересчёта с порогом точности.
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
- **Вес, сон, добавки** — логирование с историей.

View file

@ -123,6 +123,21 @@ async function init() {
return;
}
// Diagnostic logs stay in the source but only fire in debug builds. Rust reports
// the compiled build profile (cfg!(debug_assertions)); in a release build we
// silence console.log lines that start with a diagnostic tag ([poll]/[recover]/
// [stop]/[GPS]/[HR]/[SD]/...). console.error/warn are never touched. This means
// no adding/removing logs by hand — a tagged log auto-disappears in release.
try {
let info = await window.__TAURI__.core.invoke("get_app_info");
window.__BALDAV_DEBUG = !!info.debug;
} catch { window.__BALDAV_DEBUG = false; }
if (!window.__BALDAV_DEBUG) {
let _log = console.log.bind(console);
let DIAG = /^\[(poll|recover|stop|GPS|HR|SD|seq|native|flush|watch|import|rec)\]/;
console.log = function (...a) { if (typeof a[0] === "string" && DIAG.test(a[0])) return; _log.apply(console, a); };
}
// Show loading immediately so the user sees something
app.innerHTML = `<div class="seed-progress">
<div class="seed-label" id="boot-label">Загрузка...</div>
@ -2893,14 +2908,23 @@ async function drawActiveRouteOverlay(map, prev) {
return drawRoutePolyline(map, route.points, route.color);
}
// Slope tint by absolute grade in DEGREES: gentle = lime, then yellow, red, and
// black for anything a singlespeed can't pedal up / safely coast down (>6°).
// 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.
let ROUTE_SLOPE_BANDS = [
"#2e7d32", "#7cb342", "#aeea00", "#ffeb3b", "#ffc107",
"#ff9800", "#ff5722", "#e53935", "#b71c1c", "#6d4c41", "#000000",
];
function routeSlopeColor(gradePct) {
let deg = Math.abs(Math.atan((gradePct || 0) / 100) * 180 / Math.PI);
if (deg < 2) return "#aeea00"; // салатовый — пологий (<2°)
if (deg < 4) return "#ffd600"; // жёлтый (2–4°)
if (deg < 6) return "#e53935"; // красный (4–6°)
return "#000000"; // >6° — чёрный
let idx = Math.min(10, Math.floor(Math.abs(gradePct || 0)));
return ROUTE_SLOPE_BANDS[idx];
}
// Compact colour strip for the slope legend (every 1% band, 0 → 10%+).
function slopeLegendHtml() {
let cells = ROUTE_SLOPE_BANDS
.map(c => `<span style="display:inline-block;width:11px;height:10px;background:${c};vertical-align:middle"></span>`)
.join("");
return `уклон 0% ${cells} 10%+`;
}
// Draw a route as a feature group: a wide colored casing (per segment — by slope
@ -2910,7 +2934,13 @@ function routeSlopeColor(gradePct) {
// draws ON TOP of the route. Removable via map.removeLayer.
function drawRoutePolyline(map, pts, color, mode) {
mode = mode || localStorage.getItem("baldav_route_color_mode") || "slope";
if (!map.getPane("routePane")) { map.createPane("routePane"); map.getPane("routePane").style.zIndex = 250; }
// On a rotating map (leaflet-rotate) tile/overlay panes live INSIDE _rotatePane;
// a pane created at the default mapPane level wouldn't rotate/zoom with the map
// (route flashes then vanishes). Parent our pane to _rotatePane when it exists.
if (!map.getPane("routePane")) {
map.createPane("routePane", map._rotatePane || undefined);
map.getPane("routePane").style.zIndex = 250; // between tilePane(200) and overlayPane(400)
}
let popts = { pane: "routePane", interactive: false };
let latlngs = pts.map(p => [p.lat, p.lon]);
let group = L.featureGroup();
@ -2959,7 +2989,10 @@ async function showRoutesScreen(container) {
<h2>Сохранённые маршруты</h2>
<span style="width:36px"></span>
</div>
<button id="routes-import" class="btn-primary" style="width:100%;margin:8px 0 14px">Импорт GPX</button>
<div style="display:flex;gap:8px;margin:8px 0 14px">
<button id="routes-new" class="btn-primary" style="flex:1">Новый маршрут</button>
<button id="routes-import" class="btn-secondary" style="flex:1">Импорт GPX</button>
</div>
<label class="settings-item" style="cursor:pointer">
<input type="radio" name="active-route" id="route-none" value="" ${!active ? "checked" : ""} style="width:18px;height:18px;margin-right:10px">
<span>— Без маршрута —</span>
@ -2969,6 +3002,8 @@ async function showRoutesScreen(container) {
container.querySelector("#routes-back").onclick = () => renderScreen("gps");
container.querySelector("#routes-new").onclick = () => showRouteEditor(container, null);
container.querySelector("#route-none").onchange = () => {
active = ""; localStorage.removeItem("baldav_active_route");
showToast("Маршрут отключён");
@ -3041,7 +3076,7 @@ async function showRouteDetail(container, routeId) {
let st = GPS.routeElevStats(pts);
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
let legendHtml = mode === "slope"
? `<b style="color:#aeea00">▬</b>&lt;2° <b style="color:#ffd600">▬</b>2–4° <b style="color:#e53935">▬</b>4–6° <b style="color:#000;text-shadow:0 0 2px #999">▬</b>&gt;6°`
? slopeLegendHtml()
: `<b style="color:#2e7d32">▬</b> низко → <b style="color:#6d4c41">▬</b> высоко`;
container.innerHTML = `
@ -3065,9 +3100,11 @@ async function showRouteDetail(container, routeId) {
? `<div class="sd-section"><div class="sd-title">Профиль высоты</div><div class="chart-wrap" id="rd-elev-wrap"><canvas id="rd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div></div>`
: `<div class="prod-hint" style="margin-top:12px">Высоты ещё не загружены — нажмите кнопку ниже (нужен интернет).</div>`}
<button id="rd-fetch" class="btn-primary" style="width:100%;margin-top:12px">${st.hasEle ? "Обновить высоты (онлайн)" : "Загрузить высоты (онлайн)"}</button>
<button id="rd-edit" class="btn-secondary" style="width:100%;margin-top:8px">Редактировать маршрут</button>
</div>`;
container.querySelector("#rd-back").onclick = () => showRoutesScreen(container);
container.querySelector("#rd-edit").onclick = () => showRouteEditor(container, routeId);
container.querySelector("#rd-fetch").onclick = async (e) => {
let btn = e.currentTarget, orig = btn.textContent;
@ -3114,7 +3151,7 @@ async function showRouteDetail(container, routeId) {
d.innerHTML = mode === "slope" ? "∡" : "⛰";
let leg = container.querySelector("#rd-legend");
if (leg) leg.innerHTML = mode === "slope"
? `<b style="color:#aeea00">▬</b>&lt;2° <b style="color:#ffd600">▬</b>2–4° <b style="color:#e53935">▬</b>4–6° <b style="color:#000;text-shadow:0 0 2px #999">▬</b>&gt;6°`
? slopeLegendHtml()
: `<b style="color:#2e7d32">▬</b> низко → <b style="color:#6d4c41">▬</b> высоко`;
};
return d;
@ -3181,6 +3218,121 @@ async function showRouteDetail(container, routeId) {
}
}
// Draw a NEW saved route, or edit an existing one's geometry, right in the app —
// tap to add points, undo the last, save. Elevations are fetched afterwards from
// the preview screen (geometry-only here). New -> prompts a name; edit -> replaces
// points_json (drops stale elevations — geometry changed) keeping name/color.
async function showRouteEditor(container, routeId) {
let editing = routeId != null;
let route = editing ? await DB.getRoute(routeId) : null;
let points = (editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : [];
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
let markers = [];
let polyline = null;
container.innerHTML = `
<div class="draw-screen">
<div id="re-map" class="draw-map"></div>
<div class="rec-stats">
<div class="rec-stat"><span id="re-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
<div class="rec-stat"><span id="re-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
</div>
<div class="rec-bottom">
<span class="prod-hint" style="flex:1;align-self:center;line-height:1.2">тап карты — точка · тащи — двигать · тап точки — удалить</span>
<button id="re-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
<button id="re-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
<button id="re-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
</div>
</div>`;
setTimeout(() => {
let mapEl = document.querySelector("#re-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map);
let distEl = document.querySelector("#re-dist");
let ptsEl = document.querySelector("#re-pts");
let dotIcon = L.divIcon({
className: "re-dot-wrap",
html: `<div style="width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 2px #000"></div>`,
iconSize: [16, 16], iconAnchor: [8, 8],
});
function updateStats() {
let dist = 0;
for (let i = 1; i < points.length; i++) dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
function updatePolyline() {
let latlngs = points.map(p => [p.lat, p.lon]);
if (polyline) map.removeLayer(polyline);
if (latlngs.length >= 2) polyline = L.polyline(latlngs, { color, weight: 4 }).addTo(map);
}
// Draggable + deletable point. Markers stay parallel to `points` (same index),
// so drag updates points[idx] and click (delete) splices both.
function addMarker(p) {
let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon });
m.on("drag", () => {
let idx = markers.indexOf(m);
if (idx >= 0) { points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; updatePolyline(); }
});
m.on("dragend", updateStats);
m.on("click", (e) => {
L.DomEvent.stop(e); // don't fall through to a map-click (which would add a point)
let idx = markers.indexOf(m);
if (idx < 0) return;
points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m);
updatePolyline(); updateStats();
});
m.addTo(map);
markers.push(m);
}
if (points.length) {
points.forEach(p => addMarker(p));
updatePolyline(); updateStats();
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
map.on("click", e => {
let p = { lat: e.latlng.lat, lon: e.latlng.lng };
points.push(p); addMarker(p);
updatePolyline(); updateStats();
});
document.querySelector("#re-undo").onclick = () => {
if (!points.length) return;
points.pop();
let m = markers.pop(); if (m) map.removeLayer(m);
updatePolyline(); updateStats();
};
document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container);
document.querySelector("#re-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let btn = document.querySelector("#re-save");
btn.disabled = true; btn.textContent = "Сохранение...";
if (editing) {
await DB.updateRoutePoints(routeId, points);
showToast("Маршрут обновлён — загрузите высоты заново");
showRouteDetail(container, routeId);
} else {
let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить");
if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; }
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", points);
showToast("Маршрут создан");
showRouteDetail(container, id);
}
};
}, 200);
}
function showDrawRouteScreen(container) {
let points = []; // {lat, lon}
let markers = [];

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

@ -308,6 +308,7 @@ struct AppInfo {
version: String,
git_hash: String,
build_date: String,
debug: bool,
}
#[tauri::command]
@ -316,6 +317,9 @@ fn get_app_info() -> AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
git_hash: env!("GIT_HASH").to_string(),
build_date: env!("BUILD_DATE").to_string(),
// Compiled-in build profile: on for `tauri dev` / `--debug`, off for release.
// The JS side uses this to silence diagnostic logs in release builds.
debug: cfg!(debug_assertions),
}
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.400",
"version": "0.1.406",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"