GPS stats: exclude manual pauses; reject unrealistic altitude steps

Manual pause (hand-paused at foot of a climb, walked up, resumed) was
counted as riding: computeStats bridged the GPS gap for distance and
elevRef survived the gap, leaking the on-foot climb into elevation gain.

- gps_sessions.pauses (JSON) now persists manual-pause intervals
- computeStats(opts.pauses): a segment crossing a pause is excluded
  entirely (no distance/speed/elevation/kcal), elevRef reset, paused
  time subtracted from duration; automatic GPS gaps still interpolated
- altitude glitch guard: an adjacent smoothed step steeper than
  maxRealGradePct (100% = 45deg) is not banked and does not move elevRef
  (covers Android no-fix 0.0); a smooth change to any value (a real 0
  near a river) still passes
- recorder writes "alt":null when hasAltitude() is false (real fixed 0 kept)
- pauses threaded through live-stop, recalculateSession, recovery and bulk recompute
- new-tracks only (old tracks never stored pauses; no manual segment-cut UI)

Also: track-detail header shows a click-to-copy track ID.
This commit is contained in:
4aiman 2026-09-07 13:53:40 +03:00
commit ec77f67cab
7 changed files with 178 additions and 20 deletions

View file

@ -103,7 +103,7 @@
| `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m |
| `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы |
| `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_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз) |
| `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 (`{lat,lon,ele?}`), created_at |
| `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active |
@ -198,6 +198,13 @@ segMaxMs = maxSpeedKmh/3.6 (вело) или walkMaxSpeedKmh/3.6 (пеш); gapTh
норма (иначе) → segDist = хорда (haversine)
distance += segDist; высота/maxSpeed — только на «норме»; ккал в разрыве/estimated = walk-MET по времени
Зависит только от ts+speed (не от флага estimated) → live == «Стоп» == пересчёт. gapThreshold настраивается, применяется при пересчёте.
Ручная пауза (opts.pauses = [{startTs,endTs}] мс): сегмент, пересекающий паузу (prevMs<endTs И pMs>startTs),
исключается ЦЕЛИКОМ — ни дистанции, ни высоты, ни ккал, ни времени (pausedSec вычитается из duration), elevRef=null
(перепад, набранный пешком за паузу, не банкуется на первом сегменте после резюма). Авто-GPS-гэп (без паузы) — считается как раньше.
Глюк-гард высоты: соседний сглаженный перепад с грейдом > maxRealGradePct (1.0 = 100% = 45°) не банкуется и не двигает elevRef
(скачок/ступенька, в т.ч. Android no-fix 0.0). Плавный приход к любому значению (реальный 0 у реки) проходит.
Kotlin пишет "alt":null без фикса высоты (hasAltitude()), честный 0 с фиксом сохраняется. Паузы: колонка gps_sessions.pauses (JSON).
```
### Ходьба/бег — MET-таблица

View file

@ -0,0 +1,56 @@
# План: ручные паузы и глюки высоты в расчёте статистики трека
## Context (зачем)
Две ошибки в `GPS.computeStats` (frontend/js/gps.js):
1. **Ручная пауза считается как езда.** Пользователь внизу подъёма ставит паузу руками,
идёт пешком, наверху снимает. `computeStats` через гэп всё равно считает дистанцию
по позиции, а `elevRef` переживает гэп → первый сегмент после резюма сравнивается с
высотой ДО паузы → перепад подъёма утекает в elevGain. Нужно: **ручную паузу
исключать** (ни дистанции, ни перепада, ни времени, ни ккал), а **автоматический
GPS-гэп в движении — считать как сейчас** (интерполяция).
2. **Глюки высоты, включая честный 0.** Android при отсутствии фикса высоты пишет
`location.altitude=0.0` → в данных 146 точек `altitude_gps_m=0`, которые как «реальная
высота» дают ложные перепады. НО плавный приход к 0 у реки — реальная высота
(пользователь часто у рек). Правило: отбрасывать **нереальный уклон** (скачок /
грейд >45° = >100%), а плавные значения (в т.ч. 0) — оставлять.
Решения (по ответам): **только для НОВЫХ треков** (ручного «разрезания» старых не делаем).
Паузы отслеживаются в `GPS.recording.pauses` (`[{startTs,endTs,startLat,...}]`, мс epoch),
но в БД НЕ сохранялись — начали сохранять. Данные разбираются офлайн через `tools/track/`.
## Изменения (финал, как реализовано)
1. **Persist пауз (БД).** Колонка `pauses TEXT` в `gps_sessions` тем же JS-путём ADD COLUMN
под `PRAGMA table_info(gps_sessions)`-гардом (db.js ~1540). `finishGpsSession(...pauses)`
получил аргумент `pauses`: `hasPauses = pauses !== undefined` → пишет `JSON.stringify(pauses||[])`,
`undefined` оставляет колонку как есть. `getGpsSession` парсит `pauses` из JSON.
2. **`computeStats(points, wKg, opts)` — исключение ручных пауз.** `opts.pauses` (мс epoch) →
`inManualPause(aMs,bMs) = pauses.some(z => aMs<z.e && bMs>z.s)`. В сегментном цикле:
`if (inManualPause(prevMs,pMs)) { pausedSec += dt; elevRef = null; continue; }` — целиком
исключает сегмент (дистанция/скорость/высота/ккал), сбрасывает elevRef. `duration -= pausedSec`.
3. **Фильтр высоты по уклону.** В банковании перепада: `glitch = prevAlt!=null && segDist>0 &&
|smoothedAlt[i]-prevAlt|/segDist > MAX_GRADE` (`MAX_GRADE = config.maxRealGradePct ?? 1.0`
= 100% = 45°). При glitch — не банковать и не двигать elevRef. Плавные изменения (0 у реки) проходят.
Существующие фильтры (выброс >4м, «застревание», сглаживание) оставлены.
4. **Запись высоты (Kotlin `GpsRecorderService.kt` ~465).** `altStr = if (location.hasAltitude())
location.altitude.toString() else "null"` → `"alt":${altStr}`. Нет фикса → null (не 0). Идёт вперёд.
5. **Проброс пауз через вызовы `computeStats`/`finishGpsSession`:** live-стоп (recPauses),
`recalculateSession` recorded-ветка (sessPauses), recovery (`s.pauses||[]`),
`recomputeAllStats` bulk (bulkPauses из session.pauses), `showSessionDetail` (main.js:
`{pauses: session.pauses||[]}` + вычет pauseMs из duration).
Ручная-маршрут ветка recalculateSession паузы не передаёт (у нарисованных маршрутов пауз нет).
## Файлы
- `frontend/js/gps.js` — computeStats (паузы + фильтр уклона), проброс в finish/recalculate/recovery/stop/bulk.
- `frontend/js/db.js` — миграция `pauses`, finishGpsSession, getGpsSession.
- `frontend/js/main.js` — showSessionDetail: передать `session.pauses`, вычесть pauseMs.
- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt` — alt=null без фикса.
## Верификация
1. `node --check` на трёх JS — чисто.
2. `./build.sh -d -i android` → короткая поездка с ручной паузой на склоне → в деталях трека
участок паузы не учтён (нет дистанции/перепада), в БД `pauses` = JSON; треки с 0-глюками не раздуты,
плавный 0 у реки — профиль цел.
## Не трогаем
- Ручной инструмент «выделить/исключить участок» — отложено.
- Старые треки ретроактивно не правим (паузы не были сохранены). Seed/продукты не трогаем.

View file

@ -0,0 +1,28 @@
# Сессия 2026-09-07: ручные паузы и глюки высоты в статистике трека
## Контекст
На последнем ~56км-треке подъём пройден пешком: внизу пауза руками, наверху снята. BalDav
мостил GPS-гэп (дистанция + перепад высоты через elevRef, переживший гэп) как езду. Плюс
Android пишет `altitude=0.0` без фикса высоты (146 точек в данных), что как «реальная высота»
раздувает набор высоты. Пользователь часто у рек, где 0 может быть честным — нельзя тупо
выкидывать 0. Правило: исключать нереальный уклон (>45°/>100%), плавные значения (в т.ч. 0) хранить.
## Действия (по плану docs/plans/2026-09-07-manual-pauses-altitude-glitches.md)
Работали по утверждённому плану. Реализовано:
- db.js: колонка `gps_sessions.pauses TEXT` (JS ADD COLUMN миграция), `finishGpsSession(...,pauses)`
с `hasPauses`-гардом (undefined = не трогать колонку), `getGpsSession` парсит JSON.
- gps.js `computeStats`: `opts.pauses` → `inManualPause`; сегмент, пересекающий паузу,
исключается целиком (дистанция/скорость/высота/ккал), `elevRef=null`, `pausedSec` вычитается
из duration. Глюк-гард высоты: соседний перепад с грейдом > `maxRealGradePct` (1.0=45°) не
банкуется и не двигает elevRef. Проброс пауз во все вызовы: live-стоп, recalculateSession
(recorded), recovery, recomputeAllStats (bulk), showSessionDetail.
- main.js showSessionDetail: передаёт `session.pauses`, вычитает pauseMs из длительности.
- Kotlin GpsRecorderService: `alt=null` без `hasAltitude()`, честный 0 с фиксом сохраняется.
## Результат
Только для НОВЫХ треков (старые паузы не сохранялись — ретро-фикса нет; UI ручного разрезания
отложен). Все 3 JS проходят `node --check`. PROJECT_MAP обновлён (computeStats-раздел + колонка pauses).
## Следующие шаги
- `./build.sh -d -i android`, записать короткую поездку с ручной паузой на склоне, проверить:
участок паузы не в статистике, `pauses` в БД = JSON, треки с 0-глюками не раздуты.

View file

@ -779,14 +779,19 @@ async function addGpsPointsBulk(sessionId, points, startSeq) {
return inserted;
}
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) {
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) {
// Guard against NaN — SQLite may reject or corrupt NaN values
const safe = v => (v != null && !isNaN(v)) ? v : null;
// pauses: array of manual-pause intervals; store as JSON. undefined = leave column as-is.
const hasPauses = pauses !== undefined;
const pausesJson = hasPauses ? JSON.stringify(pauses || []) : null;
await execute(
`UPDATE gps_sessions SET end_time=?1, total_distance_m=?2, total_kcal=?3,
avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7
avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7${hasPauses ? ", pauses=?9" : ""}
WHERE id=?8`,
[endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id]
hasPauses
? [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id, pausesJson]
: [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id]
);
}
@ -843,7 +848,10 @@ async function getGpsSessionsForDate(date) {
async function getGpsSession(id) {
const rows = await select("SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE id = ?1", [id]);
return rows.length ? rows[0] : null;
if (!rows.length) return null;
const s = rows[0];
if (typeof s.pauses === "string") { try { s.pauses = JSON.parse(s.pauses); } catch { s.pauses = []; } }
return s;
}
async function getGpsPoints(sessionId) {
@ -1529,6 +1537,7 @@ async function runMigrations() {
if (!gsCols.includes("smoothed")) await execute("ALTER TABLE gps_sessions ADD COLUMN smoothed INTEGER DEFAULT 0");
if (!gsCols.includes("smooth_window")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_window INTEGER");
if (!gsCols.includes("smooth_iters")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_iters INTEGER");
if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);

View file

@ -111,6 +111,16 @@ GPS.computeStats = function(points, weightKg, opts) {
let elevRef = null; // running reference for cumulative elevation hysteresis
const speeds = []; // m/s per segment
// Manual pauses (ms epoch): the rider hand-paused, so that stretch must NOT count
// (walked, not ridden) — unlike an automatic GPS gap in motion, which we keep.
// A segment that touches a pause interval is dropped and elevRef is reset (so the
// elevation delta across the pause is not banked as a climb).
const manualPauses = (opts && opts.pauses ? opts.pauses : [])
.map(z => ({ s: z.startTs, e: z.endTs })).filter(z => z.s != null && z.e != null);
const inManualPause = (aMs, bMs) => manualPauses.some(z => aMs < z.e && bMs > z.s);
const MAX_GRADE = GPS.config.maxRealGradePct ?? 1.0; // 1.0 = 100% = 45°; steeper adjacent alt step = glitch
let pausedSec = 0;
// 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise)
// Step 1: Extract raw altitudes and filter outliers.
// An outlier is a point that jumps far from its neighbors and returns back —
@ -203,6 +213,11 @@ GPS.computeStats = function(points, weightKg, opts) {
let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000;
let v = dt > 0 ? d / dt : 0; // m/s
// Manual pause: exclude entirely (walked, not ridden). Reset elevRef so the
// elevation gained during the pause isn't banked on the next real segment.
let prevMs = new Date(prev.ts).getTime(), pMs = new Date(p.ts).getTime();
if (inManualPause(prevMs, pMs)) { pausedSec += Math.max(0, dt); elevRef = null; continue; }
let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated;
let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6);
let gapThr = GPS.config.gapThresholdSec ?? 30;
@ -239,10 +254,17 @@ GPS.computeStats = function(points, weightKg, opts) {
// climbs. Accumulate the rise/fall from the reference and only bank it (and
// advance the reference) once it clears the 1 m dead zone.
if (smoothedAlt[i] != null) {
if (elevRef == null) elevRef = smoothedAlt[i];
let rise = smoothedAlt[i] - elevRef;
if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; }
else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; }
// Reject a physically impossible adjacent altitude step (glitch — incl. an
// Android no-fix 0.0): grade over the segment steeper than MAX_GRADE (45°).
// A smooth change to any value (e.g. a real 0 near a river) passes.
let prevAlt = smoothedAlt[i - 1];
let glitch = prevAlt != null && segDist > 0 && Math.abs(smoothedAlt[i] - prevAlt) / segDist > MAX_GRADE;
if (!glitch) {
if (elevRef == null) elevRef = smoothedAlt[i];
let rise = smoothedAlt[i] - elevRef;
if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; }
else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; }
}
}
}
@ -353,7 +375,7 @@ GPS.computeStats = function(points, weightKg, opts) {
const first = new Date(sorted[0].ts);
const last = new Date(sorted[sorted.length - 1].ts);
const duration = (last - first) / 1000;
const duration = Math.max(0, (last - first) / 1000 - pausedSec); // exclude manual-pause time
const avgSpeed = duration > 0 ? (distance / duration) : 0;
const avgSpeedKmh = avgSpeed * 3.6;
const maxSpeedKmh = maxSpeed * 3.6;
@ -991,10 +1013,11 @@ GPS.recoverSession = async function() {
chainringTeeth: s.chainringTeeth,
cogTeeth: s.cogTeeth,
wheelDiameterInch: s.wheelDiameterInch,
pauses: s.pauses || [],
};
const stats = GPS.computeStats(pts, s.weightKg || 80, recoverOpts);
await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5),
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, s.pauses || []);
// Store bike_id if present
if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId);
console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points");
@ -1171,6 +1194,8 @@ GPS.stopRecording = async function() {
cogTeeth: GPS.recording.cogTeeth,
wheelDiameterInch: GPS.recording.wheelDiameterInch,
};
const recPauses = GPS.recording.pauses || [];
recOpts.pauses = recPauses;
const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts);
const endTime = new Date().toTimeString().slice(0, 5);
@ -1178,7 +1203,7 @@ GPS.stopRecording = async function() {
await DB.finishGpsSession(
GPS.recording.sessionId, endTime,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss
stats.elevGain, stats.elevLoss, recPauses
);
// DB save confirmed — now safe to delete the points file
if (nativePointsFile) {
@ -1710,6 +1735,8 @@ GPS.recalculateSession = async function(sessionId, opts) {
const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
// Use cleaned sorted array, not original points
const sessPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
const stats = GPS.computeStats(sorted, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
@ -1718,13 +1745,14 @@ GPS.recalculateSession = async function(sessionId, opts) {
chainringTeeth: bike?.chainring_teeth,
cogTeeth: bike?.cog_teeth,
wheelDiameterInch: bike?.wheel_diameter_inch,
pauses: sessPauses,
});
// Update session in DB
// Update session in DB (preserve pauses)
await DB.finishGpsSession(sessionId,
session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed,
stats.elevGain, stats.elevLoss
stats.elevGain, stats.elevLoss, sessPauses
);
return stats;
@ -1760,6 +1788,8 @@ GPS.recomputeAllStats = async function(onProgress) {
const points = await DB.getGpsPoints(sid);
if (points.length < 2) { skipped++; continue; }
const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
const bulkPauses = Array.isArray(session.pauses) ? session.pauses
: (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []);
const stats = GPS.computeStats(points, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0,
activityType: session.activity_type ?? "bike",
@ -1768,9 +1798,10 @@ GPS.recomputeAllStats = async function(onProgress) {
chainringTeeth: bike?.chainring_teeth,
cogTeeth: bike?.cog_teeth,
wheelDiameterInch: bike?.wheel_diameter_inch,
pauses: bulkPauses,
});
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss);
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses);
updated++;
} catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; }
}

View file

@ -4541,14 +4541,16 @@ async function showSessionDetail(sessionId) {
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
// Compute duration from points regardless
} : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
// Manual-pause total (ms) to exclude from displayed duration
let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0);
// Compute duration from points regardless (minus manual pauses)
if (points.length >= 2) {
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000));
}
// Always compute segments from points (needed for the Segments section)
if (!stats.segments && points.length >= 3) {
let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
stats.segments = fullStats.segments;
}
@ -4600,6 +4602,7 @@ async function showSessionDetail(sessionId) {
showModal(`
<h3>${actName}</h3>
<div id="sd-track-id" style="font-size:11px;color:var(--muted);margin:-6px 0 8px;cursor:pointer;user-select:all">ID: ${session.id}</div>
<div class="sd-section">
<div class="sd-title">Время</div>
@ -5158,6 +5161,10 @@ async function showSessionDetail(sessionId) {
let exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
// Track ID — click to copy to clipboard
let idEl = document.querySelector("#sd-track-id");
if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId);
// Chart fullscreen in modal — push to winStack, back collapses
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
@ -7561,6 +7568,23 @@ function showToast(msg, duration) {
setTimeout(hide, duration || 6000);
}
// Copy text to clipboard (navigator.clipboard with a hidden-textarea fallback for
// WebViews where the async API is unavailable). Optional toast on success.
function copyToClipboard(text, toastMsg) {
let ok = () => { if (toastMsg) showToast(toastMsg); };
let fallback = () => {
try {
let ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.focus(); ta.select();
document.execCommand("copy"); ta.remove(); ok();
} catch (e) { showToast("Не удалось скопировать"); }
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(ok).catch(fallback);
} else fallback();
}
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")

View file

@ -460,7 +460,10 @@ class GpsRecorderService : Service(), LocationListener, SensorEventListener {
pointCount++
val bearing = if (location.hasBearing()) location.bearing else -1f
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${location.altitude},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}"""
// No altitude fix -> write null, not 0.0: Android returns 0.0 when hasAltitude()
// is false, and a fake 0 corrupts elevation stats. A real fixed 0 is kept.
val altStr = if (location.hasAltitude()) location.altitude.toString() else "null"
val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${altStr},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}"""
synchronized(bufferLock) {
pointBuffer.add(line)
}