diff --git a/README.md b/README.md index 233eefa..4e318ea 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Возможности - **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). -- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). +- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию, переживает START_STICKY-перезапуск ОС). Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. - **Расчёт ккал** — физическая модель для велосипеда (мощность из gradient + Crr + CdA). MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 7d5be93..9b667a7 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1098,8 +1098,30 @@ select { background: rgba(187, 134, 252, 0.4); border-color: var(--primary); } -.rec-orient-lock { right: 8px; bottom: 48px; } -.rec-roads-toggle { right: 8px; bottom: 86px; } +.rec-rotate-btn { right: 8px; bottom: 48px; font-size: 18px; transition: transform 0.15s; } +.rec-freeze-btn { right: 8px; bottom: 86px; } +.rec-roads-toggle { right: 8px; bottom: 124px; } +.rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; } +/* Always-on compass needle: red half = North, grey = South. Rotates to point to real north. + Top-RIGHT (zoom controls are top-left), below the status-bar safe area. */ +.rec-compass { + position: absolute; top: calc(8px + var(--safe-area-top, 0px)); right: 8px; z-index: 1001; + width: 40px; height: 40px; border-radius: 50%; + background: rgba(20,20,20,0.8); border: 1px solid var(--border); + cursor: pointer; +} +/* Keep Leaflet zoom controls below the status bar on the recording map. */ +.rec-map-wrap .leaflet-top { margin-top: var(--safe-area-top, 0px); } +.rec-compass-arrow { position: absolute; inset: 0; transition: transform 0.12s linear; } +.rec-compass-arrow::before { + content: "N"; position: absolute; top: 1px; left: 50%; transform: translateX(-50%); + color: #f44336; font: 700 11px sans-serif; +} +.rec-compass-arrow::after { + content: ""; position: absolute; left: 50%; top: 14px; bottom: 6px; width: 3px; margin-left: -1.5px; + background: linear-gradient(to bottom, #f44336 0 55%, #9e9e9e 55% 100%); + border-radius: 2px; +} .gps-placeholder { position: absolute; inset: 0; @@ -1117,10 +1139,11 @@ select { font-size: 18px; } -/* Landscape: CSS rotation compensates physical rotation. - User sees element coords directly — so flex-direction:row = panel on the RIGHT. */ -.rec-screen.landscape-right, -.rec-screen.landscape-left { +/* Manual screen rotation (rotate button). 90/270 = landscape (swap dims, panel to side). + 180 is intentionally not used — it would flip the panel text upside down. + Map compensates via setBearing(heading - rotOffset). */ +.rec-screen.rot-90, +.rec-screen.rot-270 { position: fixed; width: 100vh; height: 100vw; @@ -1129,18 +1152,12 @@ select { z-index: 9999; flex-direction: row; } -.rec-screen.landscape-right { - transform: rotate(-90deg); -} -.rec-screen.landscape-left { - transform: rotate(90deg); -} -.rec-screen.landscape-right .rec-map-wrap, -.rec-screen.landscape-left .rec-map-wrap { - min-height: 0; -} -.rec-screen.landscape-right .rec-panel, -.rec-screen.landscape-left .rec-panel { +.rec-screen.rot-90 { transform: rotate(90deg); } +.rec-screen.rot-270 { transform: rotate(270deg); } +.rec-screen.rot-90 .rec-map-wrap, +.rec-screen.rot-270 .rec-map-wrap { min-height: 0; } +.rec-screen.rot-90 .rec-panel, +.rec-screen.rot-270 .rec-panel { width: 200px; flex-shrink: 0; display: flex; @@ -1148,21 +1165,17 @@ select { justify-content: space-evenly; border-left: 1px solid var(--border); } -.rec-screen.landscape-right .rec-stats, -.rec-screen.landscape-left .rec-stats { +.rec-screen.rot-90 .rec-stats, +.rec-screen.rot-270 .rec-stats { flex-direction: column; border-top: none; padding: 8px; gap: 2px; } -.rec-screen.landscape-right .rec-bottom .rec-status, -.rec-screen.landscape-left .rec-bottom .rec-status { - display: none; -} -.rec-screen.landscape-right .rec-hr-row, -.rec-screen.landscape-left .rec-hr-row { - display: none; -} +.rec-screen.rot-90 .rec-bottom .rec-status, +.rec-screen.rot-270 .rec-bottom .rec-status { display: none; } +.rec-screen.rot-90 .rec-hr-row, +.rec-screen.rot-270 .rec-hr-row { display: none; } .rec-stats { display: flex; diff --git a/frontend/js/db.js b/frontend/js/db.js index 94a97f6..36fc871 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -702,6 +702,7 @@ async function addGpsPoint(sessionId, ts, lat, lon, altGps, speedMs, seq, bleDat ["bp_sys", bleData?.bp_sys], ["bp_dia", bleData?.bp_dia], ["accel_rms", sensorData?.accel_rms], ["accel_max", sensorData?.accel_max], ["gyro_pitch", sensorData?.gyro_pitch], ["gyro_yaw", sensorData?.gyro_yaw], + ["bearing_deg", sensorData?.bearing], ]; for (const [col, val] of extra) { if (_gpsPointCols.has(col)) { cols.push(col); vals.push(val ?? null); } @@ -849,6 +850,19 @@ async function updateSessionBike(sessionId, bikeId) { await execute("UPDATE gps_sessions SET bike_id = ?1 WHERE id = ?2", [bikeId, sessionId]); } +// Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}]. +// Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline. +async function updatePointsSrtm(rows) { + const BATCH = 200; + for (let i = 0; i < rows.length; i += BATCH) { + const batch = rows.slice(i, i + BATCH).filter(r => r.id != null && r.srtm != null && isFinite(r.srtm)); + if (!batch.length) continue; + const cases = batch.map(r => `WHEN ${parseInt(r.id, 10)} THEN ${Number(r.srtm)}`).join(" "); + const ids = batch.map(r => parseInt(r.id, 10)).join(","); + await execute(`UPDATE gps_points SET altitude_srtm_m = CASE id ${cases} END WHERE id IN (${ids})`, []); + } +} + // --------------------------------------------------------------------------- // Bike maintenance log // --------------------------------------------------------------------------- @@ -1046,6 +1060,7 @@ window.DB.saveBike = saveBike; window.DB.updateBike = updateBike; window.DB.deleteBike = deleteBike; window.DB.updateSessionBike = updateSessionBike; +window.DB.updatePointsSrtm = updatePointsSrtm; window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog; window.DB.addBikeMaintenance = addBikeMaintenance; window.DB.deleteBikeMaintenance = deleteBikeMaintenance; @@ -1232,6 +1247,7 @@ async function runMigrations() { if (!gpsCols.includes("accel_max")) await execute("ALTER TABLE gps_points ADD COLUMN accel_max REAL"); if (!gpsCols.includes("gyro_pitch")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_pitch REAL"); if (!gpsCols.includes("gyro_yaw")) await execute("ALTER TABLE gps_points ADD COLUMN gyro_yaw REAL"); + if (!gpsCols.includes("bearing_deg")) await execute("ALTER TABLE gps_points ADD COLUMN bearing_deg REAL"); // --- Cooking methods table --- await execute(`CREATE TABLE IF NOT EXISTS cooking_methods ( diff --git a/frontend/js/gps.js b/frontend/js/gps.js index c466b92..a3b2ce5 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -7,7 +7,7 @@ const GPS = {}; // Configurable thresholds (can be changed in settings) GPS.config = { minSpeedMs: 0.42, // 1.5 km/h — below this = stationary - minAccuracyM: 20, // skip fixes less accurate than this + minAccuracyM: 50, // hard gate: reject fixes coarser than this (cell/wifi ~800m). Weak but real GPS (<=50m) kept. minDistanceM: 1, // skip points closer than this maxSpeedKmh: 60, // above this = GPS glitch, skip segment gapThresholdSec: 30, // gap longer than this = new track segment @@ -20,11 +20,14 @@ GPS.config = { bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов) walkAvgSpeedKmh: 5, // средняя скорость ходьбы runAvgSpeedKmh: 10, // средняя скорость бега + useFusedLocation: true, // fused-провайдер (Google Play services) — как Яндекс/Google Maps. + // raw GPS_PROVIDER на устройстве приложению не отдаёт фиксы; fused смешивает GPS+ГЛОНАСС+сеть. + autoFinishIdleMin: 15, // авто-завершить тренировку после N мин без движения (забыл «Конец») }; // Load saved config try { - const saved = localStorage.getItem("baldav_gps_config"); + let saved = localStorage.getItem("baldav_gps_config"); if (saved) Object.assign(GPS.config, JSON.parse(saved)); } catch {} @@ -32,8 +35,47 @@ GPS.saveConfig = function() { localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config)); }; +// One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default +// tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle. +try { + if (localStorage.getItem("baldav_fused_migrated") !== "1") { + GPS.config.useFusedLocation = true; + localStorage.setItem("baldav_fused_migrated", "1"); + GPS.saveConfig(); + } +} catch {} + +// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes +// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives. +// Only touch the value if it's still the old default; respect a user-set threshold. +try { + if (localStorage.getItem("baldav_minacc50_migrated") !== "1") { + if (GPS.config.minAccuracyM <= 20) GPS.config.minAccuracyM = 50; + localStorage.setItem("baldav_minacc50_migrated", "1"); + GPS.saveConfig(); + } +} catch {} + // Haversine distance (meters) // --------------------------------------------------------------------------- +// Compass bearing in degrees (0=N, clockwise) from point 1 to point 2. +GPS.bearing = function(lat1, lon1, lat2, lon2) { + let toRad = d => d * Math.PI / 180; + let f1 = toRad(lat1), f2 = toRad(lat2), dl = toRad(lon2 - lon1); + let y = Math.sin(dl) * Math.cos(f2); + let x = Math.cos(f1) * Math.sin(f2) - Math.sin(f1) * Math.cos(f2) * Math.cos(dl); + return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; +}; + +// Point [lat, lon] at `meters` distance along `bearingDeg` (0=N, CW) from a start point. +GPS.destination = function(lat, lon, bearingDeg, meters) { + let R = 6371000, d = meters / R, br = bearingDeg * Math.PI / 180; + let f1 = lat * Math.PI / 180, l1 = lon * Math.PI / 180; + let f2 = Math.asin(Math.sin(f1) * Math.cos(d) + Math.cos(f1) * Math.sin(d) * Math.cos(br)); + let l2 = l1 + Math.atan2(Math.sin(br) * Math.sin(d) * Math.cos(f1), Math.cos(d) - Math.sin(f1) * Math.sin(f2)); + return [f2 * 180 / Math.PI, ((l2 * 180 / Math.PI) + 540) % 360 - 180]; +}; + GPS.distance = function(lat1, lon1, lat2, lon2) { const R = 6371000; const toRad = d => d * Math.PI / 180; @@ -61,7 +103,9 @@ GPS.computeStats = function(points, weightKg, opts) { // Step 1: Extract raw altitudes and filter outliers. // An outlier is a point that jumps far from its neighbors and returns back — // e.g. [45, 45, 52, 45, 45] — the 52 is a GPS altitude glitch. - const rawAlt = sorted.map(p => p.altitude ?? p.altitude_gps_m ?? null); + // Prefer terrain (SRTM/open-elevation) altitude when available — GPS altitude is noisy + // and the stuck-filter destroys its real signal. Terrain is clean and accurate for slope. + const rawAlt = sorted.map(p => p.altitude_srtm_m ?? p.altitude ?? p.altitude_gps_m ?? null); const filteredAlt = rawAlt.slice(); for (let i = 1; i < filteredAlt.length - 1; i++) { if (filteredAlt[i] == null) continue; @@ -142,36 +186,39 @@ GPS.computeStats = function(points, weightKg, opts) { const gearRatio = hasGearData ? opts.chainringTeeth / opts.cogTeeth : 0; for (let i = 1; i < sorted.length; i++) { - const p = sorted[i], prev = sorted[i - 1]; - const d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon); + let p = sorted[i], prev = sorted[i - 1]; + let d = GPS.distance(prev.lat, prev.lon, p.lat, p.lon); + let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; + let v = dt > 0 ? d / dt : 0; // m/s - const dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; + let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; + let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); + // Count a REAL segment: not a dead-reckoning guess, not a teleport glitch. We do NOT + // drop long-gap segments here — if GPS was lost briefly, the straight line is a fair + // lower-bound of real travel (implied speed v<=segMaxMs already rejects teleports). + // DR fakes are excluded via !isEstimated, so no gap-time gate is needed. + let validSeg = !isEstimated && dt > 0 && v <= segMaxMs; - distance += d; - - const v = dt > 0 ? d / dt : 0; // m/s - speeds.push(v); - - // Elevation: accumulate from smoothed altitude with 1m dead zone. - // Use consecutive smoothed points — the 50m window gradient is only for kcal. - if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) { - const diff = smoothedAlt[i] - smoothedAlt[i - 1]; - if (diff > 1) elevGain += diff; - else if (diff < -1) elevLoss += Math.abs(diff); + if (validSeg) { + distance += d; + speeds.push(v); + // Elevation: accumulate from smoothed altitude with 1m dead zone. + if (smoothedAlt[i] != null && smoothedAlt[i - 1] != null) { + let diff = smoothedAlt[i] - smoothedAlt[i - 1]; + if (diff > 1) elevGain += diff; + else if (diff < -1) elevLoss += Math.abs(diff); + } } // Kcal calculation per segment - const isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; if (isEstimated) { - // Estimated point (GPS dead, accel confirmed movement): use walking MET - const walkMet = 3.5; // moderate walking ~4-5 km/h + // Estimated point (GPS dead, accel confirmed movement): use walking MET. + // Time-based only — no distance (dead-reckoning drift is not real travel). + let walkMet = 3.5; // moderate walking ~4-5 km/h totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200; continue; } - if (dt <= 0) continue; - // Skip if speed is unreasonable (teleportation/glitch) - const segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); - if (v > segMaxMs) continue; + if (!validSeg) continue; if (v < 0.3) { // Standing still: only basal metabolic rate @@ -397,7 +444,8 @@ GPS._startNativeService = async function() { const isWalk = type === "walk" || type === "run"; const minSpd = isWalk ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs; const maxSpd = isWalk ? (GPS.config.walkMaxSpeedKmh / 3.6) : (GPS.config.maxSpeedKmh / 3.6); - const cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}`; + let fused = GPS.config.useFusedLocation ? 1 : 0; + let cmd = `start:${minSpd}:${GPS.config.minAccuracyM}:${GPS.config.minDistanceM}:${maxSpd}:${fused}`; await window.__TAURI__.core.invoke("gps_service_command", { cmd }); console.log("[GPS] Native service started with config:", cmd); GPS.recording.nativeService = true; @@ -413,6 +461,14 @@ GPS._stopNativeService = async function() { } catch {} }; +// Ask Android for background location ("Allow all the time") — required to record +// while the screen is off. On Android 11+ this opens the system location settings. +GPS.requestBackgroundLocation = async function() { + try { + await window.__TAURI__.core.invoke("gps_service_command", { cmd: "reqbgloc" }); + } catch (e) { console.log("[GPS] reqbgloc:", e); } +}; + // BLE HR monitor — tied to GPS session (start→stop), but NOT paused when GPS pauses. // HR keeps measuring even when rider is stationary. GPS._startHrMonitor = async function() { @@ -474,6 +530,7 @@ GPS._readNativePoints = async function() { points.push({ lat: d.lat, lon: d.lon, altitude: d.alt, speed_ms: d.speed, accuracy: d.acc, + bearing: (d.bearing != null && d.bearing >= 0) ? d.bearing : null, // GPS course over ground ts: new Date(d.ts).toISOString(), accel_rms: d.accel_rms, accel_max: d.accel_max, gyro_pitch: d.gyro_pitch, gyro_yaw: d.gyro_yaw, @@ -503,9 +560,13 @@ GPS._processIncomingPoint = async function(p) { return; } - // Determine if moving - const speed = p.speed_ms != null ? p.speed_ms : 0; - let isMoving = speed > GPS.config.minSpeedMs; + // Determine if moving — same per-activity threshold as the native service + // (walk/run → walkMinSpeedMs, else minSpeedMs). 0 means "always moving": + // no auto-pause, no sticking on "начните движение". + let speed = p.speed_ms != null ? p.speed_ms : 0; + let mvType = GPS.recording.activityType; + let effMinSpeed = (mvType === "walk" || mvType === "run") ? GPS.config.walkMinSpeedMs : GPS.config.minSpeedMs; + let isMoving = effMinSpeed <= 0 ? true : speed > effMinSpeed; if (!isMoving && GPS.recording.lastMovingPoint) { const d = GPS.distance(GPS.recording.lastMovingPoint.lat, GPS.recording.lastMovingPoint.lon, p.lat, p.lon); const dt = (new Date(p.ts) - new Date(GPS.recording.lastMovingPoint.ts)) / 1000; @@ -627,6 +688,17 @@ GPS.resumeRecording = function() { if (GPS.recording.onStateChange) GPS.recording.onStateChange("manual_resumed"); }; +// True when an active session has had no movement for autoFinishIdleMin minutes — +// used to auto-finish a session the user forgot to stop, so the next outing is separate. +GPS.shouldAutoFinish = function() { + if (!GPS.recording.active || GPS.recording.manualPaused) return false; + if (!GPS.recording.sessionId) return false; // no session yet (no movement) + let last = GPS.recording.lastMovingPoint; + if (!last) return false; + let idleMs = Date.now() - new Date(last.ts).getTime(); + return idleMs > (GPS.config.autoFinishIdleMin || 15) * 60000; +}; + // Recover orphaned recording session on app startup GPS.recoverSession = async function() { const saved = localStorage.getItem("baldav_gps_active"); @@ -746,6 +818,7 @@ GPS.startRecording = async function(activityType, weightKg) { altitude: pos.coords.altitude, speed_ms: pos.coords.speed, accuracy: pos.coords.accuracy, + bearing: (pos.coords.heading != null && !isNaN(pos.coords.heading)) ? pos.coords.heading : null, ts: new Date(pos.timestamp).toISOString(), }); }, diff --git a/frontend/js/main.js b/frontend/js/main.js index 1dcdb92..bbc5aea 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -2618,25 +2618,34 @@ async function showAddGoalMarkerModal() { // --------------------------------------------------------------------------- async function fetchElevations(points) { - try { - // Batch up to 100 points per request - let elevations = new Array(points.length).fill(null); - for (let i = 0; i < points.length; i += 100) { - let batch = points.slice(i, i + 100); - let lats = batch.map(p => p.lat.toFixed(5)).join(","); - let lons = batch.map(p => p.lon.toFixed(5)).join(","); - let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`); - if (!resp.ok) continue; - let data = await resp.json(); - if (data.elevation) { - for (let j = 0; j < data.elevation.length; j++) elevations[i + j] = data.elevation[j]; + // 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. + let elevations = new Array(points.length).fill(null); + for (let i = 0; i < points.length; i += 100) { + let batch = points.slice(i, i + 100); + let got = null; + try { + let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }), + }); + if (resp.ok) { + let d = await resp.json(); + if (d.results) got = d.results.map(r => r.elevation); } + } catch (e) { console.log("[open-elevation] batch fail:", e.message); } + if (!got) { + try { + let lats = batch.map(p => p.lat.toFixed(5)).join(","); + let lons = batch.map(p => p.lon.toFixed(5)).join(","); + let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`); + if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; } + } catch (e) { console.log("[open-meteo] batch fail:", e.message); } } - return elevations; - } catch (e) { - console.log("[Elevation API] failed:", e.message); - return null; + if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; } + return elevations.some(x => x != null) ? elevations : null; } function showDrawRouteScreen(container) { @@ -2858,33 +2867,154 @@ function renderRecordingInline(container) { let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map); let trailLine = L.polyline([], { color: "#bb86fc", weight: 5 }).addTo(map); - // Compass heading + landscape detection from device orientation - let currentLandscape = null; // null | 'right' | 'left' - let orientMode = "off"; // "off" | "auto" | "lock" - let lastBeta = 0; - let lastGamma = 0; - let smoothedHeading = null; - let recScreen = document.querySelector('.rec-screen'); + // Map orientation: a mode button picks the source — magnetic compass or GPS course. + // Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show + // BOTH directions (orange = compass, green = GPS course) for visual comparison. + let rotOffset = 0; // 0/90/180/270 — whole-screen rotation + let compassFrozen = false; // X button: freeze current heading + let frozenHeading = 0; // heading captured at the moment of freezing + let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW) + let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW) + let prevCoursePt = null; // for the computed-bearing fallback + let lastPos = null; // last position, for drawing the direction arrows + let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course" + // Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing + // north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha). + let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0; + let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap + let recScreen = container.querySelector(".rec-screen"); let btnWrap = mapEl.parentElement; - // Orient button — 3 states: X (off) → A (auto) → L (lock) - let lockBtn = document.createElement("button"); - lockBtn.className = "rec-map-btn rec-orient-lock"; - lockBtn.textContent = "X"; - lockBtn.onclick = () => { - if (orientMode === "off") { orientMode = "auto"; lockBtn.textContent = "A"; lockBtn.classList.remove("active"); } - else if (orientMode === "auto") { orientMode = "lock"; lockBtn.textContent = "L"; lockBtn.classList.add("active"); } - else { orientMode = "off"; lockBtn.textContent = "X"; lockBtn.classList.remove("active"); - // Reset landscape and bearing - currentLandscape = null; - if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left'); - let nav = document.querySelector('.bottom-nav'); - if (nav) nav.style.display = ''; - map.setBearing(0); - setTimeout(() => map.invalidateSize(), 100); - } + // Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north. + let compassEl = document.createElement("div"); + compassEl.className = "rec-compass"; + compassEl.innerHTML = '
'; + compassEl.title = "Тап = выставить север по текущему направлению"; + compassEl.onclick = () => { + northOffset = lastRawHeading; + localStorage.setItem("baldav_north_offset2", String(northOffset)); + smoothedHeading = 0; // snap to north-up immediately + applyBearing(); + if (typeof showToast === "function") showToast("Север откалиброван"); }; - btnWrap.appendChild(lockBtn); + btnWrap.appendChild(compassEl); + let compassArrow = compassEl.firstChild; + function updateNorthArrow(bearing) { + compassArrow.style.transform = `rotate(${-bearing}deg)`; + } + + // Mode button: compass ("К") ↔ GPS course ("GPS"). + let modeBtn = document.createElement("button"); + modeBtn.className = "rec-map-btn rec-mode-btn"; + modeBtn.textContent = orientMode === "course" ? "GPS" : "К"; + modeBtn.title = "Поворот карты: компас / GPS-курс"; + modeBtn.onclick = () => { + orientMode = orientMode === "compass" ? "course" : "compass"; + localStorage.setItem("baldav_orient_mode", orientMode); + modeBtn.textContent = orientMode === "course" ? "GPS" : "К"; + applyBearing(); + }; + btnWrap.appendChild(modeBtn); + + // Two direction arrows drawn on the map from the current position (always visible): + // orange = compass, green = GPS course. Geo-anchored, so they turn with the map. + let compassDirLine = L.polyline([], { color: "#ff9800", weight: 4, opacity: 0.9, dashArray: "6,6" }).addTo(map); + let compassDirTip = L.circleMarker([0, 0], { radius: 5, color: "#ff9800", fillColor: "#ff9800", fillOpacity: 1, weight: 0 }).addTo(map); + let courseDirLine = L.polyline([], { color: "#4caf50", weight: 5, opacity: 0.95 }).addTo(map); + let courseDirTip = L.circleMarker([0, 0], { radius: 6, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1, weight: 0 }).addTo(map); + function updateArrows() { + if (!lastPos) return; + // Both arrows point the TRUE geographic direction (CW from north). + if (smoothedHeading != null) { + let end = GPS.destination(lastPos.lat, lastPos.lon, smoothedHeading, 40); + compassDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]); + compassDirTip.setLatLng(end); + } + if (courseHeading != null) { + let end = GPS.destination(lastPos.lat, lastPos.lon, courseHeading, 70); // travel direction (GPS) + courseDirLine.setLatLngs([[lastPos.lat, lastPos.lon], end]); + courseDirTip.setLatLng(end); + } + } + + // Base heading = chosen source. GPS mode rotates by course when we have it, else falls + // back to the compass so the map ALWAYS rotates to a known-good heading (never static). + function baseHeading() { + // Course-up: setBearing puts geo-dir (360-theta) up and net rotation = h, so to put + // courseHeading at the top we need h = 360 - courseHeading (derived from leaflet-rotate). + if (orientMode === "course" && courseHeading != null) return (360 - courseHeading + 360) % 360; + return smoothedHeading != null ? smoothedHeading : 0; + } + + function applyBearing() { + let h = compassFrozen ? frozenHeading : baseHeading(); + let bearing = h - rotOffset; + map.setBearing(bearing); + updateNorthArrow(bearing); + } + + // Update GPS course from a point. Prefer the chip's course-over-ground (p.bearing); + // fall back to bearing between positions ≥15 m apart (long baseline = less jitter). + function updateCourse(p) { + if (!p) return; + lastPos = { lat: p.lat, lon: p.lon }; + let b = null; + if (p.bearing != null && (p.speed_ms == null || p.speed_ms > 1.0)) { + b = p.bearing; + } else if (prevCoursePt && GPS.distance(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon) >= 15) { + b = GPS.bearing(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon); + prevCoursePt = { lat: p.lat, lon: p.lon }; + } + if (!prevCoursePt) prevCoursePt = { lat: p.lat, lon: p.lon }; + if (b != null) { + if (courseHeading === null) courseHeading = b; + else { + let diff = b - courseHeading; + if (diff > 180) diff -= 360; + if (diff < -180) diff += 360; + courseHeading = ((courseHeading + diff * 0.4) % 360 + 360) % 360; + } + if (orientMode === "course" && !compassFrozen) applyBearing(); + } + updateArrows(); + } + + function applyScreenRotation() { + if (recScreen) { + recScreen.classList.remove("rot-90", "rot-270"); + if (rotOffset) recScreen.classList.add("rot-" + rotOffset); + } + let nav = document.querySelector(".bottom-nav"); + if (nav) nav.style.display = rotOffset ? "none" : ""; + map.invalidateSize(); + } + + // Rotation button — 4 states, each tap +90°. Arrow shows chosen "up". + let rotBtn = document.createElement("button"); + rotBtn.className = "rec-map-btn rec-rotate-btn"; + rotBtn.innerHTML = "↑"; // ↑ + rotBtn.title = "Повернуть карту на 90°"; + rotBtn.onclick = () => { + // 3 readable states: portrait + both landscapes. 180° is skipped — + // it would flip the panel text upside down ("низ" would end up on top). + rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0); + applyScreenRotation(); + applyBearing(); + }; + btnWrap.appendChild(rotBtn); + + // Freeze button — X: lock/unlock compass following (does NOT rotate) + let freezeBtn = document.createElement("button"); + freezeBtn.className = "rec-map-btn rec-freeze-btn"; + freezeBtn.textContent = "X"; + freezeBtn.title = "Заморозить поворот карты"; + freezeBtn.onclick = () => { + compassFrozen = !compassFrozen; + if (compassFrozen) frozenHeading = baseHeading(); + freezeBtn.classList.toggle("active", compassFrozen); + applyBearing(); + }; + btnWrap.appendChild(freezeBtn); // Roads toggle button let roadsBtn = document.createElement("button"); @@ -2928,89 +3058,41 @@ function renderRecordingInline(container) { if (localStorage.getItem("baldav_show_roads") === "1") roadsBtn.click(); function handleOrientation(e) { - if (orientMode === "off") return; // no compass, no landscape - let heading = e.webkitCompassHeading || e.alpha || 0; - let beta = e.beta || 0; - let gamma = e.gamma || 0; - lastBeta = beta; - lastGamma = gamma; - - // Gimbal lock compensation: when phone tilts past horizontal, - // browser flips alpha by 180° (Euler angle discontinuity). - // Detect by checking if phone is "face-down" and compensate. - if (currentLandscape !== null && Math.abs(beta) > 90) { - heading = heading + 180; - } - if (currentLandscape === null && Math.abs(gamma) > 90) { - heading = heading + 180; - } - - // Landscape detection with hysteresis: enter at 75, exit at 25 - if (orientMode === "auto") { - let enterThreshold = 75; - let exitThreshold = 25; - let newLandscape = currentLandscape; - if (currentLandscape === null) { - if (gamma > enterThreshold) newLandscape = 'right'; - else if (gamma < -enterThreshold) newLandscape = 'left'; - } else if (currentLandscape === 'right') { - if (gamma < exitThreshold) newLandscape = null; - } else if (currentLandscape === 'left') { - if (gamma > -exitThreshold) newLandscape = null; - } - - if (newLandscape !== currentLandscape) { - currentLandscape = newLandscape; - if (recScreen) { - recScreen.classList.remove('landscape-right', 'landscape-left'); - if (newLandscape) recScreen.classList.add('landscape-' + newLandscape); - } - let nav = document.querySelector('.bottom-nav'); - if (nav) nav.style.display = newLandscape ? 'none' : ''; - setTimeout(() => map.invalidateSize(), 100); - } - } - - // Debug: show all 3 axes on map overlay - let dbgEl = document.querySelector("#rec-orient-debug"); - if (!dbgEl) { - dbgEl = document.createElement("div"); - dbgEl.id = "rec-orient-debug"; - dbgEl.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1001;background:rgba(0,0,0,0.7);color:#0f0;font:11px monospace;padding:4px 6px;border-radius:4px;pointer-events:none;"; - mapEl.parentElement.appendChild(dbgEl); - } - dbgEl.textContent = `a:${Math.round(e.alpha||0)} b:${Math.round(e.beta||0)} g:${Math.round(e.gamma||0)} h:${Math.round(heading)} m:${orientMode}`; - - // Smooth heading with EMA (exponential moving average) — reduces jitter from bumps - // Factor 0.3 = responsive but smooth (~3 samples to settle, 300ms at 10Hz) - let finalHeading = heading; - if (smoothedHeading === null) { - smoothedHeading = finalHeading; - } else { - let diff = finalHeading - smoothedHeading; + let raw; + if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading + else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion + else return; + lastRawHeading = raw; // for the calibration tap + let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset + // EMA smoothing to reduce needle jitter (factor 0.3). + if (smoothedHeading === null) smoothedHeading = heading; + else { + let diff = heading - smoothedHeading; if (diff > 180) diff -= 360; if (diff < -180) diff += 360; - smoothedHeading = (smoothedHeading + diff * 0.3) % 360; - if (smoothedHeading < 0) smoothedHeading += 360; - finalHeading = smoothedHeading; + smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360; } - - // Map bearing always updates in auto and lock modes - let bearingOffset = 0; - if (currentLandscape === 'right') bearingOffset = 90; - else if (currentLandscape === 'left') bearingOffset = -90; - map.setBearing(finalHeading + bearingOffset); + // DEBUG overlay: raw sensor values on screen (to verify calibration). + let dbg = document.getElementById("orient-dbg"); + if (!dbg) { + dbg = document.createElement("div"); + dbg.id = "orient-dbg"; + dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;"; + mapEl.parentElement.appendChild(dbg); + } + dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading); + updateArrows(); // keep the orange compass arrow live even in GPS mode + if (!compassFrozen) applyBearing(); } - let throttledOrientation = L.Util.throttle(handleOrientation, 100); - let orientEvent = 'ondeviceorientationabsolute' in window - ? 'deviceorientationabsolute' : 'deviceorientation'; + let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation'; window.addEventListener(orientEvent, throttledOrientation); + applyBearing(); // initial: north-up until first compass reading GPS.recording._compassCleanup = () => { window.removeEventListener(orientEvent, throttledOrientation); - if (recScreen) recScreen.classList.remove('landscape-right', 'landscape-left'); - let nav = document.querySelector('.bottom-nav'); - if (nav) nav.style.display = ''; + if (recScreen) recScreen.classList.remove("rot-90", "rot-270"); + let nav = document.querySelector(".bottom-nav"); + if (nav) nav.style.display = ""; }; let posMarker = null; @@ -3075,6 +3157,9 @@ function renderRecordingInline(container) { return; } + // Orient map by direction of movement (GPS course) + update direction arrows + updateCourse(p); + // Speed let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0; speedBuf.push(curSpeed); @@ -3206,6 +3291,7 @@ function renderRecordingInline(container) { map.panTo(last); if (posMarker) map.removeLayer(posMarker); posMarker = L.circleMarker(last, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map); + updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing) } let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts()); if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); @@ -3244,9 +3330,17 @@ function setupLiveUpdates(container) { if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч"; }; - // Timer for duration display - timer = setInterval(() => { + // Timer for duration display + auto-finish watchdog for a forgotten session + timer = setInterval(async () => { if (!GPS.recording.active) { clearInterval(timer); return; } + if (GPS.shouldAutoFinish()) { + clearInterval(timer); + if (GPS.recording._compassCleanup) GPS.recording._compassCleanup(); + await GPS.stopRecording(); + showToast("Тренировка завершена автоматически — нет движения"); + renderScreen("gps"); + return; + } let sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000); if (timeEl) timeEl.textContent = GPS.formatDuration(sec); }, 1000); @@ -3524,6 +3618,7 @@ async function showSessionDetail(sessionId) { ${session.activity_type === 'bike' ? `