Route elevation preview: profile, topo/slope casing, GPXZ + fallback chain
See how hilly a hand-drawn route is BEFORE riding it.
- parseRouteGPX reads <ele>; GPS.routeElevStats computes cumulative distance,
grade-to-next, ascent/descent (1m hysteresis) and max grade over smoothed
elevations (filterAndSmoothAlt kills SRTM vertical noise).
- Route preview screen (tap a route name): map with a wide colored casing +
tappable altitude/grade points, elevation profile chart (steep >8% in red),
length/ascent/descent/max-grade, fullscreen expand/collapse.
- Casing colored by slope (deg: lime <2, yellow 2-4, red 4-6, black >6) or by
elevation (green->yellow->brown), toggled with an on-map ∡/⛰ button. Casing
drawn in a dedicated pane below the overlay pane, so a recorded track stays on
top; the same toggle is on the track-detail map too.
- fetchElevations tries, in order: GPXZ (Copernicus GLO-30, key from GPS
settings — stored in localStorage, never in git) -> open-elevation (SRTM) ->
open-meteo -> OpenTopoData (keyless). Import auto-fetches elevations.
- db.js: updateRoutePoints persists fetched elevations into points_json;
getRoutes reports hasEle. Route points are now {lat, lon, ele?}.
Native prompt at route import replaced with in-app appPrompt.
This commit is contained in:
parent
bf3c0e2534
commit
f376e0944d
8 changed files with 342 additions and 24 deletions
|
|
@ -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<id>` в имени, дедуп ±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<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 сервер на роутере) |
|
||||
| `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 обновления, импорт тайлов, карта дорог
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
- **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).
|
||||
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД для будущего пересчёта с порогом точности.
|
||||
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
|
||||
- **Вес, сон, добавки** — логирование с историей.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <ele>, 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");
|
||||
|
|
|
|||
|
|
@ -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 => `
|
||||
<div class="settings-item route-row" data-id="${r.id}" data-name="${esc(r.name)}" style="gap:8px">
|
||||
<label style="display:flex;align-items:center;gap:10px;flex:1;cursor:pointer;min-width:0">
|
||||
<input type="radio" name="active-route" value="${r.id}" ${String(r.id) === active ? "checked" : ""} style="width:18px;height:18px;flex:none">
|
||||
<span class="route-swatch" style="width:16px;height:16px;border-radius:3px;background:${esc(r.color)};flex:none"></span>
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.name)} <span class="prod-hint">${r.pts} тчк</span></span>
|
||||
</label>
|
||||
<input type="radio" name="active-route" value="${r.id}" ${String(r.id) === active ? "checked" : ""} title="Сделать активным" style="width:18px;height:18px;flex:none">
|
||||
<span class="route-swatch" style="width:16px;height:16px;border-radius:3px;background:${esc(r.color)};flex:none"></span>
|
||||
<span class="route-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;min-width:0" title="Открыть (высоты, профиль)">${esc(r.name)} <span class="prod-hint">${r.pts} тчк${r.hasEle ? " · ⛰" : ""}</span></span>
|
||||
<input type="color" class="route-color" value="${esc(r.color)}" style="width:30px;height:26px;border:none;background:none;padding:0;flex:none">
|
||||
<button class="route-rename btn-secondary" style="padding:4px 8px;flex:none">✎</button>
|
||||
<button class="route-del btn-secondary" style="padding:4px 8px;color:#cf6679;flex:none">✕</button>
|
||||
|
|
@ -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 <ele>, 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"
|
||||
? `<b style="color:#aeea00">▬</b><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>>6°`
|
||||
: `<b style="color:#2e7d32">▬</b> низко → <b style="color:#6d4c41">▬</b> высоко`;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="route-detail" style="padding:12px">
|
||||
<div class="day-header">
|
||||
<button class="day-nav" id="rd-back">←</button>
|
||||
<h2 style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(route.name)}</h2>
|
||||
<span style="width:36px"></span>
|
||||
</div>
|
||||
<div id="rd-map" class="sd-map" style="height:300px"></div>
|
||||
${st.hasEle ? `<div class="prod-hint" id="rd-legend" style="margin-top:8px;text-align:center">${legendHtml}</div>` : ""}
|
||||
<div class="sd-row" style="margin-top:10px">
|
||||
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(st.distanceKm * 1000)}</span><span class="sd-lbl">Длина</span></div>
|
||||
<div class="sd-stat"><span class="sd-val">${st.hasEle ? "+" + st.ascent + " м" : "—"}</span><span class="sd-lbl">Набор</span></div>
|
||||
</div>
|
||||
<div class="sd-row">
|
||||
<div class="sd-stat"><span class="sd-val">${st.hasEle ? "−" + st.descent + " м" : "—"}</span><span class="sd-lbl">Сброс</span></div>
|
||||
<div class="sd-stat"><span class="sd-val">${st.hasEle ? (st.maxGrade >= 0 ? "+" : "") + st.maxGrade + " %" : "—"}</span><span class="sd-lbl">Макс. уклон</span></div>
|
||||
</div>
|
||||
${st.hasEle
|
||||
? `<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>
|
||||
</div>`;
|
||||
|
||||
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"
|
||||
? `<b style="color:#aeea00">▬</b><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>>6°`
|
||||
: `<b style="color:#2e7d32">▬</b> низко → <b style="color:#6d4c41">▬</b> высоко`;
|
||||
};
|
||||
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) {
|
|||
</div>
|
||||
<input id="s-use-fused" type="checkbox" ${GPS.config.useFusedLocation ? "checked" : ""} style="width:20px;height:20px;">
|
||||
</div>
|
||||
<div class="settings-item" style="flex-direction:column;align-items:stretch;gap:6px">
|
||||
<div class="settings-item-left">
|
||||
<div class="settings-item-title">GPXZ API ключ (высоты маршрутов)</div>
|
||||
<div class="settings-item-desc">Точные высоты Copernicus GLO-30 (лучше SRTM для уклонов). Пусто = open-elevation. Ключ хранится только на устройстве, не в git.</div>
|
||||
</div>
|
||||
<input id="s-gpxz-key" type="text" autocomplete="off" placeholder="ak_..." value="${esc(GPS.config.gpxzKey || '')}" style="width:100%;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:6px 8px;">
|
||||
</div>
|
||||
<div class="settings-item" id="s-gps-save" style="cursor:pointer;justify-content:center">
|
||||
<div class="settings-item-title" style="color:var(--primary)">Сохранить настройки GPS</div>
|
||||
</div>
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.393"
|
||||
version = "0.1.400"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.393"
|
||||
version = "0.1.400"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue