Recording map: follow toggle to free-pan while riding

New ⊕ button on the recording screen toggles map auto-follow. When off,
the map stays put so you can look around when unsure where you are (off
the track, or the track leads where there is no road); new GPS points no
longer recentre it. Tapping it back on recentres on the current position
and resumes following. Default on per screen entry (not persisted).

Guards followMode around panTo/setView in onPoint/onBatchUpdate/
onStateChange. Independent of the compass freeze/rotate controls.
This commit is contained in:
4aiman 2026-09-26 23:44:18 +03:00
commit d6fe47a924
6 changed files with 151 additions and 5 deletions

View file

@ -142,7 +142,7 @@
### Экраны
- **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы)
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали)
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate). Навигационный «пак» позиции (SVG divIcon): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник, указывающий ВВЕРХ (карта и так доворачивается активным источником — курс/компас — вверх, поэтому без покадрового поворота: не трясёт). Мини-компас только в режиме компаса. Z-order панелями: тайлы < дороги(`roadsPane` 240) < маршрут(`routePane` 250) < трек(overlay) < пак(marker). course-up по GPS-курсу (карта и панель доворачиваются в ландшафт вместе, `bearing = h`), ландшафтный режим (CSS rotation), графики скорости/высоты/ЧСС, импорт GPX/FTB, dead reckoning, placeholder до GPS, карта дорог (дедуп 2м, цвета по вело/пешие/бег), сохранённые маршруты (оверлей-навигация, кнопка-тоггл «М», предпросмотр с профилем высоты, топо/уклон-обводка, тоггл «∡/⛰», высоты по цепочке GPXZ→open-elevation→open-meteo→OpenTopoData), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали), тоггл следования карты за позицией (`followMode`, кнопка `⊕` в `rec-map-btn` стеке; guard вокруг `panTo`/`setView` в `onPoint`/`onBatchUpdate`/`onStateChange`; выкл = свободный просмотр карты на ходу, повторный тап центрирует; не запоминается)
- **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10)
- **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог

View file

@ -51,6 +51,7 @@
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
- **Тёмная карта** — тумблер `☾` на экранах записи, деталей трека и дорог: инвертирует ТОЛЬКО слой тайлов (`invert + hue-rotate 180° + brightness`, трек/маршрут/дороги остаются цветными), выбор запоминается. Работает одинаково на офлайн-тайлах и онлайне, без ключей/квот/перепаковки.
- **Метки ветра** — кнопка «Ветер» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
- **Слежение карты (тумблер `⊕`)** — на экране записи карта по умолчанию центрируется на текущей позиции при каждой новой точке. Кнопка `⊕` (подсвечена = слежу) выключает автоцентрирование: карту можно свободно подвигать и осмотреться, когда «где я» непонятно (отклонился от трека или трек ведёт туда, где дороги нет), а новые точки её не возвращают. Повторный тап центрирует на текущей позиции и включает слежение снова. По умолчанию включено при каждом заходе (не запоминается).
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
- **Вес, сон, добавки** — логирование с историей.

View file

@ -0,0 +1,76 @@
# План: кнопка тоггла "следование карты за позицией" на экране записи
## Context (зачем)
Во время записи карта на экране трекинга постоянно снэпится (`panTo`) на текущую GPS-позицию
при каждой новой точке. Кейс пользователя: отклонился от трека или трек ведёт туда, где дороги
нет, надо осмотреться по карте вокруг - но карта тут же прыгает обратно на позицию, подвигать
её невозможно. Нужна кнопка-тумблер: нажал - карта перестаёт следовать за позицией (можно
свободно двигать/смотреть), нажал снова - карта центрируется на текущей позиции и следование
возобновляется.
Решения по UX (согласованы с пользователем):
- Кнопка - плитка 32x32 в общем правом ряду кнопок карты, с символом (не словом), активное
состояние подсвечивается (как freeze/roads). Активна = следование включено.
- Только кнопка-тумблер. Драг карты пальцем следование НЕ выключает (строго как в запросе).
- Повторный тап (включение следования) сразу центрирует карту на текущей позиции.
- Следование по умолчанию включено при каждом заходе на экран (в localStorage не храним).
## Где сейчас следование (frontend/js/main.js, функция renderRecordingInline)
Автоследование = центрирование вида карты на текущей точке. Происходит в:
- `GPS.recording.onPoint`, ~4840: `map.panTo(ll)` - основное следование при записи.
- `GPS.recording.onPoint` состояние waiting_gps, ~4857: `map.setView([p.lat,p.lon],15)`.
- `GPS.recording.onStateChange` "ready", ~4814: `map.setView([p.lat,p.lon],16)` - разовый locate.
- `GPS.recording.onBatchUpdate`, ~5005: `map.panTo(last)` - следование при пакетном апдейте.
Маркер позиции (`updateMe`/`refreshMe`) вид карты НЕ трогает - при выключенном следовании
точка продолжит обновляться на своём месте, просто карта не будет прыгать. Компас/поворот
(`compassFrozen`, `applyBearing`) ортогональны следованию - `panTo` на bearing не влияет.
## Фикс (frontend/js/main.js, только renderRecordingInline)
1. Флаг: рядом с `compassFrozen`/`rotOffset` (~4470-4477) добавить `let followMode = true;`.
2. Кнопка: по образцу `freezeBtn`/`roadsBtn` (~4624-4676) создать `followBtn`
(`class="rec-map-btn rec-follow-btn"`, символ-цель, `title="Следить за позицией"`),
добавить в `btnWrap`, сразу выставить `followBtn.classList.add("active")` (followMode=true).
onclick:
- `followMode = !followMode;`
- `followBtn.classList.toggle("active", followMode);`
- при включении - центрировать на текущей точке: взять последнюю точку
`GPS.recording.points` (как в onBatchUpdate: сортировка по ts, последняя) и `map.panTo`.
3. Загардить все 4 места следования условием `if (followMode)`:
- ~4840 `map.panTo(ll)` -> `if (followMode) map.panTo(ll);`
- ~4857 waiting_gps `map.setView(...)` -> обернуть в `if (followMode)` (return оставить).
- ~4814 "ready" `map.setView(...)` -> обернуть в `if (followMode)`.
- ~5005 `map.panTo(last)` -> `if (followMode) map.panTo(last);`
## CSS (frontend/css/styles.css)
После `.rec-dark-toggle { right:8px; bottom:238px; }` (~1248) добавить позицию для новой кнопки
в следующем свободном слоте стека: `.rec-follow-btn { right: 8px; bottom: 276px; }`.
Базовый вид и `.active` уже есть у `.rec-map-btn` / `.rec-map-btn.active` - переиспользуем.
## Символ кнопки
Плитка-цель, читается как "центрировать на мне": glyph круг-с-точкой/крест-в-круге
(например "⊕" плюс-в-круге или "◎" bullseye) - выбрать широко отрисовываемый.
Смысл несёт `title` (текст). Активная подсветка = следование включено.
## Регрессии (проверить рядом)
- Freeze-кнопка компаса (`compassFrozen`) и поворот карты - не задеты (следование = только
центр вида, поворот = bearing). Убедиться, что обе кнопки работают независимо.
- Маркер позиции `updateMe` при выключенном следовании продолжает двигаться (карта стоит).
- Второй, отдельный экран `setupLiveUpdates` (мини-карта gpsMap, ~5030, свой panTo 5039/5055)
НЕ трогаем - это превью в списке, у него нет ряда кнопок. Пользователь просил "экран
трекинга" = renderRecordingInline.
## Верификация
- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh`): открыть экран записи, начать запись
(или подсадить точки), проверить:
1) следование по умолчанию включено, кнопка подсвечена, карта идёт за точкой;
2) тап -> подсветка гаснет, карту можно свободно двигать, новые точки её не возвращают;
3) тап -> карта центрируется на текущей позиции, подсветка загорается, следование снова идёт.
Скрин каждого состояния.
- Сборка APK + телефон (по явной просьбе): в реальной записи проехать/постоять, отжать кнопку,
подвигать карту, вернуть кнопкой. Коммит ДО теста на телефоне, доработки - отдельным коммитом.
- README/PROJECT_MAP + лог сессии обновить вместе с коммитом.
## Не в этой задаче
Авто-выключение следования при драге карты (пользователь выбрал "только кнопка").
Хранение состояния следования между сессиями (по умолчанию всегда включено).

View file

@ -0,0 +1,47 @@
# Сессия 2026-09-26 - тумблер "следование карты за позицией" на экране записи
Работали по утверждённому плану: `docs/plans/2026-09-26-map-follow-toggle.md`.
## Контекст
Во время записи карта на экране трекинга при каждой новой GPS-точке центрируется на текущей
позиции (`map.panTo`). Кейс: отклонился от трека или трек ведёт туда, где дороги нет, надо
осмотреться по карте - но она тут же прыгает обратно, подвигать её нельзя. Нужен тумблер:
нажал - карта перестаёт следовать (можно двигать), нажал снова - центрируется на позиции и
следование возобновляется.
Согласовано с пользователем (AskUserQuestion): кнопка - символ-плитка в общем ряду кнопок
карты (не слово), активная подсветка = слежу; только кнопка-тумблер (драг карты следование НЕ
выключает); повторный тап центрирует; состояние не запоминается между сессиями.
## Действия
- `frontend/js/main.js`, `renderRecordingInline`:
- добавлен флаг `let followMode = true;` рядом с `compassFrozen`/`rotOffset`;
- создана кнопка `followBtn` (`class="rec-map-btn rec-follow-btn active"`, символ `⊕`,
`title="Следить за позицией"`) по образцу `freezeBtn`; onclick тогглит `followMode` и класс
`active`, при включении центрирует `map.panTo` на последней точке `GPS.recording.points`
(сортировка по ts);
- 4 места автоследования загардены `if (followMode)`: `onPoint` `panTo` (~4840), `onPoint`
waiting_gps `setView` (~4857), `onStateChange` "ready" `setView` (~4814), `onBatchUpdate`
`panTo` (~5005).
- `frontend/css/styles.css`: `.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }`
(следующий слот стека после `.rec-dark-toggle` 238px). Базовый вид и `.active` -
переиспользован `.rec-map-btn` / `.rec-map-btn.active`.
- README: добавлен буллет "Слежение карты (тумблер ⊕)".
## Проверка
- `node -c frontend/js/main.js` - OK.
- Десктоп (Xvfb :99, `DISPLAY=:99 ./build.sh` = tauri dev, без бампа версии): открыт экран
записи (Старт), кнопка `⊕` в правом стеке вверху, по умолчанию подсвечена (слежу). Тап ->
подсветка гаснет (слежу выкл). Тап -> подсветка возвращается (слежу вкл). JS-ошибок в логе
нет. Скрины: s_rec.png / s_rec_off.png / s_rec_on2.png в scratchpad.
- НЕ проверено на десктопе: живое поведение "карта не прыгает на новую точку при выкл / прыгает
при вкл" - под Xvfb настоящего GPS нет, экран в waiting_gps, поток точек не идёт. Логика -
тривиальный guard вокруг уже работающих `panTo`/`setView`. Реальная проверка потока - на
телефоне.
## Следующие шаги
- Сборка APK + установка на телефон (по явной просьбе пользователя): проверить в живой записи -
проехать/постоять, отжать `⊕`, подвигать карту (новые точки не возвращают), вернуть кнопкой
(центрируется). Доработки - отдельным коммитом.
- Компас (freeze `X`) и поворот карты не задеты (следование = только центр вида, поворот =
bearing) - убедиться на телефоне, что обе кнопки работают независимо.

View file

@ -1246,6 +1246,7 @@ select {
.rec-mode-btn { right: 8px; bottom: 162px; font-size: 11px; }
.rec-route-toggle { right: 8px; bottom: 200px; }
.rec-dark-toggle { right: 8px; bottom: 238px; }
.rec-follow-btn { right: 8px; bottom: 276px; font-size: 18px; }
/* Dark map: invert ONLY the tile layer (overlays — track, route, roads, puck — keep
their colours). User-tuned filter on OSM standard tiles. Applies to every Leaflet map. */
body.dark-map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(1.5); }

View file

@ -4469,6 +4469,7 @@ async function renderRecordingInline(container) {
// 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 followMode = true; // ⊕ button: keep the map centred on the current position
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)
@ -4634,6 +4635,26 @@ async function renderRecordingInline(container) {
};
btnWrap.appendChild(freezeBtn);
// Follow button — ⊕: toggle whether the map keeps re-centring on the current position.
// Off = free pan (look around when lost / off-route); tapping it back on recentres now.
let followBtn = document.createElement("button");
followBtn.className = "rec-map-btn rec-follow-btn active";
followBtn.textContent = "⊕"; // ⊕
followBtn.title = "Следить за позицией";
followBtn.onclick = () => {
followMode = !followMode;
followBtn.classList.toggle("active", followMode);
if (followMode) {
// Re-enabling: jump straight back to where we are now.
let pts = GPS.recording.points;
if (pts && pts.length) {
let last = pts.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts))[pts.length - 1];
if (last) map.panTo([last.lat, last.lon]);
}
}
};
btnWrap.appendChild(followBtn);
// Roads toggle button
let roadsBtn = document.createElement("button");
roadsBtn.className = "rec-map-btn rec-roads-toggle";
@ -4811,7 +4832,7 @@ async function renderRecordingInline(container) {
if (ph) ph.remove();
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
map.setView([p.lat, p.lon], 16);
if (followMode) map.setView([p.lat, p.lon], 16);
} else if (state === "recording") {
statusEl.textContent = "";
statusEl.style.color = "";
@ -4837,7 +4858,7 @@ async function renderRecordingInline(container) {
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
map.panTo(ll);
if (followMode) map.panTo(ll);
// Save position for next session start (avoid black screen)
DB.setProfileValue("last_lat", String(p.lat));
DB.setProfileValue("last_lon", String(p.lon));
@ -4854,7 +4875,7 @@ async function renderRecordingInline(container) {
if (recState === "waiting_gps") {
statusEl.textContent = "Поиск GPS...";
statusEl.style.color = "#ffb74d";
map.setView([p.lat, p.lon], 15);
if (followMode) map.setView([p.lat, p.lon], 15);
return;
}
if (recState === "ready") {
@ -5002,7 +5023,7 @@ async function renderRecordingInline(container) {
trailLine.setLatLngs(coords);
if (coords.length) {
let last = coords[coords.length - 1];
map.panTo(last);
if (followMode) map.panTo(last);
updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing)
updateMe(last);
}