diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 464ab46..f9082fe 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -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 обновления, импорт тайлов, карта дорог diff --git a/README.md b/README.md index 27e7d79..4f9c042 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, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.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__s.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` настраивается; при изменении и пересчёте применяется сразу. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index e36ad38..1bc8e4d 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -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%; diff --git a/frontend/js/main.js b/frontend/js/main.js index 3e7e029..bd40b66 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -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 + ? ` + ` + : ""; + return L.divIcon({ + className: "rec-puck", + html: ` + ${tri} + + + + `, + 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) 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); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b0a9ebb..4a4282b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.411" +version = "0.1.415" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 07d16f9..6882b55 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.411" +version = "0.1.415" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b3b5726..620e98d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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"