diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 9eee555..7459076 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -21,7 +21,7 @@ | Файл | Строк | Назначение | |------|-------|------------| -| `js/main.js` | ~5500 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас, ландшафт | +| `js/main.js` | ~5800 | Экраны, навигация, модалки, графики, нутриенты, HR UI, компас (EMA + gimbal lock), ландшафт (3 режима), дороги | | `js/db.js` | ~1500 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync | | `js/gps.js` | ~1200 | GPS запись, физика ккал (power model), altitude filter, BLE HR polling, dead reckoning | | `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) | diff --git a/README.md b/README.md index 8b9c069..27faaf2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ ## Возможности -- **Дневник питания** — 13480 продуктов из магазинов (Магнит, 5ка, KDV) + база ИОН с микронутриентами. Рецепты с учётом термообработки (потери витаминов при варке/жарке). Единицы измерения (штуки, ложки, стаканы). Ввод количества формулами (4*30, 300+50). -- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Вращение карты по компасу (leaflet-rotate). Ландшафтный режим (CSS rotation + панель справа). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). +- **Дневник питания** — 24400+ продуктов (Магнит, 5ка, Health-Diet.ru, Ашан, Макдоналдс, KFC, Додо Пицца, KDV, ИОН, Гриднев). 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). +- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. - **Расчёт ккал** — физическая модель для велосипеда (мощность из gradient + Crr + CdA). MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. diff --git a/frontend/js/main.js b/frontend/js/main.js index 1fe7371..7ae6e7d 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -1,18 +1,24 @@ // BalDav main app — pure JS, no framework, no bundler. // Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script. +// let DB = window.DB; +// let GPS = window.GPS; +// let BMR = window.BMR; +// let TileCache = window.TileCache; + + // Disable leaflet-rotate's compass control on all maps by default; // only the recording map enables rotate: true explicitly. if (L && L.Map) L.Map.mergeOptions({ rotateControl: false }); -const app = document.getElementById("app"); +let app = document.getElementById("app"); -const UPDATE_BASE_URL = "https://4aiman.top/baldav"; +let UPDATE_BASE_URL = "https://4aiman.top/baldav"; -const ACTIVITY_MET = { squats: 6.0, jumprope: 12.3, stretch: 2.3, swim: 8.0, manual: 3.0 }; -const ACTIVITY_NAMES = { squats: "Приседания", jumprope: "Скакалка", stretch: "Растяжки", swim: "Бассейн", manual: "Другое" }; +let ACTIVITY_MET = { squats: 6.0, jumprope: 12.3, stretch: 2.3, swim: 8.0, manual: 3.0 }; +let ACTIVITY_NAMES = { squats: "Приседания", jumprope: "Скакалка", stretch: "Растяжки", swim: "Бассейн", manual: "Другое" }; function activityKcal(type, minutes, weightKg) { - const met = ACTIVITY_MET[type] || 3.0; + let met = ACTIVITY_MET[type] || 3.0; return Math.round(met * 3.5 * weightKg * minutes / 200); } @@ -27,14 +33,14 @@ let state = { // Parse amount string: supports "4*30" (=120), "2*150" (=300), "300+50" (=350), plain "250" function parseAmount(str) { if (!str) return NaN; - const s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*"); + let s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*"); if (s.includes("*")) { - const parts = s.split("*").map(Number); + let parts = s.split("*").map(Number); if (parts.length === 2 && parts.every(n => !isNaN(n))) return parts[0] * parts[1]; return NaN; } if (s.includes("+")) { - const parts = s.split("+").map(Number); + let parts = s.split("+").map(Number); if (parts.every(n => !isNaN(n))) return parts.reduce((a, b) => a + b, 0); return NaN; } @@ -46,8 +52,8 @@ async function checkForUpdate() { try { // Kotlin checks BUILD_NUMBER via native HTTP (works through VPN/proxy) // and writes result to update_info.json, Rust reads it - const raw = await window.__TAURI__.core.invoke("get_update_info"); - const info = JSON.parse(raw); + let raw = await window.__TAURI__.core.invoke("get_update_info"); + let info = JSON.parse(raw); if (info.remote && info.local && info.remote > info.local) { state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local, url: info.url || null }; console.log(`[Update] available: ${info.local} -> ${info.remote} url=${info.url}`); @@ -61,7 +67,7 @@ async function checkForUpdate() { async function startUpdate() { if (!state.updateAvailable) return; - const url = state.updateAvailable.url || (UPDATE_BASE_URL + "/baldav.apk"); + let url = state.updateAvailable.url || (UPDATE_BASE_URL + "/baldav.apk"); try { await window.__TAURI__.core.invoke("gps_service_command", { cmd: "open_url:" + url }); } catch (e) { @@ -71,11 +77,11 @@ async function startUpdate() { // Navigation history stack — survives in-memory across screen changes. // When app is restored from background, we replay the stack into browser history. -const NAV_MAX = 20; +let NAV_MAX = 20; // Restore nav stack from localStorage so back gesture works after app restart -const navStack = (() => { +let navStack = (() => { try { - const saved = JSON.parse(localStorage.getItem("baldav_nav_stack")); + let saved = JSON.parse(localStorage.getItem("baldav_nav_stack")); if (Array.isArray(saved) && saved.length > 0) return saved; } catch {} return ["today"]; @@ -89,7 +95,7 @@ function persistNavStack() { // Window stack — single global stack for ALL overlays (modals, fullscreen). // Every open = push. Every back = pop top. One popstate handler. // --------------------------------------------------------------------------- -const winStack = []; // { type, close() } +let winStack = []; // { type, close() } let _skipPops = 0; // counter: how many popstate events to skip (from closeModal's history.go) function winPush(type, closeFn) { @@ -124,22 +130,22 @@ async function init() {
`; - const bootEl = document.getElementById("boot-label"); - const bootCount = document.getElementById("seed-count"); - const bootFill = document.getElementById("seed-fill"); + let bootEl = document.getElementById("boot-label"); + let bootCount = document.getElementById("seed-count"); + let bootFill = document.getElementById("seed-fill"); // Wait for DB migrations (fast — just checks columns exist) try { if (DB._migrationsReady) await DB._migrationsReady; } catch (e) { console.error("[BOOT]", e); } // Recover any orphaned GPS recording session try { - const recovered = await GPS.recoverSession(); + let recovered = await GPS.recoverSession(); if (recovered) showToast("Восстановлена незавершённая тренировка"); } catch (e) { console.error("[Recovery]", e); } // Load RDI standard and half-life data from profile (default: RU) try { - const rdiCode = (await DB.getProfileValue("rdi_standard")) || "RU"; + let rdiCode = (await DB.getProfileValue("rdi_standard")) || "RU"; await BMR.loadRDI(rdiCode); await BMR.loadHalflife(); } catch (e) { /* RDI stays at fallback RU values */ } @@ -149,21 +155,21 @@ async function init() { let unlisten = null; try { unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => { - const { current, total } = ev.payload; - const pct = total > 0 ? Math.round(current / total * 100) : 0; + let { current, total } = ev.payload; + let pct = total > 0 ? Math.round(current / total * 100) : 0; if (bootEl) bootEl.textContent = "Обновление продуктов (" + pct + "%)"; if (bootFill) { bootFill.classList.add("determinate"); bootFill.style.width = pct + "%"; } if (bootCount) bootCount.textContent = current + " / " + total; }); } catch {} - const result = await window.__TAURI__.core.invoke("import_seed_products"); + let result = await window.__TAURI__.core.invoke("import_seed_products"); if (unlisten) unlisten(); } catch (e) { console.error("[SEED]", e); alert("Seed error: " + e); } // Apply safe area insets (Android status bar / nav bar) try { - const insets = await window.__TAURI__.core.invoke("get_safe_insets"); + let insets = await window.__TAURI__.core.invoke("get_safe_insets"); if (insets.top || insets.bottom) { document.documentElement.style.setProperty("--safe-area-top", insets.top + "px"); document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px"); @@ -203,7 +209,7 @@ function renderScreen(name, fromPopstate) { if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; } if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; } - const prevScreen = state.screen; + let prevScreen = state.screen; state.screen = name; // Push to nav stack and browser history (unless triggered by popstate) if (!fromPopstate && prevScreen && prevScreen !== name) { @@ -212,7 +218,7 @@ function renderScreen(name, fromPopstate) { persistNavStack(); history.pushState({ screen: name }, ""); } - const screens = { + let screens = { today: renderToday, products: renderProducts, recipes: renderRecipes, @@ -225,7 +231,7 @@ function renderScreen(name, fromPopstate) { // Floating recording bar (visible on all screens except recording) if (GPS.recording.active && name !== "recording") { - const bar = document.createElement("div"); + let bar = document.createElement("div"); bar.className = "rec-floating-bar"; bar.onclick = () => renderScreen("recording"); bar.innerHTML = `Запись...`; @@ -234,10 +240,10 @@ function renderScreen(name, fromPopstate) { if (window._recBarTimer) clearInterval(window._recBarTimer); window._recBarTimer = setInterval(() => { if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; } - const pts = GPS.recording.points; - const stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts()); - const sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0; - const el = document.querySelector("#rec-bar-text"); + let pts = GPS.recording.points; + let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts()); + let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0; + let el = document.querySelector("#rec-bar-text"); if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`; }, 1000); } else if (window._recBarTimer) { @@ -245,14 +251,14 @@ function renderScreen(name, fromPopstate) { } app.appendChild(buildNav(name)); - const content = document.createElement("div"); + let content = document.createElement("div"); content.className = "screen-content"; app.appendChild(content); screens[name](content); } function buildNav(active) { - const nav = document.createElement("nav"); + let nav = document.createElement("nav"); nav.className = "bottom-nav"; [ { id: "today", label: "Сегодня" }, @@ -261,7 +267,7 @@ function buildNav(active) { { id: "supplements", label: "Добавки" }, { id: "settings", label: "Настройки" }, ].forEach(item => { - const btn = document.createElement("button"); + let btn = document.createElement("button"); btn.className = "nav-btn" + (item.id === active ? " active" : ""); btn.textContent = item.label; btn.onclick = () => renderScreen(item.id); @@ -296,14 +302,14 @@ async function renderToday(container) { return; } - const recentWeightsForBmr = await DB.getRecentWeights(7); - const bmr = calcBmr(recentWeightsForBmr); - const actLevel = parseFloat(state.profile.activity_level) || 1.2; - const dailyBase = bmr ? Math.round(bmr * actLevel) : null; - const intake = Math.round(foodTotals.kcal ?? 0); - const actKcal = Math.round(actTotals.kcal ?? 0); - const expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null; - const balance = expend != null ? intake - expend : null; + let recentWeightsForBmr = await DB.getRecentWeights(7); + let bmr = calcBmr(recentWeightsForBmr); + let actLevel = parseFloat(state.profile.activity_level) || 1.2; + let dailyBase = bmr ? Math.round(bmr * actLevel) : null; + let intake = Math.round(foodTotals.kcal ?? 0); + let actKcal = Math.round(actTotals.kcal ?? 0); + let expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null; + let balance = expend != null ? intake - expend : null; container.innerHTML = `
@@ -390,9 +396,9 @@ async function renderToday(container) {