Split a track at a point; block merging tracks with different bikes

This commit is contained in:
4aiman 2026-09-21 12:36:03 +03:00
commit 15a2881c6d
6 changed files with 140 additions and 2 deletions

View file

@ -151,7 +151,8 @@
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты)
- Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save)
- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция)
- Объединение поездок: в `renderGpsScreen` долгий тап (500мс) по `.gps-session-item` -> режим выделения (`.selected`, плавающая `#gps-sel-bar`), 2+ -> `DB.mergeGpsSessions(idStrs)` (db.js): точки в самую раннюю сессию, `orig_session_id` per-point (реверс), `seq=NULL` (иначе совпадающие seq перемешивают порядок), лишние сессии удаляются; затем `GPS.recalculateSession(target,{noDelete})`. Колонка `gps_points.orig_session_id` (миграция). Склейка блокируется, если у выбранных треков разные `bike_id` (appAlert)
- Разрезание трека: кнопка `#sd-split` в `showSessionDetail` включает `#sd-map.split-mode`; в попапе каждой точки кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)` (db.js): вторая половина (точки по id, чанки) в НОВУЮ сессию (id=Date.now(), копия date/activity/bike/source), `seq=NULL`, у старой правится end_time; пересчёт обоих кусков. Велик копируется, потом меняется вручную
- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=2 входов = minPasses), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`)
- Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter
- Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal

View file

@ -24,7 +24,8 @@
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
- **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется.
- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`) для будущего разделения. Пока только склейка (разрез/вырез - позже).
- **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`).
- **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске.
- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет.
- **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
- **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.

View file

@ -0,0 +1,43 @@
# Сессия 2026-09-21 - разрезание трека + блокировка склейки разных великов
## Контекст
Три вопроса пользователя (сначала разобрал по коду, потом делал):
1. «Пересчитать» берёт велик СЕССИИ (не активный) - пользователя устраивает, не трогаем.
2. Хочет разрезать трек в точке смены передачи, чтобы разным кускам назначать свой велик
(два велика = один и тот же, разные передачи) - для точных мощности/каденса.
3. Опасение: склейка разных великов слепит всё в один велик. Решение (по просьбе): при
склейке треков с РАЗНЫМИ великами - предупреждать и НЕ склеивать (не хранить велик по точкам).
## Сделано
- **Блокировка склейки (D):** в обработчике `#gps-sel-merge` (main.js) - если у выбранных
сессий разные `bike_id` (Set по bike_id, null=none), `appAlert` «велосипеды разные, склеить
нельзя, задайте одинаковый и склеивайте» и return.
- **Разрезание (B):** `DB.splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime)`
(db.js): вторая половина -> НОВАЯ сессия (id=Date.now(), копия date/activity/bike/source,
start=part2 время, end=старый end), точки part2 переносятся по id чанками (200), `seq=NULL`;
у старой end_time=part1. UI: кнопка `#sd-split` включает `#sd-map.split-mode`; в попапе точки
кнопка `.split-here` (видна только в split-mode) -> `confirmSplitAt(k)` -> подтверждение ->
splitGpsSession -> пересчёт обоих кусков (`recalculateSession noDelete`) -> назад в историю.
- Точку разреза выбирают ТАПОМ ПО ТОЧКЕ (хит-таргеты уже есть), а не кликом карты - маркеры
перехватывают клик, поэтому кнопка живёт в попапе.
## Проверка (десктоп Xvfb, подсажен склеенный трек 16 сен 1789547934760, 3614 тчк, +2й велик)
- Разрез: «Разрезать трек» -> тост + скролл к карте -> тап точки #3547 -> попап с «✂ Разрезать
здесь» -> диалог (точка/время/км) -> «Разрезать» -> в истории ДВЕ сессии 16-го (part1 3548
тчк 19.54км, part2 66 тчк 343м), обе bike, стата пересчитана. Скрины.
- Блокировка: part2 велик -> 2 (через SQL), выделил обе (долгий тап + тап), «Объединить» ->
предупреждение «велосипеды разные, склеить нельзя», склейки не произошло. Скрин.
- БД восстановлена из бэкапа (0 сессий 16 сен, 1 велик).
## Замечания
- Инъекция `INSERT ... SELECT *` тихо даёт 0 строк, если id сессии больше нет в pulled-БД
(tools/track/baldav.db пере-пулена, старая 1789549492046 уже склеена на телефоне в
1789547934760). Проверять существование id перед выводами.
- `Xvfb :99` снова умирал; `pgrep -f "Xvfb :99"` ловит собственную команду (ложно «жив») -
проверять сокет `/tmp/.X11-unix/X99`.
- part2 start_time берётся из ts точки (UTC), а поле сессии - локальное; мелкая косметическая
рассинхронизация времени (данные так хранятся), на функциональность не влияет.
## Не сделано (по просьбе - позже/не нужно)
- Велик по точкам (вариант C) не делаем: пользователь просто не будет склеивать разные велики.
- Вырезание участка трека - позже.

View file

@ -1761,6 +1761,21 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; }
box-shadow: 0 -2px 8px rgba(0,0,0,0.3);
}
.gps-sel-bar[hidden] { display: none; }
/* "Split here" button inside a point popup — visible only when the map is in split mode */
.split-here {
display: inline-block;
margin-top: 6px;
padding: 4px 10px;
background: #ff9800;
color: #000;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
#sd-map:not(.split-mode) .split-here { display: none; }
.gps-sel-bar #gps-sel-count { flex: 1; font-size: 14px; }
.gps-sel-bar button { padding: 8px 14px; }

View file

@ -949,6 +949,31 @@ async function mergeGpsSessions(idStrs) {
return targetId;
}
// Split one session in two at a chosen point. The second part (points whose ids are in
// part2Ids) moves to a NEW session that copies the original's date / activity / bike /
// source; the first part keeps the original session. Bike stays the same on both halves -
// the rider then reassigns the bike on a half and recomputes if the gear differed. Times
// are "HH:MM:SS". Returns the new (second-part) session id_str. Caller recomputes both.
async function splitGpsSession(sessionId, part2Ids, part1EndTime, part2StartTime) {
if (!Array.isArray(part2Ids) || part2Ids.length === 0) return null;
const s = await getGpsSession(sessionId);
if (!s) return null;
const newId = Date.now();
await execute(
`INSERT INTO gps_sessions (id, date, start_time, end_time, activity_type, source, bike_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
[newId, s.date, part2StartTime, s.end_time, s.activity_type, s.source ?? "recorded", s.bike_id ?? null]
);
let CHUNK = 200;
for (let i = 0; i < part2Ids.length; i += CHUNK) {
let ch = part2Ids.slice(i, i + CHUNK);
let ph = ch.map((_, k) => "?" + (k + 2)).join(",");
await execute(`UPDATE gps_points SET session_id = CAST(?1 AS INTEGER), seq = NULL WHERE id IN (${ph})`, [newId, ...ch]);
}
if (part1EndTime) await execute("UPDATE gps_sessions SET end_time = ?1 WHERE id = CAST(?2 AS INTEGER)", [part1EndTime, sessionId]);
return String(newId);
}
async function importGpxSession(session, points) {
await execute(
`INSERT INTO gps_sessions (date, start_time, end_time, activity_type,
@ -1325,6 +1350,7 @@ window.DB.getGpsSession = getGpsSession;
window.DB.getGpsPoints = getGpsPoints;
window.DB.deleteGpsSession = deleteGpsSession;
window.DB.mergeGpsSessions = mergeGpsSessions;
window.DB.splitGpsSession = splitGpsSession;
window.DB.importGpxSession = importGpxSession;
window.DB.saveGapFill = saveGapFill;
window.DB.revertGapFill = revertGapFill;

View file

@ -2834,6 +2834,13 @@ async function renderGpsScreen(container) {
for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); }
if (sess.length < 2) return;
sess.sort((a, b) => sessTs(a) - sessTs(b));
// Different bikes must not be merged: gear/weight differ, so power and cadence would be
// computed wrong for the mixed track. Refuse and tell the rider how to proceed.
let bikeSet = new Set(sess.map(s => s.bike_id ?? "none"));
if (bikeSet.size > 1) {
await appAlert("Велосипеды у выбранных треков разные - склеить нельзя: передача и вес отличаются, мощность и каденс посчитались бы неверно.\n\nЕсли это на самом деле один велосипед, задайте его всем трекам одинаково (в детали трека: «Велосипед», затем «Пересчитать») и склеивайте снова.");
return;
}
let gapWarn = false;
for (let i = 1; i < sess.length; i++) {
let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000;
@ -5321,6 +5328,7 @@ async function showSessionDetail(sessionId) {
<button id="sd-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
<button id="sd-split" class="btn-secondary full-width" style="margin-top:8px">Разрезать трек</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</button>
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
@ -5705,10 +5713,40 @@ async function showSessionDetail(sessionId) {
let bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : "";
let bleInfo = [hr, spo2, bp].filter(Boolean).join(" ");
let info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`;
// "Split here" button lives in every point popup but is shown only in split mode
// (mapEl.split-mode, toggled by the "Разрезать трек" button) — see CSS.
info += `<button class="split-here" data-idx="${i}">✂ Разрезать здесь</button>`;
L.circleMarker([p.lat, p.lon], {
radius: 8, weight: 0, fillOpacity: 0, interactive: true,
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
// Delegated: tap "Разрезать здесь" in a point popup → split the track at that point.
mapEl.addEventListener("click", (ev) => {
let b = ev.target.closest(".split-here");
if (b) { ev.stopPropagation(); confirmSplitAt(parseInt(b.dataset.idx)); }
});
async function confirmSplitAt(k) {
if (!(k >= 2 && k <= points.length - 3)) { showToast("Выберите точку не у самого края трека"); return; }
try { map.closePopup(); } catch {}
let tOf = (p) => (p.ts.split("T")[1] || "").slice(0, 8);
let distToHere = 0;
for (let i = 1; i <= k; i++) distToHere += GPS.distance(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
if (!(await appConfirm("Разрезать трек после точки #" + k + " (время " + tOf(points[k]) + ", ~" + (distToHere / 1000).toFixed(2) + " км от старта)?\n\nВторой кусок станет отдельной тренировкой с тем же велосипедом. Потом можно сменить велосипед у неё и пересчитать.", "Разрезать"))) return;
let part2Ids = points.slice(k + 1).map(p => p.id).filter(id => id != null);
if (part2Ids.length < 2) { showToast("Во втором куске слишком мало точек"); return; }
try {
let newId = await DB.splitGpsSession(sessionId, part2Ids, tOf(points[k]), tOf(points[k + 1]));
if (newId) {
await GPS.recalculateSession(sessionId, { noDelete: true });
await GPS.recalculateSession(newId, { noDelete: true });
}
showToast("Трек разрезан на две тренировки");
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("gps");
} catch (e) { console.error("[split]", e); showToast("Ошибка разреза: " + (e.message || e)); }
}
// Ensure Leaflet has correct container size, then fit
map.invalidateSize();
@ -6038,6 +6076,20 @@ async function showSessionDetail(sessionId) {
let compareBtn = document.querySelector("#sd-compare");
if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points);
// Split-track button: toggles split mode on the map (shows a "Разрезать здесь" action
// inside each point popup). Hidden for tracks too short to split.
let splitBtn = document.querySelector("#sd-split");
if (splitBtn) {
if (points.length < 5) splitBtn.style.display = "none";
else splitBtn.onclick = () => {
let m = document.querySelector("#sd-map");
if (!m) return;
let on = m.classList.toggle("split-mode");
splitBtn.textContent = on ? "Готово (выберите точку на карте)" : "Разрезать трек";
if (on) { showToast("Тапните точку на карте, затем «Разрезать здесь»"); m.scrollIntoView({ behavior: "smooth", block: "center" }); }
};
}
// Recalculate button
let recalcBtn = document.querySelector("#sd-recalc");
if (recalcBtn) recalcBtn.onclick = async () => {