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:
parent
b5275da61e
commit
493c9b92df
7 changed files with 81 additions and 35 deletions
|
|
@ -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 обновления, импорт тайлов, карта дорог
|
||||
|
||||
|
|
|
|||
|
|
@ -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` настраивается; при изменении и пересчёте применяется сразу.
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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
2
src-tauri/Cargo.lock
generated
|
|
@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "baldav"
|
||||
version = "0.1.411"
|
||||
version = "0.1.415"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"btleplug",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "baldav"
|
||||
version = "0.1.411"
|
||||
version = "0.1.415"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue