diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 9e4e7a4..0c64578 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -21,9 +21,9 @@ | Файл | Строк | Назначение | |------|-------|------------| -| `js/main.js` | ~5900 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги. `appConfirm`/`appPrompt` (in-app диалоги вместо нативных, в стеке окон). Страница «Сохранённые маршруты», оверлей маршрута на записи и просмотре (`whenMapReady` — await готовности карты, не таймаут). Панель де-джиттера на детали трека (ползунки окно/итерации, живое превью, сохранить/откат, `reopenDetail` — переоткрытие детали без наложения оверлеев) | -| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD). `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` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, DOMParser) | +| `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/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` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. Отбор по точности (minAccuracyM), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает ``, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `config.gpxzKey` — ключ GPXZ (localStorage) | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | | `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) | | `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад | @@ -102,7 +102,7 @@ | `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_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, created_at | +| `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json (`{lat,lon,ele?}`), created_at | | `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active | | `rdi_standards` | Стандарты РСП (RU/EU/US) | code, name | | `rdi_values` | Нормы нутриентов | standard_id, nutrient_key, value, norm_type (rdi/aup) | @@ -130,7 +130,7 @@ ### Экраны - **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы) - **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник -- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М»), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали) +- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали) - **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10) - **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг, OTA обновления, импорт тайлов, карта дорог diff --git a/README.md b/README.md index fe90b77..58333a7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ - **OTA обновления** — проверка BUILD_NUMBER на роутере (LAN) или VPS, скачивание через браузер. - **Ручные маршруты** — рисование трека на карте тапами, elevation из прошлых треков или open-meteo API. - **Сохранённые маршруты** — импорт GPX-маршрутов, нарисованных заранее (например, в конструкторе Яндекс.Карт: формат ``), выбор активного, наложение цветной линией поверх карты во время записи И на экране просмотра прошлого трека — для визуальной навигации. Управление на отдельной странице (импорт/переименование/цвет/удаление, один активный за раз), переключение линии кнопкой-тогглом. Цвета «проезженных дорог» настраиваются раздельно для вело/пеших/бега. +- **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон по градусам (салатовый <2°, жёлтый 2–4°, красный 4–6°, чёрный >6°) или гипсометрия (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека; маршрут рисуется ПОД треком. Высоты (`` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM). - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД для будущего пересчёта с порогом точности. - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. diff --git a/frontend/js/db.js b/frontend/js/db.js index da59fc9..ca103be 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -999,8 +999,9 @@ async function getRoutes() { // Routes are few and small — load the blob to report a point count, drop it. const rows = await select("SELECT id, name, color, created_at, points_json FROM routes ORDER BY created_at DESC"); return rows.map(r => { - let n = 0; try { n = (JSON.parse(r.points_json) || []).length; } catch {} - return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, pts: n }; + let n = 0, hasEle = false; + try { let pts = JSON.parse(r.points_json) || []; n = pts.length; hasEle = pts.some(p => p.ele != null); } catch {} + return { id: r.id, name: r.name, color: r.color, created_at: r.created_at, pts: n, hasEle }; }); } @@ -1033,6 +1034,12 @@ async function deleteRoute(id) { await execute("DELETE FROM routes WHERE id = ?1", [id]); } +// Overwrite a route's points (used to persist fetched elevations back into +// points_json — points are {lat, lon, ele?}). +async function updateRoutePoints(id, points) { + await execute("UPDATE routes SET points_json = ?1 WHERE id = ?2", [JSON.stringify(points || []), id]); +} + // --------------------------------------------------------------------------- // Food cost helper — most frequently eaten foods in last 7 days // --------------------------------------------------------------------------- @@ -1189,6 +1196,7 @@ window.DB.addRoute = addRoute; window.DB.renameRoute = renameRoute; window.DB.setRouteColor = setRouteColor; window.DB.deleteRoute = deleteRoute; +window.DB.updateRoutePoints = updateRoutePoints; window.DB.getFrequentFoods = getFrequentFoods; window.DB.getGoalProgress = getGoalProgress; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index c4d4ab3..95da034 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -26,6 +26,7 @@ GPS.config = { roadColorWalk: '#8B5E3C', // цвет наложения проезженных/пройденных дорог: ходьба roadColorBike: '#2266aa', // ... вело roadColorRun: '#e67e22', // ... бег + gpxzKey: '', // API-ключ GPXZ (точные высоты Copernicus GLO-30). Пусто = open-elevation (SRTM). Хранится только тут (localStorage), не в git. }; // Load saved config @@ -1247,7 +1248,12 @@ GPS.parseRouteGPX = function(xmlString) { els.forEach(pt => { const lat = parseFloat(pt.getAttribute("lat")); const lon = parseFloat(pt.getAttribute("lon")); - if (!isNaN(lat) && !isNaN(lon)) points.push({ lat, lon }); + if (isNaN(lat) || isNaN(lon)) return; + const point = { lat, lon }; + // Elevation is optional: tools like Komoot/BRouter emit , Yandex does not. + const eleEl = pt.querySelector("ele"); + if (eleEl) { const e = parseFloat(eleEl.textContent); if (!isNaN(e)) point.ele = e; } + points.push(point); }); break; // first tag type that has points wins } @@ -1803,5 +1809,44 @@ GPS.filterAndSmoothAlt = function(rawAlts) { }); }; +// Elevation profile of a saved route (points {lat,lon,ele?}). Smooths the raw +// elevations (filterAndSmoothAlt kills SRTM vertical noise) so the grade — the +// thing that matters for a singlespeed rider — isn't spiky. Returns per-point +// cumulative distance (km), smoothed elevation, grade-to-next (%), plus ascent/ +// descent (1m hysteresis) and the steepest segment. +GPS.routeElevStats = function(points) { + const n = points.length; + const hasEle = points.some(p => p.ele != null); + const smoothed = GPS.filterAndSmoothAlt(points.map(p => (p.ele != null ? p.ele : null))); + const cumKm = new Array(n).fill(0); + for (let i = 1; i < n; i++) { + cumKm[i] = cumKm[i-1] + GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon) / 1000; + } + const grades = new Array(n).fill(0); + let maxGrade = 0; + for (let i = 0; i < n - 1; i++) { + const dm = GPS.distance(points[i].lat, points[i].lon, points[i+1].lat, points[i+1].lon); + const de = (smoothed[i] != null && smoothed[i+1] != null) ? (smoothed[i+1] - smoothed[i]) : 0; + const g = dm > 1 ? (de / dm) * 100 : 0; + grades[i] = g; + if (dm > 10 && Math.abs(g) > Math.abs(maxGrade)) maxGrade = g; + } + let ascent = 0, descent = 0, ref = null; + for (let i = 0; i < n; i++) { + const e = smoothed[i]; + if (e == null) continue; + if (ref == null) { ref = e; continue; } + const rise = e - ref; + if (rise > 1) { ascent += rise; ref = e; } + else if (rise < -1) { descent += -rise; ref = e; } + } + return { + hasEle, smoothed, cumKm, grades, + ascent: Math.round(ascent), descent: Math.round(descent), + maxGrade: Math.round(maxGrade * 10) / 10, + distanceKm: cumKm[n-1] || 0, + }; +}; + window.GPS = GPS; console.log("[gps.js] window.GPS ready"); diff --git a/frontend/js/main.js b/frontend/js/main.js index 506b49c..49c0bee 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2797,12 +2797,30 @@ async function showAddGoalMarkerModal() { // --------------------------------------------------------------------------- async function fetchElevations(points) { - // Terrain elevation. Primary: open-elevation.com (reachable in RU). Fallback: open-meteo - // (blocked by RU carriers on this device, kept in case it opens up). Batches of 100. + // Terrain elevation, tried in order until one answers a batch: + // 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m). + // 2. open-elevation.com (SRTM, reachable in RU). + // 3. open-meteo (often blocked by RU carriers, kept in case). + // 4. OpenTopoData public (free, no key, srtm30m) — last-resort keyless analog. + // Batches of 100 (also OpenTopoData's max locations/request). The GPXZ key lives + // only in GPS.config (localStorage), never in git. let elevations = new Array(points.length).fill(null); + let gpxzKey = GPS.config.gpxzKey && GPS.config.gpxzKey.trim(); for (let i = 0; i < points.length; i += 100) { let batch = points.slice(i, i + 100); let got = null; + if (gpxzKey) { + try { + let latlons = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|"); + let resp = await fetch("https://api.gpxz.io/v1/elevation/points?latlons=" + encodeURIComponent(latlons), + { headers: { "x-api-key": gpxzKey } }); + if (resp.ok) { + let d = await resp.json(); + if (d.results) got = d.results.map(r => r.elevation); + } else { console.log("[gpxz] http", resp.status); } + } catch (e) { console.log("[gpxz] batch fail:", e.message); } + } + if (got) { for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; continue; } try { let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", { method: "POST", @@ -2822,6 +2840,13 @@ async function fetchElevations(points) { if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; } } catch (e) { console.log("[open-meteo] batch fail:", e.message); } } + if (!got) { + try { + let locs = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|"); + let resp = await fetch("https://api.opentopodata.org/v1/srtm30m?locations=" + encodeURIComponent(locs)); + if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); } + } catch (e) { console.log("[opentopodata] batch fail:", e.message); } + } if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; } return elevations.some(x => x != null) ? elevations : null; @@ -2848,14 +2873,66 @@ async function whenMapReady(map) { // Draw the active saved route (localStorage baldav_active_route) as a colored line // on a Leaflet map. Removes `prev` first. Returns the new layer, or null if none. +// Hypsometric tint (like old topo maps): low = green, mid = yellow, high = brown. +// t in [0,1] (normalized elevation). +function routeElevColor(t) { + t = Math.max(0, Math.min(1, t)); + let lerp = (a, b, f) => Math.round(a + (b - a) * f); + let c1, c2, f; + if (t < 0.5) { c1 = [46, 125, 50]; c2 = [253, 216, 53]; f = t / 0.5; } // green -> yellow + else { c1 = [253, 216, 53]; c2 = [109, 76, 65]; f = (t - 0.5) / 0.5; } // yellow -> brown + return `rgb(${lerp(c1[0], c2[0], f)},${lerp(c1[1], c2[1], f)},${lerp(c1[2], c2[2], f)})`; +} + async function drawActiveRouteOverlay(map, prev) { if (prev) { try { map.removeLayer(prev); } catch {} } let id = localStorage.getItem("baldav_active_route"); if (!id) return null; let route = await DB.getRoute(id); if (!route || !route.points || route.points.length < 2) return null; - return L.polyline(route.points.map(p => [p.lat, p.lon]), - { color: route.color || "#e53935", weight: 4, opacity: 0.85, interactive: false }).addTo(map); + 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°). +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° — чёрный +} + +// Draw a route as a feature group: a wide colored casing (per segment — by slope +// or by elevation, per baldav_route_color_mode) UNDER a thin identity-color line, +// when the route has elevations; otherwise just the plain colored line. The casing +// lives in a dedicated pane below the overlay pane, so a recorded track always +// 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; } + let popts = { pane: "routePane", interactive: false }; + let latlngs = pts.map(p => [p.lat, p.lon]); + let group = L.featureGroup(); + let st = GPS.routeElevStats(pts); + if (st.hasEle) { + let valid = st.smoothed.filter(e => e != null); + let lo = Math.min(...valid), hi = Math.max(...valid), span = (hi - lo) || 1; + for (let i = 0; i < pts.length - 1; i++) { + let col; + if (mode === "elev") { + let e = st.smoothed[i] ?? st.smoothed[i+1]; + if (e == null) continue; + col = routeElevColor((e - lo) / span); + } else { + col = routeSlopeColor(st.grades[i]); + } + L.polyline([latlngs[i], latlngs[i+1]], { ...popts, color: col, weight: 14, opacity: 0.95 }).addTo(group); + } + } + L.polyline(latlngs, { ...popts, color: color || "#e53935", weight: 3, opacity: 0.95 }).addTo(group); + group.addTo(map); + return group; } // Saved routes management page (import GPX, rename, color, delete, pick active). @@ -2867,11 +2944,9 @@ async function showRoutesScreen(container) { let routes = await DB.getRoutes(); let rows = routes.map(r => `
- + + + ${esc(r.name)} ${r.pts} тчк${r.hasEle ? " · ⛰" : ""} @@ -2905,11 +2980,21 @@ async function showRoutesScreen(container) { try { parsed = GPS.parseRouteGPX(text); } catch (e) { showToast("Ошибка чтения GPX"); return; } if (!parsed.points || parsed.points.length < 2) { showToast("В GPX нет линии маршрута"); return; } let defName = (parsed.name || (file.name || "маршрут")).replace(/\.gpx$/i, "").trim(); - let name = prompt("Название маршрута:", defName); + let name = await appPrompt("Название маршрута:", defName, "Импорт"); if (name == null) return; name = name.trim() || defName; - await DB.addRoute(name, "#e53935", parsed.points); - showToast(`Импортирован: ${name} (${parsed.points.length} тчк)`); + let pts = parsed.points; + // Best-effort: if the GPX had no , fetch terrain elevations now so the + // route shows relief right away. Offline just skips it (import still works). + if (!pts.some(p => p.ele != null)) { + showToast("Загрузка высот..."); + try { + let eles = await fetchElevations(pts); + if (eles) pts = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p)); + } catch {} + } + await DB.addRoute(name, "#e53935", pts); + showToast(`Импортирован: ${name} (${pts.length} тчк)`); render(); }); }; @@ -2917,6 +3002,7 @@ async function showRoutesScreen(container) { container.querySelectorAll(".route-row").forEach(row => { let id = row.getAttribute("data-id"); let curName = row.getAttribute("data-name"); + row.querySelector(".route-name").onclick = () => showRouteDetail(container, id); row.querySelector("input[type=radio]").onchange = () => { active = id; localStorage.setItem("baldav_active_route", id); @@ -2945,6 +3031,156 @@ async function showRoutesScreen(container) { render(); } +// Route preview: map (topo casing + tappable altitude/grade points) + elevation +// profile chart + ascent/descent/max-grade — so you can see how hilly a hand-drawn +// route is BEFORE riding it. Reuses fetchElevations / GPS.routeElevStats / Chart.js. +async function showRouteDetail(container, routeId) { + let route = await DB.getRoute(routeId); + if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); showRoutesScreen(container); return; } + let pts = route.points; + let st = GPS.routeElevStats(pts); + let mode = localStorage.getItem("baldav_route_color_mode") || "slope"; + let legendHtml = mode === "slope" + ? `▬<2° ▬2–4° ▬4–6° ▬>6°` + : `▬ низко → ▬ высоко`; + + container.innerHTML = ` +
+
+ +

${esc(route.name)}

+ +
+
+ ${st.hasEle ? `
${legendHtml}
` : ""} +
+
${GPS.formatDistance(st.distanceKm * 1000)}Длина
+
${st.hasEle ? "+" + st.ascent + " м" : "—"}Набор
+
+
+
${st.hasEle ? "−" + st.descent + " м" : "—"}Сброс
+
${st.hasEle ? (st.maxGrade >= 0 ? "+" : "") + st.maxGrade + " %" : "—"}Макс. уклон
+
+ ${st.hasEle + ? `
Профиль высоты
` + : `
Высоты ещё не загружены — нажмите кнопку ниже (нужен интернет).
`} + +
`; + + container.querySelector("#rd-back").onclick = () => showRoutesScreen(container); + + container.querySelector("#rd-fetch").onclick = async (e) => { + let btn = e.currentTarget, orig = btn.textContent; + btn.disabled = true; btn.textContent = "Загрузка высот..."; + let eles = null; + try { eles = await fetchElevations(pts); } catch {} + if (!eles) { showToast("Сервис высот недоступен"); btn.disabled = false; btn.textContent = orig; return; } + let merged = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p)); + await DB.updateRoutePoints(routeId, merged); + showToast("Высоты загружены"); + showRouteDetail(container, routeId); + }; + + let mapEl = container.querySelector("#rd-map"); + if (mapEl) { + void mapEl.offsetHeight; // force layout so Leaflet reads the real size + L.Icon.Default.imagePath = "lib/"; + let map = L.map(mapEl, { attributionControl: false, zoomControl: false }); + map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing) + new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map); + let routeLayer = drawRoutePolyline(map, pts, route.color, mode); + // Tappable vertices → altitude + grade to next point + for (let i = 0; i < pts.length; i++) { + let e = st.smoothed[i], g = st.grades[i]; + let info = `#${i}${e != null ? " | " + Math.round(e) + " м" : ""}${i < pts.length - 1 ? " | " + (g >= 0 ? "+" : "") + g.toFixed(1) + "%" : ""}`; + L.circleMarker([pts[i].lat, pts[i].lon], { radius: 4, color: "#fff", weight: 1, fillColor: "#000", fillOpacity: 0.5, interactive: true }) + .bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map); + } + let bounds = L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.1); + + // On-map color-mode toggle (slope <-> elevation) — redraws the route in place. + if (st.hasEle) { + let colorCtl = L.control({ position: "topleft" }); + colorCtl.onAdd = () => { + let d = L.DomUtil.create("div", "sd-map-fs-btn"); + d.innerHTML = mode === "slope" ? "∡" : "⛰"; + d.title = "Раскраска: уклон / высота"; + d.onclick = (e) => { + e.stopPropagation(); + mode = mode === "slope" ? "elev" : "slope"; + localStorage.setItem("baldav_route_color_mode", mode); + map.removeLayer(routeLayer); + routeLayer = drawRoutePolyline(map, pts, route.color, mode); + d.innerHTML = mode === "slope" ? "∡" : "⛰"; + let leg = container.querySelector("#rd-legend"); + if (leg) leg.innerHTML = mode === "slope" + ? `▬<2° ▬2–4° ▬4–6° ▬>6°` + : `▬ низко → ▬ высоко`; + }; + return d; + }; + colorCtl.addTo(map); + } + + // Fullscreen expand/collapse — reuses .sd-map-full + winStack, like the track map. + let fsDiv; + function toggleFs() { + if (mapEl.classList.contains("sd-map-full")) { history.back(); return; } + mapEl.classList.add("sd-map-full"); + fsDiv.innerHTML = "✕"; + map.invalidateSize(); map.fitBounds(bounds); + winPush("fsRouteMap", () => { + mapEl.classList.remove("sd-map-full"); + fsDiv.innerHTML = "⛶"; + map.invalidateSize(); map.fitBounds(bounds); + }); + } + let fsCtl = L.control({ position: "topright" }); + fsCtl.onAdd = () => { + fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn"); + fsDiv.innerHTML = "⛶"; + fsDiv.title = "На весь экран"; + fsDiv.onclick = (e) => { e.stopPropagation(); toggleFs(); }; + return fsDiv; + }; + fsCtl.addTo(map); + + map.invalidateSize(); + map.fitBounds(bounds); + } + + if (st.hasEle) { + let ctx = container.querySelector("#rd-elev-chart"); + if (ctx) new Chart(ctx, { + type: "line", + data: { + labels: st.cumKm.map(k => k.toFixed(1)), + datasets: [{ + data: st.smoothed, + borderColor: "#ffb74d", borderWidth: 2, pointRadius: 0, fill: true, + backgroundColor: "rgba(255,183,77,0.12)", tension: 0.3, + // Steep (>8%) segments turn red — the sections that hurt on a singlespeed. + segment: { borderColor: c => Math.abs(st.grades[c.p0DataIndex] || 0) > 8 ? "#e53935" : "#ffb74d" }, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { + title: items => items[0].label + " км", + label: it => Math.round(it.raw) + " м, уклон " + ((st.grades[it.dataIndex] || 0) >= 0 ? "+" : "") + (st.grades[it.dataIndex] || 0).toFixed(1) + "%", + } }, + }, + scales: { + x: { title: { display: true, text: "км", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, + y: { title: { display: true, text: "м", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } }, + }, + }, + }); + } +} + function showDrawRouteScreen(container) { let points = []; // {lat, lon} let markers = []; @@ -4008,6 +4244,25 @@ async function showSessionDetail(sessionId) { return sdRouteDiv; }; routeCtl.addTo(map); + + // Route colour mode toggle (slope <-> elevation), same as on the route-preview + // screen. Flips the shared preference and redraws the overlay if it's shown. + let sdColorCtl = L.control({ position: "topleft" }); + sdColorCtl.onAdd = () => { + let d = L.DomUtil.create("div", "sd-map-fs-btn"); + let m0 = localStorage.getItem("baldav_route_color_mode") || "slope"; + d.innerHTML = m0 === "slope" ? "∡" : "⛰"; + d.title = "Раскраска маршрута: уклон / высота"; + d.onclick = async (e) => { + e.stopPropagation(); + let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope"; + localStorage.setItem("baldav_route_color_mode", nm); + d.innerHTML = nm === "slope" ? "∡" : "⛰"; + if (sdRouteLayer) sdRouteLayer = await drawActiveRouteOverlay(map, sdRouteLayer); + }; + return d; + }; + sdColorCtl.addTo(map); // Initial draw is deferred to after the track is fitted (below), so it awaits // a ready map and doesn't delay the track rendering. } @@ -5326,6 +5581,13 @@ async function renderSettings(container) {
+
+
+
GPXZ API ключ (высоты маршрутов)
+
Точные высоты Copernicus GLO-30 (лучше SRTM для уклонов). Пусто = open-elevation. Ключ хранится только на устройстве, не в git.
+
+ +
Сохранить настройки GPS
@@ -5580,9 +5842,11 @@ async function renderSettings(container) { GPS.config.roadColorRun = container.querySelector("#s-road-color-run").value; let fusedEl = container.querySelector("#s-use-fused"); if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked; + let gpxzEl = container.querySelector("#s-gpxz-key"); + if (gpxzEl) GPS.config.gpxzKey = gpxzEl.value.trim(); GPS.saveConfig(); }; - let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed", "s-road-color-bike", "s-road-color-walk", "s-road-color-run"]; + let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed", "s-road-color-bike", "s-road-color-walk", "s-road-color-run", "s-gpxz-key"]; for (let id of gpsInputs) { let el = container.querySelector("#" + id); if (el) { el.onchange = readGpsConfig; el.oninput = readGpsConfig; } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 44d571b..8f34209 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.393" +version = "0.1.400" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5ef591c..fda00d9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.393" +version = "0.1.400" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2519227..99aadda 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.393", + "version": "0.1.400", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"