Recording map: navigation puck (position+direction), z-order panes, compass-only mini-compass

- One direction indicator instead of two geo-arrows: an SVG divIcon L.marker
  (rotateWithView) — a track-colored circle (20% darker) with white + 1px black
  rings, plus a triangle pointing the ACTIVE source heading (compass in compass
  mode, GPS course in GPS mode). Rotation = geo heading; rotateWithView keeps it
  correct as the map turns. Replaces posMarker + compass/course arrows.
- Z-order via panes: roads -> roadsPane(240), route routePane(250), track
  overlay(400), puck markerPane — tiles<roads<route<track<puck (track no longer
  hidden under roads).
- Mini-compass shown only in compass mode; tap-to-calibrate kept.
- darkenHex helper; .rec-puck css (no default div-icon box).
This commit is contained in:
4aiman 2026-08-24 23:06:26 +03:00
commit 493c9b92df
7 changed files with 81 additions and 35 deletions

View file

@ -130,7 +130,7 @@
### Экраны
- **Сегодня** — баланс ккал, еда (продукты + блюда), GPS тренировки, активность, вес, сон, добавки, прогресс-бар жидкости, нутриенты (приход + запасы)
- **Продукты** — подтабы [Продукты | Рецепты], поиск, карточки с КБЖУ, добавление в дневник
- **Трек** — GPS тренировки (запись с HR), карта с компасом (leaflet-rotate), 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, `rotateWithView`): круг цвета трека (на 20% темнее) с белой + 1px чёрной обводкой + треугольник-направленец по АКТИВНОМУ источнику (компас/GPS-курс). Мини-компас только в режиме компаса. 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), де-джиттер трека (Савицкий-Голей, кнопка «≈» на детали)
- **Добавки** — 41 нутриент (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10)
- **Настройки** — профиль, образ жизни, нормы ЧСС/давления, BLE пульсометр, велосипеды, GPS конфиг (в т.ч. цвет трека `trackColor`, цвета дорог, GPXZ-ключ), OTA обновления, импорт тайлов, карта дорог

View file

@ -5,7 +5,7 @@
## Возможности
- **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Импорт тайлов по регионам (SQLite, роутер LAN).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN).
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека.
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м). Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности.
- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.

View file

@ -1147,6 +1147,10 @@ select {
.rec-route-toggle { right: 8px; bottom: 200px; }
/* 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. */
/* Position/direction puck (SVG divIcon) — no default leaflet-div-icon box. */
.rec-puck { background: none; border: none; pointer-events: none; }
.rec-puck svg { display: block; overflow: visible; }
.rec-compass {
position: absolute; top: calc(8px + var(--safe-area-top, 0px)); right: 8px; z-index: 1001;
width: 40px; height: 40px; border-radius: 50%;

View file

@ -3547,6 +3547,37 @@ function showDrawRouteScreen(container) {
}, 200);
}
// Darken a #rrggbb colour by factor f (0.8 = 20% darker) — for the position puck fill.
function darkenHex(hex, f) {
let m = /^#?([0-9a-fA-F]{6})$/.exec(hex || "");
if (!m) return hex || "#bb86fc";
let n = parseInt(m[1], 16);
let r = Math.round(((n >> 16) & 255) * f), g = Math.round(((n >> 8) & 255) * f), b = Math.round((n & 255) * f);
return "#" + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
}
// SVG "navigation puck": a position circle (track colour, white ring, 1px black
// outer ring so it never blends with the map) + an optional direction triangle
// above it. Circle is centred in the icon so it stays put while the icon rotates
// (leaflet-rotate rotates the icon by heading around the anchor).
function posPuckIcon(fill, withTriangle) {
let tri = withTriangle
? `<path d="M28 5 L37 23 L19 23 Z" fill="${fill}" stroke="#fff" stroke-width="2.5" stroke-linejoin="round" paint-order="stroke"/>
<path d="M28 5 L37 23 L19 23 Z" fill="none" stroke="#000" stroke-width="0.6" stroke-linejoin="round"/>`
: "";
return L.divIcon({
className: "rec-puck",
html: `<svg width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
${tri}
<circle cx="28" cy="28" r="11" fill="#000"/>
<circle cx="28" cy="28" r="10" fill="#fff"/>
<circle cx="28" cy="28" r="7.5" fill="${fill}"/>
</svg>`,
iconSize: [56, 56],
iconAnchor: [28, 28], // circle centre = geo position; rotation pivots here
});
}
async function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
@ -3639,6 +3670,8 @@ async function renderRecordingInline(container) {
if (typeof showToast === "function") showToast("Север откалиброван");
};
btnWrap.appendChild(compassEl);
// Mini-compass (north needle + tap-to-calibrate) is only meaningful in compass mode.
compassEl.style.display = orientMode === "compass" ? "" : "none";
let compassArrow = compassEl.firstChild;
function updateNorthArrow(bearing) {
compassArrow.style.transform = `rotate(${-bearing}deg)`;
@ -3653,30 +3686,43 @@ async function renderRecordingInline(container) {
orientMode = orientMode === "compass" ? "course" : "compass";
localStorage.setItem("baldav_orient_mode", orientMode);
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
compassEl.style.display = orientMode === "compass" ? "" : "none";
applyBearing();
refreshMe(); // switch the direction triangle to the newly active source
};
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);
// Position + direction "puck": ONE marker showing the current spot (circle in the
// track colour) and, when a heading is known, a triangle pointing the ACTIVE source
// direction (compass in compass mode, GPS course in GPS mode). leaflet-rotate's
// rotateWithView keeps the geo-heading correct while the map turns.
let puckFill = darkenHex(GPS.config.trackColor || "#bb86fc", 0.8);
let meMarker = null, meHasTri = false;
function activeHeading() {
let h = orientMode === "course" ? courseHeading : smoothedHeading;
return (h == null) ? null : h;
}
function updateMe(ll) {
let h = activeHeading(), wantTri = h != null;
if (!meMarker) {
meMarker = L.marker(ll, { icon: posPuckIcon(puckFill, wantTri), rotation: h || 0, rotateWithView: true, pane: "markerPane", interactive: false, keyboard: false }).addTo(map);
meHasTri = wantTri;
} else {
meMarker.setLatLng(ll);
if (wantTri !== meHasTri) { meMarker.setIcon(posPuckIcon(puckFill, wantTri)); meHasTri = wantTri; }
meMarker.options.rotation = h || 0;
meMarker.update();
}
}
// Refresh only the direction (rotation/triangle) — called when heading changes.
function refreshMe() {
if (!meMarker) return;
let h = activeHeading(), wantTri = h != null;
if (wantTri !== meHasTri) { meMarker.setIcon(posPuckIcon(puckFill, wantTri)); meHasTri = wantTri; }
meMarker.options.rotation = h || 0;
meMarker.update();
}
// 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).
@ -3723,7 +3769,7 @@ async function renderRecordingInline(container) {
}
if (orientMode === "course" && !compassFrozen) applyBearing();
}
updateArrows();
refreshMe();
}
function applyScreenRotation() {
@ -3778,6 +3824,8 @@ async function renderRecordingInline(container) {
}
roadsBtn.classList.add("active");
localStorage.setItem("baldav_show_roads", "1");
// Roads live in their own pane BELOW route/track: tiles(200)<roads(240)<route(250)<track(400).
if (!map.getPane("roadsPane")) { map.createPane("roadsPane", map._rotatePane || undefined); map.getPane("roadsPane").style.zIndex = 240; }
let pts = await DB.getRoadPoints();
let seg = [];
for (let i = 0; i < pts.length; i++) {
@ -3788,7 +3836,7 @@ async function renderRecordingInline(container) {
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) {
if (seg.length >= 2) {
let color = roadColor(seg[0].atype);
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6 }).addTo(map));
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
}
seg = [];
}
@ -3797,7 +3845,7 @@ async function renderRecordingInline(container) {
}
if (seg.length >= 2) {
let color = roadColor(seg[0].atype);
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6 }).addTo(map));
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
}
};
btnWrap.appendChild(roadsBtn);
@ -3854,7 +3902,7 @@ async function renderRecordingInline(container) {
}
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
refreshMe(); // update the direction triangle from the live compass heading
if (!compassFrozen) applyBearing();
}
let throttledOrientation = L.Util.throttle(handleOrientation, 100);
@ -3867,8 +3915,6 @@ async function renderRecordingInline(container) {
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = "";
};
let posMarker = null;
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
@ -3907,8 +3953,7 @@ async function renderRecordingInline(container) {
DB.setProfileValue("last_lat", String(p.lat));
DB.setProfileValue("last_lon", String(p.lon));
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker(ll, { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
updateMe(ll);
// Stats
let pts = GPS.recording.points;
@ -3924,9 +3969,7 @@ async function renderRecordingInline(container) {
return;
}
if (recState === "ready") {
// Position marker only
if (posMarker) map.removeLayer(posMarker);
posMarker = L.circleMarker([p.lat, p.lon], { radius: 6, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 1, weight: 2 }).addTo(map);
updateMe([p.lat, p.lon]); // position only (no heading yet)
return;
}
@ -4062,9 +4105,8 @@ async function renderRecordingInline(container) {
if (coords.length) {
let last = coords[coords.length - 1];
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)
updateMe(last);
}
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.411"
version = "0.1.415"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.411"
version = "0.1.415"
edition = "2021"
[lib]

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.411",
"version": "0.1.415",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"