// 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 }); let app = document.getElementById("app"); let UPDATE_BASE_URL = "https://4aiman.top/baldav"; 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) { let met = ACTIVITY_MET[type] || 3.0; return Math.round(met * 3.5 * weightKg * minutes / 200); } let state = { screen: "today", date: todayIso(), profile: {}, latestWeight: null, updateAvailable: null, // { remoteBuild: number } or null }; // Parse amount string: supports "4*30" (=120), "2*150" (=300), "300+50" (=350), plain "250" function parseAmount(str) { if (!str) return NaN; let s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*"); if (s.includes("*")) { 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("+")) { let parts = s.split("+").map(Number); if (parts.every(n => !isNaN(n))) return parts.reduce((a, b) => a + b, 0); return NaN; } return parseFloat(s); } // Check for app updates by comparing BUILD_NUMBER with remote 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 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}`); } else { state.updateAvailable = null; } } catch (e) { console.log("[Update] check failed:", e.message || e); } } async function startUpdate() { if (!state.updateAvailable) return; 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) { console.error("[Update] open browser failed:", e); } } // Navigation history stack — survives in-memory across screen changes. // When app is restored from background, we replay the stack into browser history. let NAV_MAX = 20; // Restore nav stack from localStorage so back gesture works after app restart let navStack = (() => { try { let saved = JSON.parse(localStorage.getItem("baldav_nav_stack")); if (Array.isArray(saved) && saved.length > 0) return saved; } catch {} return ["today"]; })(); function persistNavStack() { try { localStorage.setItem("baldav_nav_stack", JSON.stringify(navStack)); } catch {} } // --------------------------------------------------------------------------- // Window stack — single global stack for ALL overlays (modals, fullscreen). // Every open = push. Every back = pop top. One popstate handler. // --------------------------------------------------------------------------- let winStack = []; // { type, close() } let _skipPops = 0; // counter: how many popstate events to skip (from closeModal's history.go) function winPush(type, closeFn) { winStack.push({ type, close: closeFn }); history.pushState({ win: type }, ""); } function winPopTop() { if (winStack.length === 0) return false; winStack.pop().close(); return true; } // --------------------------------------------------------------------------- // Boot // --------------------------------------------------------------------------- async function init() { applyDarkMap(); // restore dark-map tile filter preference before any map renders // Hydration reminder is ON by default — write the config so the native service has it (the // native side also defaults ON when the file is missing; this keeps file + UI consistent). try { let on = localStorage.getItem("baldav_hydr_on") !== "0"; let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; window.__TAURI__?.core?.invoke("set_hydration_config", { enabled: on, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 }); } catch (e) {} // Live location sharing is OFF by default — keep the native config file in sync with the UI // toggle so a session always sees the current choice (off unless the user turned it on). try { let liveOn = localStorage.getItem("baldav_live_on") === "1"; let liveUrl = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push"; // migrate the old /api/live/push endpoint to /api/trackme/push (renamed server-side) if (liveUrl.endsWith("/api/live/push")) { liveUrl = liveUrl.replace("/api/live/push", "/api/trackme/push"); localStorage.setItem("baldav_live_url", liveUrl); } let liveToken = localStorage.getItem("baldav_live_token") || ""; window.__TAURI__?.core?.invoke("set_live_config", { enabled: liveOn, url: liveUrl, token: liveToken }); } catch (e) {} if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") { app.innerHTML = `
Ошибка: db.js не загрузился.
window.DB = ${JSON.stringify(typeof window.DB)}
__TAURI__ = ${JSON.stringify(typeof window.__TAURI__)}
`; return; } // Diagnostic logs stay in the source but only fire in debug builds. Rust reports // the compiled build profile (cfg!(debug_assertions)); in a release build we // silence console.log lines that start with a diagnostic tag ([poll]/[recover]/ // [stop]/[GPS]/[HR]/[SD]/...). console.error/warn are never touched. This means // no adding/removing logs by hand — a tagged log auto-disappears in release. try { let info = await window.__TAURI__.core.invoke("get_app_info"); window.__BALDAV_DEBUG = !!info.debug; } catch { window.__BALDAV_DEBUG = false; } if (!window.__BALDAV_DEBUG) { let _log = console.log.bind(console); let DIAG = /^\[(poll|recover|stop|GPS|HR|SD|seq|native|flush|watch|import|rec)\]/; console.log = function (...a) { if (typeof a[0] === "string" && DIAG.test(a[0])) return; _log.apply(console, a); }; } // Show loading immediately so the user sees something app.innerHTML = `
Загрузка...
`; 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 { 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 { let rdiCode = (await DB.getProfileValue("rdi_standard")) || "RU"; await BMR.loadRDI(rdiCode); await BMR.loadHalflife(); } catch (e) { /* RDI stays at fallback RU values */ } // Import seed products if needed — show progress only if actually importing try { let unlisten = null; try { unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => { 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 {} let result = await window.__TAURI__.core.invoke("import_seed_products"); if (unlisten) unlisten(); } catch (e) { console.error("[SEED]", e); appAlert("Seed error: " + e); } // Apply safe area insets (Android status bar / nav bar) try { 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"); } } catch {} state.profile = await DB.getFullProfile(); state.latestWeight = await DB.getLatestWeight(); // Migrate reserves for historical days (one-time) try { await migrateReserves(); } catch (e) { console.error("[Reserves]", e); } // Seed browser history from navStack so back gesture works after restart history.replaceState({ screen: navStack[0] }, ""); for (let i = 1; i < navStack.length; i++) { history.pushState({ screen: navStack[i] }, ""); } // Add padding entries so first back doesn't exit for (let i = 0; i < 3; i++) history.pushState({ screen: navStack[navStack.length - 1] }, ""); // Restore last screen (fromPopstate=true to avoid duplicate navStack push) renderScreen(navStack[navStack.length - 1], true); // Initialize sync try { await Sync.init(); if (Sync.userId) Sync.now(); } catch {} // Check for updates in background (non-blocking) checkForUpdate(); } // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- // Keep the vertical scroll position across a re-render of the SAME screen (day nav, adding // food, deletes) instead of jumping to the top. Anchor on the topmost visible top-level // section + its offset (robust to height changes, e.g. one more food row or async charts). function captureScrollAnchor(el) { let top = el.scrollTop, kids = el.children, idx = 0, delta = top; for (let i = 0; i < kids.length; i++) { if (kids[i].offsetTop <= top + 4) { idx = i; delta = top - kids[i].offsetTop; } else break; } return { idx, delta }; } function applyScrollAnchor(el, a) { let kid = el.children[a.idx]; if (kid) el.scrollTop = Math.max(0, kid.offsetTop + a.delta); } // The screen fills asynchronously (charts, nutrient strips), so re-apply for a short window as // content settles; stop early if the user scrolls themselves. function scheduleScrollRestore(el, a) { let stop = false, off = () => { stop = true; }; el.addEventListener("wheel", off, { passive: true, once: true }); el.addEventListener("touchmove", off, { passive: true, once: true }); let apply = () => { if (!stop) applyScrollAnchor(el, a); }; apply(); requestAnimationFrame(apply); [60, 180, 400].forEach(ms => setTimeout(apply, ms)); } function renderScreen(name, fromPopstate) { // Release screen wake lock and compass listener when leaving recording screen if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; } if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; } try { TileCache.stopHealer(); } catch {} // leaving a map screen stops online tile-healing; map screens restart it let prevScreen = state.screen; state.screen = name; // Push to nav stack and browser history (unless triggered by popstate) if (!fromPopstate && prevScreen && prevScreen !== name) { navStack.push(name); if (navStack.length > NAV_MAX) navStack.shift(); persistNavStack(); history.pushState({ screen: name }, ""); } let screens = { today: renderToday, products: renderProducts, recipes: renderRecipes, gps: renderGpsScreen, recording: renderRecordingInline, supplements: renderSupplements, settings: renderSettings, }; // Re-render of the same screen: remember where we were scrolled so we can stay put. let scrollAnchor = null; if (prevScreen === name) { let oldContent = app.querySelector(".screen-content"); if (oldContent && oldContent.scrollTop > 0) scrollAnchor = captureScrollAnchor(oldContent); } app.innerHTML = ""; // Floating recording bar (visible on all screens except recording) if (GPS.recording.active && name !== "recording") { let bar = document.createElement("div"); bar.className = "rec-floating-bar"; bar.onclick = () => renderScreen("recording"); bar.innerHTML = `Запись...`; app.appendChild(bar); // Update bar every second if (window._recBarTimer) clearInterval(window._recBarTimer); window._recBarTimer = setInterval(() => { if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; } 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) { clearInterval(window._recBarTimer); } app.appendChild(buildNav(name)); let content = document.createElement("div"); content.className = "screen-content"; app.appendChild(content); screens[name](content); if (scrollAnchor) scheduleScrollRestore(content, scrollAnchor); } function buildNav(active) { let nav = document.createElement("nav"); nav.className = "bottom-nav"; [ { id: "today", label: "Сегодня" }, { id: "products", label: "Продукты" }, { id: "gps", label: "Трек" }, { id: "supplements", label: "Добавки" }, { id: "settings", label: "Настройки" }, ].forEach(item => { let btn = document.createElement("button"); btn.className = "nav-btn" + (item.id === active ? " active" : ""); btn.textContent = item.label; btn.onclick = () => renderScreen(item.id); nav.appendChild(btn); }); return nav; } // --------------------------------------------------------------------------- // TODAY screen // --------------------------------------------------------------------------- async function renderToday(container) { container.innerHTML = `
Загрузка...
`; let weights = [], food = [], activities = [], sleep = [], foodTotals = {}, actTotals = {}, suppLog = [], suppTotals = {}, gpsSessions = []; try { [weights, food, activities, sleep, foodTotals, actTotals, suppLog, suppTotals, gpsSessions] = await Promise.all([ DB.getWeightsForDate(state.date), DB.getFoodForDate(state.date), DB.getActivitiesForDate(state.date), DB.getSleepForDate(state.date), DB.getFoodTotalsForDate(state.date), DB.getActivityTotalsForDate(state.date), DB.getSupplementLogForDate(state.date), DB.getSupplementTotalsForDate(state.date), DB.getGpsSessionsForDate(state.date), ]); } catch (e) { console.error("[renderToday] DB error:", e); container.innerHTML = `
Ошибка загрузки: ${esc(String(e))}
`; return; } let recentWeightsForBmr = await DB.getRecentWeights(7, state.date); let bmr = calcBmr(recentWeightsForBmr); let actLevel = parseFloat(state.profile.activity_level) || 1.2; let intake = Math.round(foodTotals.kcal ?? 0); let actKcal = Math.round(actTotals.kcal ?? 0); // TEF (термический эффект пищи): доля прихода на переваривание. Вводим с сохранением // поддержки — базу урезаем на tefFrac, а TEF добавляем от реального прихода, поэтому на // поддержке итог тот же, но в дефицит (ел меньше — переварил меньше) расход чуть ниже. let tefFrac = Math.max(0, Math.min(0.3, GPS.config.tefFrac ?? 0)); let dailyBase = bmr ? Math.round(bmr * actLevel * (1 - tefFrac)) : null; let tefKcal = Math.round(tefFrac * intake); let expend = dailyBase != null ? dailyBase + tefKcal + actKcal : (actKcal > 0 ? actKcal + tefKcal : null); let balance = expend != null ? intake - expend : null; container.innerHTML = `

${formatDate(state.date)}

${intake} ккал приход
${expend ?? "—"} ккал расход
${balance != null ? (balance >= 0 ? "+" : "") + balance : "—"} баланс
${expend != null ? `
расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""}
` : ""}

Вес

Еда: Продукты

Еда: Блюда

Активность

Добавки

Сон

${sleep ? formatSleepInfo(sleep) : `нет данных`}

Статистика

`; container.querySelector("#prev-day").onclick = () => { state.date = shiftDate(state.date, -1); renderScreen("today"); }; container.querySelector("#next-day").onclick = () => { state.date = shiftDate(state.date, 1); renderScreen("today"); }; container.querySelectorAll(".del-btn").forEach(btn => { btn.onclick = async () => { let { table, id } = btn.dataset; if (table === "weight") await DB.deleteWeight(parseInt(id)); if (table === "food") await DB.deleteFoodEntry(parseInt(id)); if (table === "activity") await DB.deleteActivity(parseInt(id)); if (table === "supp") await DB.deleteSupplementLog(parseInt(id)); renderScreen("today"); }; }); container.querySelectorAll(".food-edit-item").forEach(li => { li.onclick = e => { if (e.target.closest(".del-btn")) return; let fid = parseInt(li.dataset.foodId); let entry = food.find(f => f.id === fid); if (entry) showEditFoodModal(entry); }; }); // 7-day rolling balance trend (IDEA 6) (async () => { try { let trendEl = container.querySelector("#balance-trend"); if (!trendEl || dailyBase == null) return; let [food7, act7] = await Promise.all([ DB.getDailyFoodTotals(7, state.date), DB.getDailyActivityTotals(7, state.date), ]); // The range is now exactly 7 days ending at the selected day (no off-by-one slice needed). if (food7.length === 0) return; let foodMap = {}; food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0)); let actMap = {}; act7.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0)); // Build per-day balance for all dates that have food data let balances = []; for (let d of food7) { let dayIntake = foodMap[d.date] ?? 0; let dayAct = actMap[d.date] ?? 0; let dayBalance = dayIntake - ((dailyBase ?? 0) + Math.round(tefFrac * dayIntake) + dayAct); balances.push(dayBalance); } if (balances.length === 0) return; let sum = balances.reduce((a, b) => a + b, 0); let avg = Math.round(sum / balances.length); // Накопительный "банк": сумма дневных балансов за окно. Отвечает на "сколько доесть/недоесть" // (цель асимптотически к весовой). ~7700 ккал = 1 кг жира. let bank = Math.round(sum); let bankKg = (bank / 7700).toFixed(2); let cls = avg <= 0 ? "trend-deficit" : "trend-surplus"; let bankCls = bank <= 0 ? "trend-deficit" : "trend-surplus"; trendEl.innerHTML = `
Банк за ${balances.length} дн.: ${bank >= 0 ? "+" : ""}${bank} ккал (${bankKg} кг)
Среднее: ${avg >= 0 ? "+" : ""}${avg} ккал/день
`; } catch (e) { console.error("[7d trend]", e); } })(); // Fill drink bar let drinkWrap = container.querySelector("#drink-bar-wrap"); if (drinkWrap) { (async () => { try { let waterMl = await DB.getDailyWaterMl(state.date); let norm = parseInt(state.profile.daily_water_ml) || 2000; let pct = Math.min(100, Math.round(waterMl / norm * 100)); let cls = pct < 30 ? "low" : pct < 70 ? "mid" : "ok"; drinkWrap.innerHTML = ` ${waterMl} / ${norm} мл
`; } catch (e) { console.error("[DrinkBar]", e); } })(); } // Fill nutrient strip (top of page, sorted by deficiency) let dailyNutEl = container.querySelector("#daily-nutrients"); if (dailyNutEl) { let nutrients = calcDailyNutrients(food, suppTotals); dailyNutEl.innerHTML = renderNutrientChips(nutrients, "Приход") || `
нет данных о нутриентах
`; dailyNutEl.onclick = () => { dailyNutEl.classList.toggle("nutrient-collapsed"); if (dailyNutEl.classList.contains("nutrient-collapsed")) localStorage.removeItem("nut_intake_open"); else localStorage.setItem("nut_intake_open", "1"); }; } // Fill reserves strip (accumulated body stores from previous days) let reservesEl = container.querySelector("#daily-reserves"); if (reservesEl) { (async () => { try { // Calculate and save today's reserves let reserves = await calcReservesForDate(state.date); if (reserves && Object.keys(reserves).length > 0) { await DB.saveReserves(state.date, reserves); let RDI = BMR.RDI; let chips = []; for (let [key, rdi] of Object.entries(RDI)) { let amount = reserves[key] || 0; if (amount <= 0) continue; let pct = Math.round(amount / rdi * 100); if (pct < 1) continue; let label = NUTRIENT_LABELS[key] || key; let normType = BMR.RDI_TYPES[key] || "rdi"; chips.push({ pct, label, normType }); } chips.sort((a, b) => a.pct - b.pct); reservesEl.innerHTML = renderNutrientChips(chips, "Запасы") || ""; } else { reservesEl.innerHTML = ""; } } catch (e) { console.error("[Reserves] render:", e); } })(); reservesEl.onclick = () => { reservesEl.classList.toggle("nutrient-collapsed"); if (reservesEl.classList.contains("nutrient-collapsed")) localStorage.removeItem("nut_reserves_open"); else localStorage.setItem("nut_reserves_open", "1"); }; } container.querySelector("#add-weight-btn").onclick = () => showWeightModal(); container.querySelector("#add-food-btn").onclick = () => showFoodModal(); container.querySelector("#add-recipe-food-btn").onclick = () => showFoodFromRecipeModal(); container.querySelector("#add-act-btn").onclick = () => showActivityModal(); container.querySelector("#add-supp-btn").onclick = () => showLogSupplementModal(); container.querySelector("#add-sleep-btn").onclick = () => showSleepModal(sleep); // GPS session clicks → open track details container.querySelectorAll("[data-gps-session]").forEach(el => { el.onclick = () => showSessionDetail(el.dataset.gpsSession); }); // Stats charts section let statsContainer = container.querySelector("#stats-charts"); let statsPeriod = container.querySelector(".stats-period"); if (statsContainer && statsPeriod) { let chartRefDate = state.date; // window ends at the selected day renderStatsCharts(statsContainer, 30, foodTotals, dailyBase, chartRefDate); statsPeriod.querySelectorAll(".type-chip").forEach(chip => { chip.onclick = () => { statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected")); chip.classList.add("selected"); renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase, chartRefDate); }; }); } } // --------------------------------------------------------------------------- // Stats charts // --------------------------------------------------------------------------- let statsChartInstances = []; async function renderStatsCharts(container, days, foodTotals, bmr, refDate) { // Destroy old charts statsChartInstances.forEach(c => c.destroy()); statsChartInstances.length = 0; container.innerHTML = `
Загрузка...
`; let weights, dailyFood, dailyActivity; try { [weights, dailyFood, dailyActivity] = await Promise.all([ DB.getRecentWeights(days, refDate), DB.getDailyFoodTotals(days, refDate), DB.getDailyActivityTotals(days, refDate), ]); } catch (e) { container.innerHTML = `
Ошибка загрузки статистики
`; return; } container.innerHTML = ""; // --- Weight chart (line) --- if (weights.length > 1) { let block = document.createElement("div"); block.className = "stats-chart-block"; block.innerHTML = `
Вес
`; container.appendChild(block); let ctx = block.querySelector("#stats-weight-chart"); let labels = weights.map(w => { let d = w.date.split("-"); return d[2] + "." + d[1]; }); let chart = new Chart(ctx, { type: "line", data: { labels, datasets: [{ data: weights.map(w => w.weight_kg), borderColor: "#bb86fc", borderWidth: 1.5, pointRadius: weights.length > 30 ? 0 : 2, fill: false, tension: 0.3, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, zoom: { zoom: { wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, pinch: { enabled: true }, mode: "x", onZoomStart: ({chart, event}) => { if (event && event.type === "wheel") return true; let wrap = chart.canvas.closest(".chart-wrap"); return wrap && wrap.classList.contains("chart-full"); }, }, pan: { enabled: false }, }, }, scales: { x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, y: { ticks: { color: "#888" }, grid: { color: "#333" } }, }, }, }); statsChartInstances.push(chart); } // --- Macros donut (doughnut) --- let protein = Math.round(foodTotals.protein_g ?? 0); let fat = Math.round(foodTotals.fat_g ?? 0); let carbs = Math.round(foodTotals.carbs_g ?? 0); let totalKcal = Math.round(foodTotals.kcal ?? 0); if (protein + fat + carbs > 0) { let block = document.createElement("div"); block.className = "stats-chart-block"; block.innerHTML = `
Макронутриенты (сегодня)
`; container.appendChild(block); let ctx = block.querySelector("#stats-macros-chart"); let chart = new Chart(ctx, { type: "doughnut", data: { labels: ["Белки", "Жиры", "Углеводы"], datasets: [{ data: [protein, fat, carbs], backgroundColor: ["#64b5f6", "#ffb74d", "#81c784"], borderWidth: 0, }], }, options: { responsive: true, maintainAspectRatio: false, cutout: "60%", plugins: { legend: { display: true, position: "bottom", labels: { color: "#888", font: { size: 12 } }, }, }, }, plugins: [{ id: "centerText", afterDraw(chart) { let { ctx: c, chartArea } = chart; let cx = (chartArea.left + chartArea.right) / 2; let cy = (chartArea.top + chartArea.bottom) / 2; c.save(); c.textAlign = "center"; c.textBaseline = "middle"; c.font = "bold 18px system-ui"; c.fillStyle = "#e0e0e0"; c.fillText(totalKcal + " кк", cx, cy); c.restore(); }, }], }); statsChartInstances.push(chart); } // --- Kcal balance chart (bar) --- if (dailyFood.length > 0) { let block = document.createElement("div"); block.className = "stats-chart-block"; block.innerHTML = `
Баланс ккал
`; container.appendChild(block); // Build a merged date set from food and activity data let dateSet = new Set(); dailyFood.forEach(d => dateSet.add(d.date)); dailyActivity.forEach(d => dateSet.add(d.date)); let dates = Array.from(dateSet).sort(); let foodMap = {}; dailyFood.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0)); let actMap = {}; dailyActivity.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0)); let labels = dates.map(d => { let parts = d.split("-"); return parts[2] + "." + parts[1]; }); let intakeData = dates.map(d => foodMap[d] ?? 0); let expendData = dates.map(d => (bmr ?? 0) + (actMap[d] ?? 0)); let ctx = block.querySelector("#stats-kcal-chart"); let chart = new Chart(ctx, { type: "bar", data: { labels, datasets: [ { label: "Приход", data: intakeData, backgroundColor: "#81c784", }, { label: "Расход", data: expendData, backgroundColor: "#cf6679", }, ], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: "bottom", labels: { color: "#888", font: { size: 12 } }, }, zoom: { zoom: { wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, pinch: { enabled: true }, mode: "x", onZoomStart: ({chart, event}) => { if (event && event.type === "wheel") return true; let wrap = chart.canvas.closest(".chart-wrap"); return wrap && wrap.classList.contains("chart-full"); }, }, pan: { enabled: false }, }, }, scales: { x: { ticks: { color: "#888", maxTicksLimit: 10 }, grid: { color: "#333" } }, y: { ticks: { color: "#888" }, grid: { color: "#333" } }, }, }, }); statsChartInstances.push(chart); } // --- Weight vs 7-day rolling caloric balance (IDEA 7) --- if (weights.length > 3 && dailyFood.length > 3) { let block = document.createElement("div"); block.className = "stats-chart-block"; block.innerHTML = `
Вес и баланс калорий
`; container.appendChild(block); // Build date-indexed maps let wMap = {}; weights.forEach(w => wMap[w.date] = w.weight_kg); let fMap = {}; dailyFood.forEach(d => fMap[d.date] = Math.round(d.kcal ?? 0)); let aMap = {}; dailyActivity.forEach(d => aMap[d.date] = Math.round(d.kcal ?? 0)); // Generate all dates in range let allDates = []; if (weights.length > 0 && dailyFood.length > 0) { let startDate = weights[0].date < dailyFood[0].date ? weights[0].date : dailyFood[0].date; let endDate = new Date().toISOString().slice(0, 10); let cur = new Date(startDate + "T00:00:00"); let end = new Date(endDate + "T00:00:00"); while (cur <= end) { allDates.push(cur.toISOString().slice(0, 10)); cur.setDate(cur.getDate() + 1); } } // Interpolate weight (fill gaps with last known value) let weightData = []; let lastW = null; for (let d of allDates) { if (wMap[d] != null) lastW = wMap[d]; weightData.push(lastW); } // 7-day rolling average of caloric balance let balanceData = []; for (let i = 0; i < allDates.length; i++) { let window7 = []; for (let j = Math.max(0, i - 6); j <= i; j++) { let dDate = allDates[j]; if (fMap[dDate] != null) { let dayBalance = fMap[dDate] - (bmr ?? 0) - (aMap[dDate] ?? 0); window7.push(dayBalance); } } balanceData.push(window7.length > 0 ? Math.round(window7.reduce((a, b) => a + b, 0) / window7.length) : null); } // Шкала баланса (y1): симметрична вокруг 0 и ФЛОТИРУЕТ под реальные данные - подгоняется по // максимуму |баланса|, округляя вверх до 500, с полом ±500 (чтобы мелкие колебания не были // плоской линией). Без фиксированного потолка: нет больших значений - нет и большой шкалы. // Симметрия держит читаемость даже при жёстком дне (дефицит у нижнего края, норма у центра). let bAbs = Math.max(500, ...balanceData.filter(v => v != null).map(v => Math.abs(v)), 0); let bMax = Math.ceil(bAbs / 500) * 500; let corrLabels = allDates.map(d => { let parts = d.split("-"); return parts[2] + "." + parts[1]; }); let ctx = block.querySelector("#stats-corr-chart"); let chart = new Chart(ctx, { type: "line", data: { labels: corrLabels, datasets: [ { label: "Вес", data: weightData, borderColor: "#bb86fc", borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.3, yAxisID: "y", spanGaps: true, }, { label: "Баланс 7д", data: balanceData, borderColor: "#81c784", borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.3, yAxisID: "y1", spanGaps: true, }, ], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: true, position: "bottom", labels: { color: "#888", font: { size: 12 } }, }, zoom: { zoom: { wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, pinch: { enabled: true }, mode: "x", onZoomStart: ({chart, event}) => { if (event && event.type === "wheel") return true; let wrap = chart.canvas.closest(".chart-wrap"); return wrap && wrap.classList.contains("chart-full"); }, }, pan: { enabled: false }, }, }, scales: { x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, y: { position: "left", title: { display: true, text: "кг", color: "#bb86fc" }, ticks: { color: "#bb86fc" }, grid: { color: "#333" }, }, y1: { position: "right", min: -bMax, max: bMax, title: { display: true, text: "ккал", color: "#81c784" }, ticks: { color: "#81c784" }, grid: { drawOnChartArea: false }, }, }, }, }); statsChartInstances.push(chart); } // If no data at all if (container.innerHTML === "") { container.innerHTML = `
Недостаточно данных для графиков
`; return; } // Bind chart fullscreen toggle for stats charts (Today screen, no modal) container.querySelectorAll(".chart-wrap").forEach(wrap => { wrap.onclick = () => { if (wrap.classList.contains("chart-full")) return; wrap.classList.add("chart-full"); let closeBtn = document.createElement("div"); closeBtn.className = "chart-close-btn"; closeBtn.innerHTML = "✕"; closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); }; wrap.appendChild(closeBtn); winPush("fsChart", () => { wrap.classList.remove("chart-full"); closeBtn.remove(); }); }; }); } // --------------------------------------------------------------------------- // Modals // --------------------------------------------------------------------------- function showModal(html, onSubmit, onMount) { let overlay = document.createElement("div"); overlay.className = "modal-overlay"; overlay.innerHTML = ` `; document.body.appendChild(overlay); let stackBase = winStack.length; // remember stack depth before our push winPush("modal", () => overlay.remove()); function closeModal() { // Pop all entries this modal added (fullscreen children + modal itself) let count = winStack.length - stackBase; for (let i = 0; i < count; i++) winStack.pop().close(); // Jump history back by that many steps; skip the resulting popstate events _skipPops = count; if (count > 0) history.go(-count); } overlay.querySelector("#modal-back").onclick = () => closeModal(); overlay.querySelector("#modal-cancel").onclick = () => closeModal(); overlay.querySelector("#modal-ok").onclick = async () => { try { let ok = await onSubmit(overlay); if (ok !== false) { closeModal(); if (ok !== "norender") renderScreen(state.screen); } } catch (e) { console.error("[showModal] onSubmit error:", e); appAlert("Ошибка: " + e); } }; if (onMount) onMount(overlay); } // In-app confirm dialog (replaces the ugly native confirm()). Integrated with the // winStack/history so the hardware back button cancels it. Returns Promise. function appConfirm(message, okText = "OK") { return new Promise(resolve => { let ov = document.createElement("div"); ov.className = "modal-overlay confirm-overlay"; ov.innerHTML = `
${message}
`; document.body.appendChild(ov); let settled = false; let base = winStack.length; winPush("confirm", () => { ov.remove(); if (!settled) { settled = true; resolve(false); } }); let finish = (val) => { if (settled) return; settled = true; let count = winStack.length - base; for (let i = 0; i < count; i++) winStack.pop().close(); _skipPops = count; if (count > 0) history.go(-count); ov.remove(); resolve(val); }; ov.querySelector("#cf-cancel").onclick = () => finish(false); ov.querySelector("#cf-ok").onclick = () => finish(true); }); } // In-app text prompt (replaces native prompt()). Returns Promise. function appPrompt(message, defaultValue = "", okText = "OK") { return new Promise(resolve => { let ov = document.createElement("div"); ov.className = "modal-overlay confirm-overlay"; ov.innerHTML = `
${message}
`; document.body.appendChild(ov); let input = ov.querySelector("#cf-input"); let settled = false; let base = winStack.length; winPush("prompt", () => { ov.remove(); if (!settled) { settled = true; resolve(null); } }); let finish = (val) => { if (settled) return; settled = true; let count = winStack.length - base; for (let i = 0; i < count; i++) winStack.pop().close(); _skipPops = count; if (count > 0) history.go(-count); ov.remove(); resolve(val); }; ov.querySelector("#cf-cancel").onclick = () => finish(null); ov.querySelector("#cf-ok").onclick = () => finish(input.value); input.focus(); input.select(); }); } // In-app info dialog (replaces native alert()). One OK button. Returns Promise. function appAlert(message, okText = "OK") { return new Promise(resolve => { let ov = document.createElement("div"); ov.className = "modal-overlay confirm-overlay"; ov.innerHTML = `
${message}
`; document.body.appendChild(ov); let settled = false; let base = winStack.length; winPush("alert", () => { ov.remove(); if (!settled) { settled = true; resolve(); } }); let finish = () => { if (settled) return; settled = true; let count = winStack.length - base; for (let i = 0; i < count; i++) winStack.pop().close(); _skipPops = count; if (count > 0) history.go(-count); ov.remove(); resolve(); }; ov.querySelector("#cf-ok").onclick = () => finish(); }); } // Close the currently-open track-detail modal (plus its fullscreen-map child, if any), // balance the history stack, then reopen it fresh. Mirrors the recalc/refine pattern // but pops down to the detail modal so it also works while the map is fullscreen — // re-rendering in place instead of stacking a second overlay (which broke the map/charts). // Fill a button's background to `pct` (0-100) as a live progress bar; pct=null clears it. // The % rides on a CSS custom property (.btn-progress reads --pct) so no inline style rule. function setBtnProgress(el, pct) { if (!el) return; if (pct == null) { el.classList.remove("btn-progress"); el.style.removeProperty("--pct"); return; } el.classList.add("btn-progress"); el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%"); } // Show the HR strap charge as a background fill on the HR toggle (0-100%, coloured by level). function setHrBatteryFill(el, bat) { if (!el) return; if (bat == null || bat < 0 || bat > 100) { el.style.removeProperty("--bat"); el.style.removeProperty("--bat-color"); return; } el.style.setProperty("--bat", bat + "%"); el.style.setProperty("--bat-color", bat > 40 ? "rgba(76,175,80,0.5)" : bat > 15 ? "rgba(255,183,77,0.55)" : "rgba(239,83,80,0.6)"); } // Before a recording, nudge the user to turn on what it needs: Bluetooth (for the HR strap) and // location (for the track). Can't silently toggle them on modern Android, so prompt + act: BT via // the system enable dialog, location via the location-settings screen. Declining is fine, and the // two "Не спрашивать" settings suppress each prompt. Never prompts when the state is unknown. async function ensureSystemReady(needBt, needGps) { let inv = window.__TAURI__.core.invoke; if (needBt && localStorage.getItem("baldav_dontask_bt") !== "1") { let bt = "on"; try { bt = await inv("ble_check_bluetooth"); } catch {} if (bt === "off" && await appConfirm("Для пульсометра нужен Bluetooth. Включить?", "Включить")) { try { await inv("ble_request_enable"); } catch {} } } if (needGps && localStorage.getItem("baldav_dontask_gps") !== "1") { let locOn = await GPS.isLocationEnabled(); if (locOn === false && await appConfirm("Для записи трека нужна геолокация. Открыть настройки, чтобы включить?", "Открыть")) { try { await inv("gps_service_command", { cmd: "open_location_settings" }); } catch {} } } } async function reopenDetail(sessionId) { let count = 0; while (winStack.length > 0) { let top = winStack.pop(); count++; try { top.close(); } catch {} if (top.type === "modal") break; } // AWAIT the history.go(-count) popstate(s) before re-rendering. Rendering synchronously // after history.go races it: showSessionDetail pushes its own history entry, then the // pending popstates unwind THAT entry and desync the stack, and the async map/chart build // comes up blank until the next event repaints it (the "graphs+map gone until Cancel" bug). // Same fix as closeThen() — resolve on the popstates, never a timeout. if (count > 0) { _skipPops = count; await new Promise(res => { let remaining = count; let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } }; window.addEventListener("popstate", h); history.go(-count); }); } showSessionDetail(sessionId); } function showWeightModal() { showModal(`

Вес

`, async overlay => { let kg = parseFloat(overlay.querySelector("#m-kg").value); let time = overlay.querySelector("#m-time").value; if (isNaN(kg)) return false; await DB.addWeight(state.date, time, kg); // also syncs profile weight_kg to the latest weigh-in // Refresh in-memory state so kcal/BMR and the settings weight field are current at once. try { state.latestWeight = await DB.getLatestWeight(); if (state.profile && state.latestWeight?.weight_kg != null) state.profile.weight_kg = state.latestWeight.weight_kg; } catch {} }); } function showFoodModal() { let selectedProduct = null; let selectedUnit = null; // { id, name, name_short, grams_per_unit } let availableUnits = []; showModal(`

Еда

`, async overlay => { let time = overlay.querySelector("#m-time").value; let rawAmount = parseAmount(overlay.querySelector("#m-amount").value) || 100; if (selectedProduct) { let amountG = rawAmount; let unitId = null; let unitAmount = null; if (selectedUnit && selectedUnit.grams_per_unit) { // Convert unit amount to grams: e.g. 2 шт * 60г = 120г unitId = selectedUnit.id; unitAmount = rawAmount; amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10; } await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount); } else { let desc = overlay.querySelector("#m-desc").value.trim(); if (!desc) return false; let kcal = parseFloat(overlay.querySelector("#m-kcal").value) || null; let protein = parseFloat(overlay.querySelector("#m-protein").value) || null; let fat = parseFloat(overlay.querySelector("#m-fat").value) || null; let carbs = parseFloat(overlay.querySelector("#m-carbs").value) || null; let amount = rawAmount; if (kcal || protein || fat || carbs) { // Create a custom product in DB, then log it let pid = await DB.addProduct({ name: desc, kcal_per_100g: kcal, protein_per_100g: protein, fat_per_100g: fat, carbs_per_100g: carbs, source: "manual", }); await DB.addFoodEntry(state.date, time, pid, amount, null); } else { await DB.addFoodManual(state.date, time, desc, kcal); } } }); let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); let selectedEl = overlay.querySelector("#m-selected"); let manualFields = overlay.querySelector("#m-manual-fields"); let unitChipsEl = overlay.querySelector("#m-unit-chips"); let amountLabel = overlay.querySelector("#m-amount-label"); let amountInput = overlay.querySelector("#m-amount"); function renderUnitChips() { // "г" chip is always first (default), then available units let html = ``; for (let u of availableUnits) { if (u.grams_per_unit == null) continue; // skip units without conversion html += ``; } unitChipsEl.innerHTML = html; unitChipsEl.style.display = ""; unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => { chip.onclick = () => { let uid = parseInt(chip.dataset.unitId); if (uid === 0) { selectedUnit = null; amountLabel.textContent = "Количество (г)"; amountInput.placeholder = "100"; amountInput.value = "100"; } else { selectedUnit = availableUnits.find(u => u.id === uid); amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`; amountInput.placeholder = "1"; amountInput.value = "1"; } unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected")); chip.classList.add("selected"); }; }); } createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => { selectedProduct = product; selectedUnit = null; selectedEl.style.display = "block"; selectedEl.innerHTML = `${esc(product.name)}`; manualFields.style.display = "none"; // Load available units for this product availableUnits = await DB.getUnitsForProduct(product.id); if (availableUnits.length > 0) { renderUnitChips(); } else { unitChipsEl.style.display = "none"; } // Reset amount — show "мл" for liquids let isLiquid = product.water_pct && product.water_pct > 80; amountLabel.textContent = `Количество (${isLiquid ? "мл" : "г"})`; amountInput.placeholder = isLiquid ? "250" : "100"; amountInput.value = isLiquid ? "250" : "100"; selectedEl.onclick = () => { selectedProduct = null; selectedUnit = null; availableUnits = []; selectedEl.style.display = "none"; unitChipsEl.style.display = "none"; manualFields.style.display = ""; amountLabel.textContent = "Количество (г)"; amountInput.placeholder = "100"; amountInput.value = "100"; }; }); } function showEditFoodModal(entry) { let newProduct = null; // if user picks a different product let origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? ""; showModal(`

Редактирование

${!entry.product_id && !entry.recipe_id ? `
` : ""}
`, async overlay => { let amount = parseAmount(overlay.querySelector("#m-amount").value); let time = overlay.querySelector("#m-time").value; let note = overlay.querySelector("#m-note")?.value.trim() || null; if (isNaN(amount) || amount <= 0) return false; if (newProduct) { // Replace: delete old, add new await DB.deleteFoodEntry(entry.id); await DB.addFoodEntry(entry.date ?? state.date, time, newProduct.id, amount, note); } else { let kcalEl = overlay.querySelector("#m-kcal"); let kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null; await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride); } }); let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); let currentEl = overlay.querySelector("#m-current"); let searchBlock = overlay.querySelector("#m-search-block"); currentEl.onclick = () => { searchBlock.style.display = "block"; searchBlock.querySelector("#m-prod-search").focus(); }; let searchInput = overlay.querySelector("#m-prod-search"); let resultsEl = overlay.querySelector("#m-prod-results"); let debounce = null; searchInput.oninput = () => { clearTimeout(debounce); debounce = setTimeout(async () => { let q = searchInput.value.trim(); if (q.length < 2) { resultsEl.innerHTML = ""; return; } let results = await DB.searchProducts(q); resultsEl.innerHTML = results.map(p => `` ).join("") || `
Не найдено
`; resultsEl.querySelectorAll(".modal-prod-item").forEach(item => { item.onclick = () => { let pid = parseInt(item.dataset.pid); newProduct = results.find(r => r.id === pid); currentEl.querySelector("span").textContent = newProduct.name; searchBlock.style.display = "none"; resultsEl.innerHTML = ""; searchInput.value = ""; // Hide manual kcal field since now it's a product let kcalGroup = overlay.querySelector("#m-kcal-group"); if (kcalGroup) kcalGroup.style.display = "none"; }; }); }, 300); }; } function showActivityModal() { let types = Object.entries(ACTIVITY_NAMES); showModal(`

Активность

`, async overlay => { let atype = overlay.querySelector("#m-atype").value; let desc = overlay.querySelector("#m-desc").value.trim(); let min = parseInt(overlay.querySelector("#m-min").value) || null; let sets = parseInt(overlay.querySelector("#m-sets").value) || null; let reps = parseInt(overlay.querySelector("#m-reps").value) || null; let dist = parseFloat(overlay.querySelector("#m-dist").value) || null; let time = overlay.querySelector("#m-time").value; let kcalRaw = overlay.querySelector("#m-kcal").value.trim(); let kcal = kcalRaw ? parseFloat(kcalRaw) : null; if (kcal === null || isNaN(kcal)) { if (min && atype !== "manual") { kcal = activityKcal(atype, min, parseFloat(state.latestWeight?.weight_kg) || 75); } else { kcal = null; } } let label = atype === "manual" ? desc : ACTIVITY_NAMES[atype]; if (!label) return false; await DB.addActivity(state.date, time, "manual", label, min, kcal, { activity_type: atype, sets, reps, distance_m: dist }); }, overlay => { // Dynamic field visibility based on type let sel = overlay.querySelector("#m-atype"); let update = () => { let t = sel.value; overlay.querySelector("#m-desc-row").style.display = t === "manual" ? "" : "none"; overlay.querySelector("#m-sets-row").style.display = t === "squats" ? "" : "none"; overlay.querySelector("#m-dist-row").style.display = t === "swim" ? "" : "none"; // Auto-calc kcal when minutes change let minEl = overlay.querySelector("#m-min"); let kcalEl = overlay.querySelector("#m-kcal"); if (t !== "manual" && minEl.value && !kcalEl.value) { let w = parseFloat(state.latestWeight?.weight_kg) || 75; kcalEl.placeholder = activityKcal(t, parseInt(minEl.value) || 0, w) || "авто"; } }; sel.onchange = update; overlay.querySelector("#m-min").oninput = () => { let t = sel.value; if (t !== "manual") { let w = parseFloat(state.latestWeight?.weight_kg) || 75; let m = parseInt(overlay.querySelector("#m-min").value) || 0; overlay.querySelector("#m-kcal").placeholder = m ? activityKcal(t, m, w) : "авто"; } }; update(); }); } function showSleepModal(existing) { showModal(`

Сон

`, async overlay => { await DB.saveSleep( state.date, overlay.querySelector("#m-bed").value || null, overlay.querySelector("#m-asleep").value || null, overlay.querySelector("#m-wake").value || null, parseInt(overlay.querySelector("#m-nap").value) || 0, overlay.querySelector("#m-notes").value.trim() || null ); }); } // --------------------------------------------------------------------------- // PRODUCTS screen // --------------------------------------------------------------------------- async function renderProducts(container) { let debounceTimer = null; let expandedId = null; // currently expanded product card id container.innerHTML = `
`; // Sub-tab switching container.querySelectorAll(".sub-tab").forEach(btn => { btn.onclick = () => { container.querySelectorAll(".sub-tab").forEach(b => b.classList.remove("active")); btn.classList.add("active"); let tab = btn.dataset.tab; container.querySelector("#products-tab").style.display = tab === "products" ? "" : "none"; container.querySelector("#recipes-tab").style.display = tab === "recipes" ? "" : "none"; if (tab === "recipes") renderRecipes(container.querySelector("#recipes-tab")); }; }); let input = container.querySelector("#prod-search"); let countEl = container.querySelector("#prod-count"); let listEl = container.querySelector("#prod-results"); let lastResults = []; async function doSearch(resetExpanded) { let q = input.value.trim(); if (q.length < 2) { listEl.innerHTML = `
Введите название продукта
`; countEl.textContent = ""; lastResults = []; return; } if (resetExpanded) expandedId = null; lastResults = await DB.searchProducts(q); countEl.textContent = lastResults.length ? `Найдено: ${lastResults.length}` : ""; if (!lastResults.length) { listEl.innerHTML = `
Ничего не найдено
`; return; } renderList(); } function renderList() { listEl.innerHTML = lastResults.map(p => renderProdCard(p)).join(""); bindProdCards(listEl); } function renderProdCard(p) { let open = p.id === expandedId; let kcal = p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) : null; return `
${esc(p.name)} ${p.brand ? `${esc(p.brand)}` : ""} ${p.weight_g ? `${p.weight_g}г` : ""} ${sourceBadge(p.source)}
${kcal != null ? kcal + " кк" : "?"}
${open ? renderProdDetails(p) : ""}
`; } function renderProdDetails(p) { let r = v => v != null ? v : "—"; // Micronutrient chips for this product (per 100g) let RDI = BMR.RDI; let microChips = []; for (let [key, rdi] of Object.entries(RDI)) { if (!p[key]) continue; let pct = Math.round(p[key] / rdi * 100); if (pct < 1) continue; let label = NUTRIENT_LABELS[key] || key; let cls = pct < 30 ? "nut-low" : pct < 80 ? "nut-mid" : "nut-ok"; microChips.push({ pct, html: `
${label} ${pct}%
` }); } microChips.sort((a, b) => a.pct - b.pct); return `
${r(p.protein_per_100g)}Б
${r(p.fat_per_100g)}Ж
${r(p.carbs_per_100g)}У
${r(p.fiber_per_100g)}Кл
${microChips.length ? `
${microChips.map(c => c.html).join("")}
` : ""} ${p.composition ? `
${esc(p.composition)}
` : ""}
`; } function bindProdCards(el) { el.querySelectorAll(".prod-card-header").forEach(hdr => { hdr.onclick = () => { let card = hdr.closest(".prod-card"); let pid = parseInt(card.dataset.pid); expandedId = (expandedId === pid) ? null : pid; renderList(); }; }); el.querySelectorAll(".prod-add-btn").forEach(btn => { btn.onclick = e => { e.stopPropagation(); showAddFoodFromProduct(parseInt(btn.dataset.pid)); }; }); el.querySelectorAll(".prod-edit-btn").forEach(btn => { btn.onclick = async e => { e.stopPropagation(); let p = await DB.getProduct(parseInt(btn.dataset.pid)); if (p) showEditProductModal(p, () => doSearch(false)); }; }); el.querySelectorAll(".prod-del-btn").forEach(btn => { btn.onclick = async e => { e.stopPropagation(); let pid = parseInt(btn.dataset.pid); let refs = await DB.getProductRefs(pid); if (refs.food > 0 || refs.recipes > 0) { await appAlert(`Нельзя удалить: продукт используется в ${refs.food ? refs.food + " записях еды" : ""}${refs.food && refs.recipes ? " и " : ""}${refs.recipes ? refs.recipes + " рецептах" : ""}`); return; } await DB.deleteProduct(pid); doSearch(false); }; }); } function showAddFoodFromProduct(productId) { showModal(`

Добавить в дневник

`, async overlay => { let amount = parseAmount(overlay.querySelector("#m-amount").value); let time = overlay.querySelector("#m-time").value; let note = overlay.querySelector("#m-note").value.trim() || null; if (isNaN(amount) || amount <= 0) return false; await DB.addFoodEntry(state.date, time, productId, amount, note); }); } input.oninput = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => doSearch(true), 300); }; listEl.innerHTML = `
Введите название продукта
`; input.focus(); // Add custom product button let addCustomBtn = container.querySelector("#add-custom-product"); if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true)); container.querySelector("#scan-chzn")?.addEventListener("click", () => showChznScanModal(() => doSearch(true))); // Export all custom data (manual products + recipes + supplements) container.querySelector("#export-all-custom")?.addEventListener("click", async () => { let manualProducts = await DB.getManualProducts(); let allRecipes = await exportAllRecipes(); let supps = await DB.getSupplements(); let bundle = { products: manualProducts, recipes: allRecipes, supplements: supps }; downloadFile("baldav-custom-data.json", JSON.stringify(bundle, null, 2), "application/json"); showToast(`Экспорт: ${manualProducts.length} продуктов, ${allRecipes.length} рецептов, ${supps.length} добавок`); }); // Import custom data container.querySelector("#import-all-custom")?.addEventListener("click", () => { pickFile(".json", async (text) => { try { let data = JSON.parse(text); let imported = { products: 0, recipes: 0, supplements: 0 }; if (data.products && Array.isArray(data.products)) { for (let p of data.products) { // Dedup by name_lower + source let existing = await DB.searchProducts(p.name_lower || p.name?.toLowerCase() || "", 1); if (existing.some(e => e.name_lower === (p.name_lower || p.name?.toLowerCase()) && e.source === (p.source || "manual"))) continue; delete p.id; await DB.addProduct(p); imported.products++; } } if (data.recipes && Array.isArray(data.recipes)) { await importRecipes(data.recipes); imported.recipes = data.recipes.length; } if (data.supplements && Array.isArray(data.supplements)) { await importSupplements(data.supplements); imported.supplements = data.supplements.length; } showToast(`Импорт: ${imported.products} продуктов, ${imported.recipes} рецептов, ${imported.supplements} добавок`); doSearch(true); } catch (e) { showToast("Ошибка импорта: " + e.message); } }); }); } function showAddProductModal(onSaved, prefill) { let pf = prefill || {}; let micros = pf.micros || {}; let v = (x) => (x === null || x === undefined || x === "") ? "" : x; let microHtml = PRODUCT_MICRO_FIELDS.map(f => { let raw = micros[f.key]; if (raw != null && f.mult) raw = Math.round(raw / f.mult * 1000) / 1000; return `
`; }).join(""); // Честный знак source: header note + composition/weight hint (not stored as columns). let chznNote = pf.source === "chzn" ? `
Данные из Честного знака${pf.gtin ? " · GTIN " + esc(pf.gtin) : ""}${pf.net_weight_g ? " · нетто " + esc(pf.net_weight_g) + " г" : ""} ${pf.composition ? `
Состав: ${esc(pf.composition)}
` : ""}
` : ""; showModal(`

Новый продукт

${chznNote}

Нутриенты на 100г (опционально):

${microHtml}
`, async overlay => { let name = overlay.querySelector("#np-name").value.trim(); if (!name) return false; let data = { name, kcal_per_100g: parseFloat(overlay.querySelector("#np-kcal").value) || null, protein_per_100g: parseFloat(overlay.querySelector("#np-protein").value) || null, fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null, carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null, water_pct: parseFloat(overlay.querySelector("#np-water").value) || null, source: pf.source === "chzn" ? "chzn" : "manual", }; for (let f of PRODUCT_MICRO_FIELDS) { let val = parseFloat(overlay.querySelector(`#np-${f.key}`).value) || null; if (val && f.mult) val *= f.mult; data[f.key] = val; } await DB.addProduct(data); if (onSaved) onSaved(); }); } // Честный знак scanner: camera + photo/manual, decoded by bundled zxing-wasm // (ZXing-C++: frontend/lib/zxing-wasm.js + zxing_reader.wasm) — fully offline, // no Google Play Services, and far better at LOCATING a DataMatrix than the // pure-JS port. Live frames are cropped to the central ROI and upscaled for // speed/reliability. On decode -> ChZ.resolve -> prefill the new-product modal. function showChznScanModal(onSaved) { if (!ChZ.hasSession()) { appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh)."); return; } let ov = document.createElement("div"); ov.className = "modal-overlay"; ov.innerHTML = ` `; document.body.appendChild(ov); let stream = null, scanning = false, busy = false, zoomFactor = 1; let base = winStack.length; let statusEl = ov.querySelector("#cz-status"); let video = ov.querySelector("#cz-video"); let canvas = document.createElement("canvas"); let cctx = canvas.getContext("2d", { willReadFrequently: true }); function stopCamera() { scanning = false; try { let s = video && video.srcObject; if (s && s.getTracks) s.getTracks().forEach(t => t.stop()); if (video) video.srcObject = null; } catch {} } winPush("chzn-scan", () => { stopCamera(); ov.remove(); }); function close() { let count = winStack.length - base; for (let i = 0; i < count; i++) winStack.pop().close(); _skipPops = count; if (count > 0) history.go(-count); } // Close this modal AND wait for its history.go popstate(s) to flush before the // caller opens the next modal. Opening it synchronously after close() races the // async history.go (it would unwind the NEW modal's entry) and desyncs the back // stack — that was the "swipe-back does nothing" bug on scan -> add-product. function closeThen() { let count = winStack.length - base; for (let i = 0; i < count; i++) winStack.pop().close(); if (count <= 0) return Promise.resolve(); _skipPops = count; return new Promise(res => { let remaining = count; let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } }; window.addEventListener("popstate", h); history.go(-count); }); } async function onDecoded(rawVal) { if (busy) return; busy = true; scanning = false; statusEl.textContent = "Код распознан, запрашиваю КБЖУ..."; try { let prefill = await ChZ.resolve(rawVal); stopCamera(); await closeThen(); showAddProductModal(onSaved, prefill); } catch (e) { busy = false; statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз."; if (stream) { scanning = true; tick(); } } } // Central ROI of the current video frame, upscaled to a square -> ImageData. // Cropping to the guide box removes clutter and makes the code larger, which // is exactly what the locator needs (full-frame search was slow/unreliable). function grabROI() { let vw = video.videoWidth, vh = video.videoHeight; if (!vw || !vh) return null; // The square container shows the video center-cropped (object-fit:cover), so // sample exactly that center square (WYSIWYG). zxing-wasm locates the code // anywhere within it — no need for the user to fill a tiny box; use zoom to // enlarge the code at a focusable distance instead. let side = Math.min(vw, vh) / zoomFactor; // digital zoom crops tighter to center let sx = (vw - side) / 2, sy = (vh - side) / 2; let out = Math.min(Math.min(vw, vh), 768); // enough detail, still fast canvas.width = out; canvas.height = out; cctx.drawImage(video, sx, sy, side, side, 0, 0, out, out); return cctx.getImageData(0, 0, out, out); } async function tick() { if (busy || !scanning) return; let img = grabROI(); if (img) { try { let raw = await ChZ.decodeImageData(img, ["DataMatrix"]); if (raw) { onDecoded(raw); return; } } catch (e) { /* keep scanning */ } } if (scanning) setTimeout(tick, 100); } // Wire zoom slider / torch / continuous focus from the track capabilities. // Zoom is the real answer to "the code is too small at focus distance" — the // user enlarges the code optically instead of moving the phone dangerously close. let torchOn = false; function setupCameraControls() { let track = stream && stream.getVideoTracks && stream.getVideoTracks()[0]; if (!track || !track.getCapabilities) return; let caps = {}; try { caps = track.getCapabilities() || {}; } catch {} try { if (caps.focusMode && caps.focusMode.includes("continuous")) { track.applyConstraints({ advanced: [{ focusMode: "continuous" }] }).catch(() => {}); } } catch {} // Zoom slider: prefer hardware zoom; otherwise fall back to digital zoom // (CSS-scale the preview + crop the decode ROI). Always shown so the user can // enlarge a small code instead of moving the phone inside its focus range. let zw = ov.querySelector("#cz-zoom-wrap"); let zi = ov.querySelector("#cz-zoom"); if (caps.zoom && caps.zoom.max > caps.zoom.min) { zi.min = caps.zoom.min; zi.max = caps.zoom.max; zi.step = caps.zoom.step || (caps.zoom.max - caps.zoom.min) / 100 || 0.1; zi.value = ((track.getSettings && track.getSettings()) || {}).zoom || caps.zoom.min; zi.oninput = () => { track.applyConstraints({ advanced: [{ zoom: parseFloat(zi.value) }] }).catch(() => {}); }; } else { zi.min = 1; zi.max = 5; zi.step = 0.1; zi.value = 1; zi.oninput = () => { zoomFactor = parseFloat(zi.value) || 1; video.style.transformOrigin = "center center"; video.style.transform = "scale(" + zoomFactor + ")"; }; } zw.style.display = "flex"; if (caps.torch) { let tb = ov.querySelector("#cz-torch"); tb.style.display = "block"; tb.onclick = () => { torchOn = !torchOn; track.applyConstraints({ advanced: [{ torch: torchOn }] }).catch(() => {}); tb.style.background = torchOn ? "rgba(255,214,10,.35)" : ""; }; } } async function startCamera() { try { statusEl.textContent = "Загрузка декодера..."; await ChZ.zxReady(); } catch (e) { statusEl.textContent = "Декодер не загрузился: " + (e.message || e); return; } try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" }, width: { ideal: 1920 }, height: { ideal: 1080 } }, }); video.srcObject = stream; await video.play(); setupCameraControls(); scanning = true; statusEl.textContent = "Наведите на код (используйте зум, если код мелкий)…"; tick(); } catch (e) { statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код."; } } ov.querySelector("#cz-photo").onclick = () => { pickFile("image/*", async (buf) => { try { await ChZ.zxReady(); let bmp = await createImageBitmap(new Blob([buf])); let c = document.createElement("canvas"); c.width = bmp.width; c.height = bmp.height; let x = c.getContext("2d", { willReadFrequently: true }); x.drawImage(bmp, 0, 0); let img = x.getImageData(0, 0, bmp.width, bmp.height); let raw = await ChZ.decodeImageData(img, ["DataMatrix", "QRCode"]); if (raw) onDecoded(raw); else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое/крупное фото."; } catch (e) { statusEl.textContent = "Ошибка распознавания: " + (e.message || e); } }, true); }; ov.querySelector("#cz-manual").onclick = async () => { let code = await appPrompt("Содержимое кода DataMatrix (01…21…93…):", ""); if (code) onDecoded(code.trim()); }; ov.querySelector("#cz-close").onclick = () => close(); startCamera(); } function showEditProductModal(p, onSaved) { let microHtml = PRODUCT_MICRO_FIELDS.map(f => { let val = p[f.key] ?? ""; if (val && f.mult) val = Math.round(val / f.mult * 1000) / 1000; return `
`; }).join(""); showModal(`

Редактирование продукта

Нутриенты на 100г:

${microHtml}
`, async overlay => { let name = overlay.querySelector("#ep-name").value.trim(); if (!name) return false; let waterPct = parseFloat(overlay.querySelector("#ep-water").value) || null; let sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5", "water_pct=?6"]; let vals = [ name, parseFloat(overlay.querySelector("#ep-kcal").value) || null, parseFloat(overlay.querySelector("#ep-protein").value) || null, parseFloat(overlay.querySelector("#ep-fat").value) || null, parseFloat(overlay.querySelector("#ep-carbs").value) || null, waterPct, ]; let idx = 7; for (let f of PRODUCT_MICRO_FIELDS) { let val = parseFloat(overlay.querySelector(`#ep-${f.key}`).value) || null; if (val && f.mult) val *= f.mult; sets.push(`${f.key}=?${idx}`); vals.push(val); idx++; } sets.push(`user_edited=1`); vals.push(p.id); await window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: `UPDATE products SET ${sets.join(", ")} WHERE id=?${idx}`, values: vals, }); if (onSaved) onSaved(); }); // Export single product setTimeout(() => { let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); let expBtn = overlay?.querySelector("#ep-export"); if (expBtn) expBtn.onclick = () => { let clean = { ...p }; delete clean.id; downloadFile(`baldav-product-${(p.name || "").slice(0, 30)}.json`, JSON.stringify({ products: [clean] }, null, 2), "application/json"); }; }, 100); } // --------------------------------------------------------------------------- // RECIPES screen // --------------------------------------------------------------------------- async function renderRecipes(container) { let recipes = await DB.getRecipes(); // Pre-calculate nutrition for each recipe to show kcal in list let nutritionMap = {}; for (let r of recipes) { try { nutritionMap[r.id] = await DB.calculateRecipeNutrition(r.id); } catch (e) { nutritionMap[r.id] = null; } } container.innerHTML = `

Рецепты

${recipes.length ? recipes.map(r => { let n = nutritionMap[r.id]; let kcalStr = n ? n.kcal + " кк" : ""; let macroStr = n ? `Б${n.protein} Ж${n.fat} У${n.carbs}` : ""; return `
${esc(r.name)} ${r.cooking_method_name ? `${esc(r.cooking_method_name)}` : ""} ${r.servings > 1 ? `${r.servings} порц.` : ""}
${kcalStr}
${macroStr ? `
${macroStr}
` : ""}
`; }).join("") : `
Нет рецептов. Нажмите + чтобы создать.
`}
`; container.querySelector("#add-recipe-btn").onclick = () => showRecipeEditorModal(null); container.querySelector("#export-recipes-btn").onclick = async () => { let allRecipes = await exportAllRecipes(); let json = JSON.stringify(allRecipes, null, 2); downloadFile("baldav-recipes.json", json, "application/json"); }; container.querySelector("#import-recipes-btn").onclick = () => { pickFile(".json", async (text) => { let data = JSON.parse(text); await importRecipes(data); renderRecipes(container); }); }; container.querySelectorAll(".recipe-card").forEach(card => { card.onclick = () => { let rid = parseInt(card.dataset.rid); showRecipeDetailModal(rid); }; }); } // --------------------------------------------------------------------------- // Recipe detail modal — view recipe, log to food diary // --------------------------------------------------------------------------- async function showRecipeDetailModal(recipeId) { let recipe = await DB.getRecipe(recipeId); if (!recipe) return; let ingredients = await DB.getRecipeIngredients(recipeId); let nutr = await DB.calculateRecipeNutrition(recipeId); let ingList = ingredients.map(ing => { let name = ing.product_name ?? ing.custom_name ?? "?"; let cm = ing.cooking_method_name ? ` (${ing.cooking_method_name})` : ""; return `
  • ${esc(name)}${cm} ${ing.amount_g} г
  • `; }).join(""); showModal(`

    ${esc(recipe.name)}

    ${recipe.cooking_method_name ? `

    Способ: ${esc(recipe.cooking_method_name)}

    ` : ""} ${recipe.servings > 1 ? `

    Порций: ${recipe.servings}

    ` : ""} ${recipe.notes ? `

    ${esc(recipe.notes)}

    ` : ""}
    Ингредиенты
      ${ingList || `
    • нет ингредиентов
    • `}
    ${nutr ? `
    Пищевая ценность (на порцию)
    ${nutr.kcal}ккал
    ${nutr.protein}Б
    ${nutr.fat}Ж
    ${nutr.carbs}У
    ${nutr.fiber}Кл
    Микронутриенты (на порцию, % РСП)
    ${(() => { let RDI = BMR.RDI; let chips = []; for (let [key, rdi] of Object.entries(RDI)) { let val = nutr[key]; if (!val) continue; let pct = Math.round(val / rdi * 100); if (pct < 1) continue; let label = NUTRIENT_LABELS[key] || key; let cls = pct < 30 ? "nut-low" : pct < 80 ? "nut-mid" : "nut-ok"; chips.push({ pct, html: '
    ' + label + " " + pct + "%
    " }); } chips.sort((a, b) => a.pct - b.pct); return chips.map(c => c.html).join(""); })()}
    ` : ""}
    ${nutr ? `1 порция = ${Math.round(nutr.totalWeight / (recipe.servings || 1))} г` : ""}
    `, () => {}); // Wire up buttons after DOM is ready let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); let logBtn = overlay.querySelector("#rd-log"); let editBtn = overlay.querySelector("#rd-edit"); let exportBtn = overlay.querySelector("#rd-export"); let delBtn = overlay.querySelector("#rd-delete"); logBtn.onclick = async () => { let amountG = parseAmount(overlay.querySelector("#rd-amount").value); if (isNaN(amountG) || amountG <= 0) return; await DB.addFoodFromRecipe(state.date, nowTime(), recipeId, amountG); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen(state.screen); }; exportBtn.onclick = async () => { let ings = await DB.getRecipeIngredients(recipeId); let exported = { name: recipe.name, cooking_method_id: recipe.cooking_method_id, servings: recipe.servings, notes: recipe.notes, ingredients: ings.map(i => ({ product_name: i.product_name, custom_name: i.custom_name, amount_g: i.amount_g, cooking_method_id: i.cooking_method_id, kcal_per_100g: i.kcal_per_100g, protein_per_100g: i.protein_per_100g, fat_per_100g: i.fat_per_100g, carbs_per_100g: i.carbs_per_100g, })), }; downloadFile(`${recipe.name}.json`, JSON.stringify(exported, null, 2), "application/json"); }; editBtn.onclick = () => { if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); showRecipeEditorModal(recipeId); }; delBtn.onclick = async () => { await DB.deleteRecipe(recipeId); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("recipes"); }; } // --------------------------------------------------------------------------- // Recipe editor modal — create / edit recipe with ingredient list // --------------------------------------------------------------------------- async function showRecipeEditorModal(recipeId) { let isNew = !recipeId; let recipe = isNew ? { name: "", cooking_method_id: null, servings: 1, notes: "" } : await DB.getRecipe(recipeId); if (!recipe) return; let cookingMethods = await DB.getCookingMethods(); let existingIngredients = isNew ? [] : await DB.getRecipeIngredients(recipeId); // Local in-memory ingredient list for editing // Each item: { product_id, product_name, custom_name, weight_g, cooking_method_id, cooking_method_name } let ingredients = existingIngredients.map(ing => ({ product_id: ing.product_id, product_name: ing.product_name, custom_name: ing.custom_name, weight_g: ing.amount_g, cooking_method_id: ing.cooking_method_id, cooking_method_name: ing.cooking_method_name, })); let cmOptions = cookingMethods.map(cm => `` ).join(""); function renderIngredientRows() { if (ingredients.length === 0) return `
    Добавьте ингредиенты
    `; return ingredients.map((ing, idx) => { let name = ing.product_name ?? ing.custom_name ?? "?"; return `
    ${esc(name)} ${ing.weight_g} г ${ing.cooking_method_name ? `${esc(ing.cooking_method_name)}` : ""}
    `; }).join(""); } // Calculate totals from current in-memory ingredients, with cooking loss function calcTotals() { let kcal = 0, protein = 0, fat = 0, carbs = 0; let defaultMethodId = parseInt(document.querySelector("#re-method")?.value) || 0; for (let ing of ingredients) { if (!ing._product) continue; let f = (ing.weight_g || 0) / 100; let ik = (ing._product.kcal_per_100g ?? 0) * f; let ip = (ing._product.protein_per_100g ?? 0) * f; let ifat = (ing._product.fat_per_100g ?? 0) * f; let ic = (ing._product.carbs_per_100g ?? 0) * f; // Apply cooking loss let cmId = ing.cooking_method_id || defaultMethodId; let cm = cmId ? cookingMethods.find(m => m.id === cmId) : null; if (cm) { ik *= (1 - (cm.kcal_loss_pct ?? 0) / 100); ip *= (1 - (cm.protein_loss_pct ?? 0) / 100); ifat *= (1 - (cm.fat_loss_pct ?? 0) / 100); ic *= (1 - (cm.carbs_loss_pct ?? 0) / 100); } kcal += ik; protein += ip; fat += ifat; carbs += ic; } let servings = parseFloat(document.querySelector("#re-servings")?.value) || 1; return { kcal: Math.round(kcal / servings), protein: Math.round(protein / servings * 10) / 10, fat: Math.round(fat / servings * 10) / 10, carbs: Math.round(carbs / servings * 10) / 10, }; } function updateTotalsDisplay() { let el = document.querySelector("#re-totals"); if (!el) return; let t = calcTotals(); el.innerHTML = `${t.kcal} кк | Б${t.protein} Ж${t.fat} У${t.carbs}`; } showModal(`

    ${isNew ? "Новый рецепт" : "Редактирование рецепта"}

    Ингредиенты
    ${renderIngredientRows()}
    `, async overlay => { let name = overlay.querySelector("#re-name").value.trim(); if (!name) return false; if (ingredients.length === 0) return false; let methodId = parseInt(overlay.querySelector("#re-method").value) || null; let servings = parseFloat(overlay.querySelector("#re-servings").value) || 1; let notes = overlay.querySelector("#re-notes").value.trim() || null; let rid; if (isNew) { rid = await DB.saveRecipe(name, methodId, servings, notes); } else { rid = recipeId; await DB.updateRecipe(rid, name, methodId, servings, notes); await DB.clearRecipeIngredients(rid); } for (let ing of ingredients) { await DB.addRecipeIngredient( rid, ing.product_id, ing.custom_name, ing.weight_g, ing.cooking_method_id ); } // Cache computed nutrients on the recipe for daily tracking await DB.cacheRecipeNutrients(rid); renderScreen("recipes"); }); // Set initial method value let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); if (recipe.cooking_method_id) { overlay.querySelector("#re-method").value = recipe.cooking_method_id; } // Servings or method change recalculates totals overlay.querySelector("#re-servings").oninput = updateTotalsDisplay; overlay.querySelector("#re-method").onchange = updateTotalsDisplay; // Bind delete buttons and add ingredient button function rebindIngredients() { let listEl = overlay.querySelector("#re-ing-list"); listEl.innerHTML = renderIngredientRows(); listEl.querySelectorAll(".recipe-ing-del").forEach(btn => { btn.onclick = (e) => { e.stopPropagation(); let idx = parseInt(btn.dataset.idx); ingredients.splice(idx, 1); rebindIngredients(); updateTotalsDisplay(); }; }); } rebindIngredients(); // Add ingredient button — shows inline search overlay.querySelector("#re-add-ing").onclick = () => { showAddIngredientModal(cookingMethods, (newIng) => { ingredients.push(newIng); rebindIngredients(); updateTotalsDisplay(); }); }; // Load product data for existing ingredients (for nutrition calc) for (let i = 0; i < ingredients.length; i++) { if (ingredients[i].product_id) { ingredients[i]._product = await DB.getProduct(ingredients[i].product_id); } } updateTotalsDisplay(); } // --------------------------------------------------------------------------- // Add ingredient sub-modal — search product, set weight, optional cooking method // --------------------------------------------------------------------------- function showAddIngredientModal(cookingMethods, onAdd) { let selectedProduct = null; let cmOptions = cookingMethods.map(cm => `` ).join(""); showModal(`

    Добавить ингредиент

    `, async overlay => { let weight = parseFloat(overlay.querySelector("#ing-weight").value); if (isNaN(weight) || weight <= 0) return false; let methodId = parseInt(overlay.querySelector("#ing-method").value) || null; let methodName = methodId ? cookingMethods.find(cm => cm.id === methodId)?.name ?? null : null; if (selectedProduct) { onAdd({ product_id: selectedProduct.id, product_name: selectedProduct.name, custom_name: null, weight_g: weight, cooking_method_id: methodId, cooking_method_name: methodName, _product: selectedProduct, }); } else { let custom = overlay.querySelector("#ing-custom").value.trim(); if (!custom) return false; onAdd({ product_id: null, product_name: null, custom_name: custom, weight_g: weight, cooking_method_id: methodId, cooking_method_name: methodName, _product: null, }); } return "norender"; // sub-modal: close without re-rendering parent screen }); let overlays = document.querySelectorAll(".modal-overlay"); let overlay = overlays[overlays.length - 1]; let selectedEl = overlay.querySelector("#ing-selected"); let customBlock = overlay.querySelector("#ing-custom-block"); let search = createProductSearch(overlay.querySelector("#ing-search-wrap"), (product) => { selectedProduct = product; selectedEl.style.display = "block"; selectedEl.innerHTML = `${esc(product.name)}`; customBlock.style.display = "none"; selectedEl.onclick = () => { selectedProduct = null; selectedEl.style.display = "none"; customBlock.style.display = ""; }; }); search.focus(); } // --------------------------------------------------------------------------- // Food modal: add recipe option // --------------------------------------------------------------------------- function showFoodFromRecipeModal() { // Show a list of saved recipes to quickly log one showModal(`

    Записать рецепт

    Загрузка...
    `, async overlay => { let selected = overlay.querySelector(".recipe-pick-item.selected"); if (!selected) return false; let rid = parseInt(selected.dataset.rid); let time = overlay.querySelector("#rfr-time").value; let amountG = parseAmount(overlay.querySelector("#rfr-amount").value); if (isNaN(amountG) || amountG <= 0) return false; await DB.addFoodFromRecipe(state.date, time, rid, amountG); }); // Load recipes into the list let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); let listEl = overlay.querySelector("#rfr-list"); (async () => { let recipes = await DB.getRecipes(); if (!recipes.length) { listEl.innerHTML = `
    Нет рецептов. Создайте в Продукты → Рецепты.
    `; return; } // Calculate nutrition for display let items = []; for (let r of recipes) { let n = await DB.calculateRecipeNutrition(r.id); items.push({ ...r, nutr: n }); } listEl.innerHTML = items.map(r => `
    ${esc(r.name)} ${r.nutr ? `${r.nutr.kcal} кк` : ""}
    ` ).join(""); let amountInput = overlay.querySelector("#rfr-amount"); let hintEl = overlay.querySelector("#rfr-hint"); listEl.querySelectorAll(".recipe-pick-item").forEach(item => { item.onclick = () => { listEl.querySelectorAll(".recipe-pick-item").forEach(i => i.classList.remove("selected")); item.classList.add("selected"); let r = items.find(x => x.id === parseInt(item.dataset.rid)); if (r && r.nutr) { let servingG = Math.round(r.nutr.totalWeight / (r.servings || 1)); amountInput.value = servingG; hintEl.textContent = `1 порция = ${servingG} г, ${r.nutr.kcal} кк`; } }; }); })(); } // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // GPS screen — map, recording, sessions list, GPX import // --------------------------------------------------------------------------- let gpsMap = null; let gpsTrailLine = null; let gpsMarker = null; // At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water // you are for the time of day (need-so-far minus logged intake). During the session the native // service adds sweat on top. Intake comes from what you log, not a button. async function seedHydrationDeficit() { if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off try { let target = parseInt(state.profile?.daily_water_ml) || 2000; let now = new Date(); let nowMin = now.getHours() * 60 + now.getMinutes(); let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00 let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart))); let needSoFar = target * frac; let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10)); let startDeficit = Math.max(0, Math.min(5000, Math.round(needSoFar - intake))); let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600; await window.__TAURI__.core.invoke("set_hydration_config", { enabled: true, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: startDeficit }); } catch (e) { console.error("seedHydration", e); } } async function renderGpsScreen(container) { // Check Bluetooth state if BLE device is paired let bleAddr = await DB.getProfileValue("ble_device_addr"); if (bleAddr) { try { let btState = await window.__TAURI__.core.invoke("ble_check_bluetooth"); if (btState === "off") { showToast("Bluetooth выключен"); } else if (btState === "no_adapter") { showToast("Bluetooth недоступен"); } } catch {} } let PAGE_SIZE = 20; let sessionCard = (s) => `
    ${s.date} ${s.start_time ?? ""} ${s.activity_type ?? ""}
    ${s.total_distance_m != null ? GPS.formatDistance(s.total_distance_m) : "—"} · ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""} · ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""} ${s.elevation_gain_m ? " · +" + Math.round(s.elevation_gain_m) + "м" : ""}
    `; let sessions = await DB.getGpsSessions(PAGE_SIZE, 0); let isRec = GPS.recording.active; container.innerHTML = `

    История

    ${sessions.length ? sessions.map(sessionCard).join("") : `
    Нет записей
    `}
    `; // Recording button // Save activity type on change container.querySelector("#gps-type").onchange = (e) => { localStorage.setItem("baldav_gps_type", e.target.value); }; container.querySelector("#gps-rec-btn").onclick = async () => { if (GPS.recording.active) { renderScreen("recording"); } else { let type = container.querySelector("#gps-type").value; localStorage.setItem("baldav_gps_type", type); // A fresh start shows the selected route by default (overriding a prior toggle-off). if (localStorage.getItem("baldav_active_route")) localStorage.setItem("baldav_route_shown", "1"); let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; // Get active bike for bike sessions — store bike data in recording state if (type === "bike") { let bike = await DB.getActiveBike(); if (bike) { GPS.recording.bikeId = bike.id; GPS.recording.bikeWeightKg = bike.weight_kg ?? 0; GPS.recording.surfaceCrr = ({asphalt: 0.004, mixed: 0.006, gravel: 0.008})[bike.surface_type] ?? 0.006; GPS.recording.tirePressureBar = bike.tire_pressure_bar ?? 3.0; GPS.recording.chainringTeeth = bike.chainring_teeth; GPS.recording.cogTeeth = bike.cog_teeth; GPS.recording.wheelDiameterInch = bike.wheel_diameter_inch; GPS.recording.gearCount = bike.gear_count; } } // Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's // choice: they can decline, and the two "Не спрашивать" settings suppress each prompt. await ensureSystemReady(!!state.profile?.ble_device_addr, !GPS.isHrOnly(type)); // Workout/VR (HR-only): pass the profile so finish can compute kcal from HR (Keytel). let hrProfile = GPS.isHrOnly(type) ? { weightKg: wkg, age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), sex: state.profile?.sex || "m", heightCm: parseFloat(state.profile?.height_cm) || 178, } : null; await GPS.startRecording(type, wkg, hrProfile); seedHydrationDeficit(); renderScreen("recording"); } }; // Session click → detail view; LONG-PRESS → multi-select for merging tracks. Keep sid a // STRING (numeric id from the SQL plugin is corrupted off-by-1 for large i64 values). let selMode = false, selected = new Set(); let selBar = container.querySelector("#gps-sel-bar"); let selCount = container.querySelector("#gps-sel-count"); let selMergeBtn = container.querySelector("#gps-sel-merge"); let refreshSel = () => { container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid))); if (selBar) selBar.hidden = !selMode; if (selCount) selCount.textContent = "Выбрано " + selected.size; if (selMergeBtn) selMergeBtn.disabled = selected.size < 2; }; let exitSel = () => { selMode = false; selected.clear(); refreshSel(); }; let toggleSel = (sid) => { if (selected.has(sid)) selected.delete(sid); else selected.add(sid); if (selected.size === 0) { exitSel(); return; } refreshSel(); }; let enterSel = (sid) => { selMode = true; selected.add(sid); refreshSel(); }; // Long-press: hold ~500 ms without moving. Works for touch and (desktop) mouse; the // click that follows is suppressed so a long-press doesn't also open the detail. let attachLongPress = (item) => { let timer = null, longFired = false; let cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; let start = () => { longFired = false; cancel(); timer = setTimeout(() => { timer = null; longFired = true; if (!selMode) enterSel(item.dataset.sid); else toggleSel(item.dataset.sid); }, 500); }; item.addEventListener("touchstart", start, { passive: true }); item.addEventListener("touchmove", cancel, { passive: true }); item.addEventListener("touchend", cancel); item.addEventListener("mousedown", start); item.addEventListener("mouseup", cancel); item.addEventListener("mouseleave", cancel); item.onclick = () => { if (longFired) { longFired = false; return; } if (selMode) toggleSel(item.dataset.sid); else showSessionDetail(item.dataset.sid); }; }; let bindSessionClick = attachLongPress; container.querySelectorAll(".gps-session-item").forEach(bindSessionClick); let sessTs = (s, which) => new Date((s.date || "1970-01-01") + "T" + ((which === "end" ? s.end_time : s.start_time) || "00:00:00")); if (container.querySelector("#gps-sel-cancel")) container.querySelector("#gps-sel-cancel").onclick = () => exitSel(); if (selMergeBtn) selMergeBtn.onclick = async () => { if (selected.size < 2) return; let ids = [...selected]; let sess = []; for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); } if (sess.length < 2) return; sess.sort((a, b) => sessTs(a) - sessTs(b)); // Different bikes must not be merged: gear/weight differ, so power and cadence would be // computed wrong for the mixed track. Refuse and tell the rider how to proceed. let bikeSet = new Set(sess.map(s => s.bike_id ?? "none")); if (bikeSet.size > 1) { await appAlert("Велосипеды у выбранных треков разные - склеить нельзя: передача и вес отличаются, мощность и каденс посчитались бы неверно.\n\nЕсли это на самом деле один велосипед, задайте его всем трекам одинаково (в детали трека: «Велосипед», затем «Пересчитать») и склеивайте снова."); return; } let gapWarn = false; for (let i = 1; i < sess.length; i++) { let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000; if (gapMin > 5) gapWarn = true; } let msg = "Объединить " + sess.length + " тренировки в одну?" + (gapWarn ? "\n\nВнимание: между треками разрыв больше 5 минут - точки лягут с пропусками." : "") + "\n\nОбратимо: исходные тренировки помечаются в точках (можно будет разделить позже)."; if (!(await appConfirm(msg, "Объединить"))) return; try { selMergeBtn.disabled = true; selMergeBtn.textContent = "Объединяю..."; let targetId = await DB.mergeGpsSessions(ids); if (targetId) await GPS.recalculateSession(targetId, { noDelete: true }); showToast("Объединено в одну тренировку"); exitSel(); renderScreen("gps"); } catch (e) { console.error("[merge]", e); showToast("Ошибка объединения: " + (e.message || e)); selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить"; } }; // Pagination: "Показать ещё" loads the next page from the DB and appends it. let sessOffset = sessions.length; let moreBtn = container.querySelector("#gps-more"); if (moreBtn) moreBtn.onclick = async () => { moreBtn.disabled = true; let label = moreBtn.textContent; moreBtn.textContent = "Загрузка..."; let next = await DB.getGpsSessions(PAGE_SIZE, sessOffset); let listEl = container.querySelector("#gps-session-list"); next.forEach(s => { let tmp = document.createElement("div"); tmp.innerHTML = sessionCard(s); let item = tmp.firstElementChild; bindSessionClick(item); listEl.appendChild(item); }); sessOffset += next.length; moreBtn.disabled = false; moreBtn.textContent = label; if (next.length < PAGE_SIZE) moreBtn.style.display = "none"; }; // Three-dot menu let menuBtn = container.querySelector("#gps-menu-btn"); let menu = container.querySelector("#gps-menu"); menuBtn.onclick = () => menu.classList.toggle("hidden"); document.addEventListener("click", e => { if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden"); }, { once: false }); // Download map tiles — region picker container.querySelector("#gps-draw-route").onclick = () => showDrawRouteScreen(container); container.querySelector("#gps-routes").onclick = () => { menu.classList.add("hidden"); showRoutesScreen(container); }; container.querySelector("#gps-download-map").onclick = () => { menu.classList.add("hidden"); showMapDownloadModal(); }; // Clear tile cache container.querySelector("#gps-clear-cache").onclick = async () => { menu.classList.add("hidden"); let count = await TileCache.count(); await TileCache.clear(); console.log("Cache cleared"); }; // Recover points from native files — rebuilds sessions whose DB rows fell behind // the service's JSONL (e.g. screen-off import glitch). Works fully on-device. container.querySelector("#gps-recover").onclick = async () => { menu.classList.add("hidden"); if (GPS.recording.active) { showToast("Нельзя во время записи"); return; } // Keep the screen awake — recovery runs in the WebView and stalls if it sleeps. let wl = null; try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {} // Visible progress bar (fixed at the bottom) so it's clear it's working. let prog = document.createElement("div"); prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600"; prog.textContent = "Восстановление: подготовка..."; document.body.appendChild(prog); try { let res = await GPS.recoverFromFiles((i, n) => { prog.textContent = `Восстановление: файл ${i}/${n}`; }); if (res.error === "recording") { prog.remove(); showToast("Нельзя во время записи"); return; } let list = res.results || []; let totalPts = list.reduce((a, r) => a + (r.imported || 0), 0); let sessions = list.filter(r => r.imported > 0).length; let unmatched = list.filter(r => r.unmatched).length; let removed = res.removed || 0; // Show the result IN the bar — a toast gets overwritten by the Bluetooth // check when the screen re-renders. Tap the bar to close and refresh. prog.textContent = `Готово: файлов ${list.length}, вставлено ${totalPts} в ${sessions} сессий, удалено ${removed}, сирот ${unmatched}. Нажмите, чтобы закрыть`; prog.style.cursor = "pointer"; prog.onclick = () => { prog.remove(); renderScreen("gps"); }; } catch (e) { console.error("[recover] failed", e); prog.textContent = "Ошибка восстановления: " + (e.message || e) + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); } finally { if (wl) { try { await wl.release(); } catch {} } } }; // Recompute stored elevation/kcal for every recorded session (formula fix). // Stats-only: never touches points. Same progress-bar UX as recovery. container.querySelector("#gps-recalc-stats").onclick = async () => { menu.classList.add("hidden"); if (GPS.recording.active) { showToast("Нельзя во время записи"); return; } let wl = null; try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {} let prog = document.createElement("div"); prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600"; prog.textContent = "Пересчёт: подготовка..."; document.body.appendChild(prog); try { let res = await GPS.recomputeAllStats((i, n) => { prog.textContent = `Пересчёт: ${i}/${n}`; }); if (res.error) { prog.textContent = "Ошибка: " + res.error + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); return; } prog.textContent = `Готово: пересчитано ${res.updated}, пропущено ${res.skipped}. Нажмите, чтобы закрыть`; prog.style.cursor = "pointer"; prog.onclick = () => { prog.remove(); renderScreen("gps"); }; } catch (e) { console.error("[recalc-all] failed", e); prog.textContent = "Ошибка пересчёта: " + (e.message || e) + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); } finally { if (wl) { try { await wl.release(); } catch {} } } }; // Unified import — detect by extension container.querySelector("#gps-import").onclick = () => { menu.classList.add("hidden"); pickFile("", async (data, file) => { console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data); let ext = file.name.split(".").pop().toLowerCase(); console.log("[IMPORT] ext:", ext); let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; try { if (ext === "gpx") { let text = typeof data === "string" ? data : new TextDecoder().decode(data); await GPS.importGPX(text, wkg); } else if (ext === "ftb" || ext === "zip") { let buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data); console.log("[IMPORT] FTB buf size:", buf.byteLength); let count = await GPS.importFTB(buf, wkg); console.log("[IMPORT] done, count:", count); } else { console.error("[IMPORT] Unsupported file type: " + ext); } renderScreen("gps"); } catch (e) { console.error("[IMPORT] error:", e, String(e)); } }, true); }; // Goals button — manage goal markers container.querySelector("#gps-goals-btn").onclick = () => showGoalMarkersModal(); } // --------------------------------------------------------------------------- // Goal markers management modal (IDEA 2) // --------------------------------------------------------------------------- async function showGoalMarkersModal() { let markers = await DB.getGoalMarkers(); let html = `

    Цели (маркеры)

    ${markers.length ? markers.map(m => `
    ${esc(m.name)}
    ${m.lat.toFixed(5)}, ${m.lon.toFixed(5)}
    ${m.notes ? `
    ${esc(m.notes)}
    ` : ""}
    `).join("") : `
    Нет маркеров. Нажмите "Добавить" для создания.
    `}
    `; showModal(html, () => {}); // Hide save/cancel since this is a management modal setTimeout(() => { let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); if (!overlay) return; let btns = overlay.querySelector(".modal-btns"); if (btns) btns.style.display = "none"; // Delete buttons overlay.querySelectorAll(".gm-del").forEach(btn => { btn.onclick = async (e) => { e.stopPropagation(); await DB.deleteGoalMarker(parseInt(btn.dataset.mid)); // Close and reopen modal if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); showGoalMarkersModal(); }; }); // Add button overlay.querySelector("#gm-add").onclick = () => { if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); showAddGoalMarkerModal(); }; }, 100); } async function showAddGoalMarkerModal() { showModal(`

    Новый маркер

    Нажмите на карту для выбора точки
    `, async overlay => { let name = overlay.querySelector("#gm-name").value.trim(); let lat = parseFloat(overlay.querySelector("#gm-lat").value); let lon = parseFloat(overlay.querySelector("#gm-lon").value); let notes = overlay.querySelector("#gm-notes").value.trim() || null; if (!name) return false; if (isNaN(lat) || isNaN(lon)) return false; await DB.addGoalMarker(name, lat, lon, notes); // Return to goals list showGoalMarkersModal(); }); // Initialize map after DOM is ready setTimeout(() => { let mapEl = document.querySelector("#gm-map"); let coordsEl = document.querySelector("#gm-coords"); let latEl = document.querySelector("#gm-lat"); let lonEl = document.querySelector("#gm-lon"); if (!mapEl) return; // Center on user's last known position or default let defaultLat = 55.75, defaultLon = 37.62; // Try to get last GPS point from recent sessions DB.getGpsSessions(1).then(async sessions => { if (sessions.length > 0) { let pts = await DB.getGpsPoints(sessions[0].id_str ?? sessions[0].id); if (pts.length > 0) { let last = pts[pts.length - 1]; defaultLat = last.lat; defaultLon = last.lon; map.setView([defaultLat, defaultLon], 14); } } }).catch(() => {}); L.Icon.Default.imagePath = "lib/"; let map = L.map(mapEl, { attributionControl: false }).setView([defaultLat, defaultLon], 14); addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); new CachedLayer({ maxZoom: 19 }).addTo(map); let marker = null; map.on("click", (e) => { let lat = e.latlng.lat; let lon = e.latlng.lng; latEl.value = lat; lonEl.value = lon; coordsEl.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; if (marker) map.removeLayer(marker); marker = L.marker([lat, lon]).addTo(map); }); // Also try current position navigator.geolocation?.getCurrentPosition( pos => { if (!latEl.value) { map.setView([pos.coords.latitude, pos.coords.longitude], 15); } }, () => {}, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 } ); }, 200); } // --------------------------------------------------------------------------- // Draw manual route on map // --------------------------------------------------------------------------- async function fetchElevations(points, onProgress) { // Terrain elevation, tried in order until one answers a batch: // 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m). // 2. open-elevation.com (SRTM, reachable in RU). // 3. open-meteo (often blocked by RU carriers, kept in case). // 4. OpenTopoData public (free, no key, srtm30m) — last-resort keyless analog. // Batches of 100 (also OpenTopoData's max locations/request). The GPXZ key lives // only in GPS.config (localStorage), never in git. let elevations = new Array(points.length).fill(null); let gpxzKey = GPS.config.gpxzKey && GPS.config.gpxzKey.trim(); for (let i = 0; i < points.length; i += 100) { let batch = points.slice(i, i + 100); let got = null; if (gpxzKey) { try { let latlons = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|"); let resp = await fetch("https://api.gpxz.io/v1/elevation/points?latlons=" + encodeURIComponent(latlons), { headers: { "x-api-key": gpxzKey } }); if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); } else { console.log("[gpxz] http", resp.status); } } catch (e) { console.log("[gpxz] batch fail:", e.message); } } if (got) { for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; continue; } try { let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }), }); if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); } } catch (e) { console.log("[open-elevation] batch fail:", e.message); } if (!got) { try { let lats = batch.map(p => p.lat.toFixed(5)).join(","); let lons = batch.map(p => p.lon.toFixed(5)).join(","); let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`); if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; } } catch (e) { console.log("[open-meteo] batch fail:", e.message); } } if (!got) { try { let locs = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|"); let resp = await fetch("https://api.opentopodata.org/v1/srtm30m?locations=" + encodeURIComponent(locs)); if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); } } catch (e) { console.log("[opentopodata] batch fail:", e.message); } } if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; if (onProgress) try { onProgress(Math.min(i + 100, points.length), points.length); } catch {} } return elevations.some(x => x != null) ? elevations : null; } // Colour for the roads overlay by activity type (road_points.atype holds the full // activity_type: "walk" / "bike" / "run"). Configurable in GPS settings. function roadColor(atype) { // get_road_points (Rust) returns atype as a single letter: "w" walk, "r" run, "b" bike/other. let c = GPS.config; if (atype === "w") return c.roadColorWalk || "#8B5E3C"; if (atype === "r") return c.roadColorRun || "#e67e22"; return c.roadColorBike || "#2266aa"; } // Line thickness by how often a stretch was ridden (road_points.passes from rebuild_roads): // base width x (1..3), +0.2 per extra pass, capped at 3x. Optional — disabled by the flag. function roadsThickOn() { return localStorage.getItem("baldav_roads_thick") !== "0"; } function roadWeight(passes, base) { base = base || 3; if (!roadsThickOn()) return base; return base * Math.min(3, 1 + 0.2 * ((passes || 1) - 1)); } // Draw one road segment (array of {lat,lon,passes}) as ONE polyline, its width from the // segment's MEDIAN pass count. Earlier this split each segment into a run per weight change, // which exploded a 1.4k-segment network into ~84k polylines and janked the map; one width per // segment keeps it at one polyline per segment while still reading thicker on often-ridden // routes. `extra`: { pane, renderer, base, opacity, smoothFactor }. function drawRoadSegment(map, seg, color, out, extra) { if (!seg || seg.length < 2) return; extra = extra || {}; let base = extra.base || 3; let opts = { color, opacity: extra.opacity != null ? extra.opacity : 0.6 }; if (extra.pane) opts.pane = extra.pane; if (extra.renderer) opts.renderer = extra.renderer; if (extra.smoothFactor != null) opts.smoothFactor = extra.smoothFactor; let weight = base; if (roadsThickOn()) { let ps = seg.map(p => p.passes || 1).sort((a, b) => a - b); weight = roadWeight(ps[ps.length >> 1], base); // median pass → one width per segment } out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight }).addTo(map)); } function debounce(fn, ms) { let t; return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); }; } // Split flat road points into segments (break on >100 m gap or activity-type change) and // precompute each segment's bbox [minLat,minLon,maxLat,maxLon] for viewport culling. function buildRoadSegments(pts) { let segs = [], seg = []; const push = () => { if (seg.length >= 2) { let minLa = Infinity, maxLa = -Infinity, minLo = Infinity, maxLo = -Infinity; for (let p of seg) { if (p.lat < minLa) minLa = p.lat; if (p.lat > maxLa) maxLa = p.lat; if (p.lon < minLo) minLo = p.lon; if (p.lon > maxLo) maxLo = p.lon; } segs.push({ pts: seg, atype: seg[0].atype, b: [minLa, minLo, maxLa, maxLo] }); } seg = []; }; for (let i = 0; i < pts.length; i++) { if (seg.length > 0) { let prev = seg[seg.length - 1]; let dlat = (pts[i].lat - prev.lat) * 111000; let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180); if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) push(); } seg.push(pts[i]); } push(); return segs; } // Viewport culling: clear `out` layers, then draw ONLY segments whose bbox meets the padded // view. Keeps the drawn set to a screenful instead of the whole (possibly huge) road network. function cullRoadsInto(map, segments, out, pad, extra) { out.forEach(l => map.removeLayer(l)); out.length = 0; let v = map.getBounds().pad(pad); let S = v.getSouth(), N = v.getNorth(), W = v.getWest(), E = v.getEast(); for (let s of segments) { let bb = s.b; // [minLat,minLon,maxLat,maxLon] if (bb[2] < S || bb[0] > N || bb[1] > E || bb[3] < W) continue; // bbox fully outside view drawRoadSegment(map, s.pts, roadColor(s.atype), out, extra); } } // Resolve once the Leaflet map has finished its initial layout, so layers added // after this actually render (adding a polyline before the map is loaded/sized // leaves it invisible until the next redraw). Awaits the real 'load' event — the // single event→await bridge — then forces a size recalc. async function whenMapReady(map) { if (!map._loaded) await new Promise(res => map.once("load", res)); map.invalidateSize(); } // Draw the active saved route (localStorage baldav_active_route) as a colored line // on a Leaflet map. Removes `prev` first. Returns the new layer, or null if none. // Hypsometric tint (like old topo maps): low = green, mid = yellow, high = brown. // t in [0,1] (normalized elevation). function routeElevColor(t) { t = Math.max(0, Math.min(1, t)); let lerp = (a, b, f) => Math.round(a + (b - a) * f); let c1, c2, f; if (t < 0.5) { c1 = [46, 125, 50]; c2 = [253, 216, 53]; f = t / 0.5; } // green -> yellow else { c1 = [253, 216, 53]; c2 = [109, 76, 65]; f = (t - 0.5) / 0.5; } // yellow -> brown return `rgb(${lerp(c1[0], c2[0], f)},${lerp(c1[1], c2[1], f)},${lerp(c1[2], c2[2], f)})`; } async function drawActiveRouteOverlay(map, prev) { if (prev) { try { map.removeLayer(prev); } catch {} } let id = localStorage.getItem("baldav_active_route"); if (!id) return null; let route = await DB.getRoute(id); if (!route || !route.points || route.points.length < 2) return null; return drawRoutePolyline(map, route.points, route.color); } // Draw the impassable "nogo" circles (DB.nogo_areas) as red translucent circles. Tapping a // circle offers to remove it; onChange() (if given) fires after a removal so the caller can // re-fetch nogos and re-route. Returns the layer group (removed via the `prev` arg on redraw). async function drawNogoAreas(map, prev, onChange) { if (prev) { try { map.removeLayer(prev); } catch {} } let areas = await DB.getNogoAreas(); let group = L.layerGroup(); for (let a of areas) { let c = L.circle([a.lat, a.lon], { radius: a.radius_m || 25, color: "#e53935", weight: 2, fillColor: "#e53935", fillOpacity: 0.22 }); ((area) => c.on("click", async (e) => { L.DomEvent.stop(e); if (await appConfirm("Убрать пометку «нет проезда»?", "Убрать")) { await DB.deleteNogoArea(area.id); if (onChange) onChange(); } }))(a); c.addTo(group); } group.addTo(map); return group; } // nogos for the router: [{lat,lon,radius}] from the nogo_areas table. async function getNogosForRouter() { return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 })); } // --------------------------------------------------------------------------- // Ridden-roads router: route on the user's own tracks first, BRouter for the rest // --------------------------------------------------------------------------- let _rideGraph = null; // { nodes:[[lat,lon]], adj:Map, grid:Map, cellDeg } async function loadRideGraph(force) { if (_rideGraph && !force) return _rideGraph; let raw = await window.__TAURI__.core.invoke("get_ride_graph", { minSpeedMs: 0.8 }); let g = JSON.parse(raw); let nodes = g.nodes || []; let adj = new Map(); for (let e of (g.edges || [])) { let a = e[0], b = e[1], d = e[2]; if (!adj.has(a)) adj.set(a, []); adj.get(a).push([b, d]); if (!adj.has(b)) adj.set(b, []); adj.get(b).push([a, d]); } let cellDeg = 0.00045; // ~50 m nearest-node buckets let grid = new Map(); for (let i = 0; i < nodes.length; i++) { let key = Math.round(nodes[i][0] / cellDeg) + "," + Math.round(nodes[i][1] / cellDeg); if (!grid.has(key)) grid.set(key, []); grid.get(key).push(i); } _rideGraph = { nodes, adj, grid, cellDeg }; return _rideGraph; } function _nearestRideNode(g, lat, lon, maxM) { let cx = Math.round(lat / g.cellDeg), cy = Math.round(lon / g.cellDeg), best = -1, bestD = Infinity; for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) { let arr = g.grid.get((cx + dx) + "," + (cy + dy)); if (!arr) continue; for (let i of arr) { let d = GPS.distance(lat, lon, g.nodes[i][0], g.nodes[i][1]); if (d < bestD) { bestD = d; best = i; } } } return (best >= 0 && bestD <= maxM) ? { node: best, dist: bestD } : null; } function _rideDijkstra(g, src, dst, maxDist) { let distMap = new Map([[src, 0]]), prev = new Map(), heap = [[0, src]], seen = new Set(); while (heap.length) { let mi = 0; for (let i = 1; i < heap.length; i++) if (heap[i][0] < heap[mi][0]) mi = i; let popped = heap.splice(mi, 1)[0], d = popped[0], u = popped[1]; if (seen.has(u)) continue; seen.add(u); if (u === dst) break; if (d > maxDist) return null; let nb = g.adj.get(u); if (!nb) continue; for (let vw of nb) { let v = vw[0], w = vw[1]; if (seen.has(v)) continue; let nd = d + w; if (nd < (distMap.get(v) ?? Infinity)) { distMap.set(v, nd); prev.set(v, u); heap.push([nd, v]); } } } if (!seen.has(dst)) return null; let path = [], u = dst; while (u != null) { path.push([g.nodes[u][0], g.nodes[u][1]]); u = prev.get(u); } path.reverse(); return { path, dist: distMap.get(dst) }; } // Route a→b on ridden roads. Returns [{lat,lon}] (with the exact waypoints at the ends) or null // when either end isn't near ridden roads or the ridden path is too indirect (fall back to BRouter). async function rideRoute(aLat, aLon, bLat, bLon, opts) { opts = opts || {}; let g = await loadRideGraph(); if (!g || !g.nodes.length) return null; let sn = _nearestRideNode(g, aLat, aLon, opts.snapM ?? 60); let dn = _nearestRideNode(g, bLat, bLon, opts.snapM ?? 60); if (!sn || !dn) return null; if (sn.node === dn.node) return null; // same node → let BRouter handle a tiny leg let straight = GPS.distance(aLat, aLon, bLat, bLon); let cap = (opts.maxRatio ?? 2.5) * Math.max(300, straight); let res = _rideDijkstra(g, sn.node, dn.node, cap + 2000); if (!res || res.dist > cap) return null; return [{ lat: aLat, lon: aLon }].concat(res.path.map(p => ({ lat: p[0], lon: p[1] })), [{ lat: bLat, lon: bLon }]); } // Move a point `d` metres along bearing `brDeg` (for a "start forward, don't U-turn" helper). function _offsetLatLon(lat, lon, brDeg, d) { let R = 6378137, br = brDeg * Math.PI / 180, la1 = lat * Math.PI / 180, lo1 = lon * Math.PI / 180; let la2 = Math.asin(Math.sin(la1) * Math.cos(d / R) + Math.cos(la1) * Math.sin(d / R) * Math.cos(br)); let lo2 = lo1 + Math.atan2(Math.sin(br) * Math.sin(d / R) * Math.cos(la1), Math.cos(d / R) - Math.sin(la1) * Math.sin(la2)); return { lat: la2 * 180 / Math.PI, lon: lo2 * 180 / Math.PI }; } // On-the-fly detour: from the current position, build a connector back ONTO the saved route // PAST the blockage, avoiding nogos, preferring to start in the travel direction (no 180 if a // bearing is given). Does NOT modify the saved route. Blockage reference = nearest nogo that // sits on the route (<=150 m), else the current position's nearest route point. Target = a // route point ~1.5 km further along; on failure step further (up to the route end). // Returns { points:[{lat,lon}], target:{lat,lon} } or null. // Reroute back onto the active route PAST a blockage ahead. Only acts when a nogo // actually sits on the route ahead of the current progress point (in the travel // direction) — otherwise returns {noBlockage:true} and builds nothing (pressing it // with no blockage used to route to a bogus far point). Returns {points,target} on // success, null if a detour couldn't be built, {noBlockage:true} if nothing to do. async function rejoinReroute(cur, bearingDeg, routePts, nogos) { if (!cur || !routePts || routePts.length < 2) return null; let cum = [0]; for (let i = 1; i < routePts.length; i++) cum[i] = cum[i-1] + GPS.distance(routePts[i-1].lat, routePts[i-1].lon, routePts[i].lat, routePts[i].lon); let inNogo = (p) => nogos && nogos.some(n => GPS.distance(p.lat, p.lon, n.lat, n.lon) <= (n.radius || 25) + 15); // current progress = nearest route point to the current position let curIdx = 0, curD = Infinity; for (let i = 0; i < routePts.length; i++) { let d = GPS.distance(cur.lat, cur.lon, routePts[i].lat, routePts[i].lon); if (d < curD) { curD = d; curIdx = i; } } // travel direction along the route: increasing index unless the compass bearing // clearly points the other way (handles routes ridden in reverse / loops). let step = 1; if (bearingDeg != null && !isNaN(bearingDeg)) { let ja = Math.min(routePts.length - 1, curIdx + 1), jb = Math.max(0, curIdx - 1); if (ja !== jb) { let fb = GPS.bearing(routePts[jb].lat, routePts[jb].lon, routePts[ja].lat, routePts[ja].lon); let diff = Math.abs(((bearingDeg - fb + 540) % 360) - 180); // 0 = aligned w/ increasing idx, 180 = opposite if (diff > 90) step = -1; // travelling against increasing index → forward is decreasing idx } } // blockage: first route point within a nogo AHEAD, within a lookahead window const LOOKAHEAD = 2500; let blocked = null; for (let i = curIdx; i >= 0 && i < routePts.length; i += step) { if (Math.abs(cum[i] - cum[curIdx]) > LOOKAHEAD) break; if (inNogo(routePts[i])) { blocked = i; break; } } if (blocked == null) return { noBlockage: true }; // rejoin target: first route point clear of every nogo AND >= margin past the blockage const CLEAR_MARGIN = 300; let ti = blocked; while (ti >= 0 && ti < routePts.length) { if (!inNogo(routePts[ti]) && Math.abs(cum[ti] - cum[blocked]) >= CLEAR_MARGIN) break; ti += step; } if (ti < 0 || ti >= routePts.length) ti = step > 0 ? routePts.length - 1 : 0; let helper = (bearingDeg != null && !isNaN(bearingDeg)) ? _offsetLatLon(cur.lat, cur.lon, bearingDeg, 40) : null; let jump = Math.max(1, Math.round(routePts.length / 50)); for (let attempt = 0; attempt < 6; attempt++) { let target = routePts[ti]; let straight = GPS.distance(cur.lat, cur.lon, target.lat, target.lon); let wps = helper ? [[cur.lon, cur.lat], [helper.lon, helper.lat], [target.lon, target.lat]] : [[cur.lon, cur.lat], [target.lon, target.lat]]; try { let gpx = await BRouter.route(wps, "trekking", nogos); let rp = (GPS.parseRouteGPX(gpx) || {}).points || []; if (rp.length >= 2) { let rlen = 0; for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon); if (rlen <= Math.max(500, straight * 3)) return { points: rp.map(p => ({ lat: p.lat, lon: p.lon })), target: { lat: target.lat, lon: target.lon } }; } } catch (e) { /* try a further rejoin point */ } let next = ti + step * jump; if (next < 0 || next >= routePts.length) break; ti = next; } return null; } // --------------------------------------------------------------------------- // Share image ("Похвастаться"): track shape + stats as a story-format PNG // --------------------------------------------------------------------------- // Story-format duration: "1h 29min" / "29 min" (GPS.formatDuration is h:mm:ss). function _shareDuration(sec) { let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60); if (m === 60) { h += 1; m = 0; } return h > 0 ? (m > 0 ? h + "h " + m + "min" : h + "h") : m + " min"; } // Project track points into a WxH box (equirectangular at track scale, padded, Y down). function _projectTrack(points, w, h, pad) { let lats = points.map(p => p.lat), lons = points.map(p => p.lon); let minLa = Math.min(...lats), maxLa = Math.max(...lats), minLo = Math.min(...lons), maxLo = Math.max(...lons); let kx = Math.cos((minLa + maxLa) / 2 * Math.PI / 180); // lon → same metric scale as lat let spanX = ((maxLo - minLo) * kx) || 1e-6, spanY = (maxLa - minLa) || 1e-6; let scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY); let ox = (w - spanX * scale) / 2, oy = (h - spanY * scale) / 2; return points.map(p => ({ x: ox + (p.lon - minLo) * kx * scale, y: oy + (maxLa - p.lat) * scale })); } // Which fields the share image can show, and their defaults. `f` is the per-image // selection (opts.fields), falling back to defaults so an unset key stays on. let SHARE_FIELDS = ["track", "distance", "time", "speed", "maxspeed", "elev", "kcal", "date"]; let SHARE_FIELD_LABELS = { track: "Форма трека", distance: "Дистанция", time: "Время", speed: "Ср. скорость", maxspeed: "Макс. скорость", elev: "Набор высоты", kcal: "Калории", date: "Дата" }; let SHARE_FIELD_DEFAULTS = { track: true, distance: true, time: true, speed: true, maxspeed: false, elev: true, kcal: true, date: true }; function shareFieldOn(fields, key) { return fields && key in fields ? !!fields[key] : SHARE_FIELD_DEFAULTS[key]; } // Build the share PNG dataURL. opts.bg: "transparent" | "photo"; opts.fields: per-field on/off. async function buildShareImage(session, stats, points, opts) { opts = opts || {}; let f = opts.fields; let W = 1080, H = 1920; let cv = document.createElement("canvas"); cv.width = W; cv.height = H; let ctx = cv.getContext("2d"); if (opts.bg === "photo") { let bg = localStorage.getItem("baldav_share_bg"); if (bg) { await new Promise((res) => { let img = new Image(); img.onload = () => { let s = Math.max(W / img.width, H / img.height); let dw = img.width * s, dh = img.height * s; ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); res(); }; img.onerror = () => res(); img.src = bg; }); } let g = ctx.createLinearGradient(0, H * 0.4, 0, H); g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.55)"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } let hasTrack = shareFieldOn(f, "track") && points && points.length >= 2; // Track shape (top ~42%). if (hasTrack) { let boxTop = 90, boxH = Math.round(H * 0.42); let pts = _projectTrack(points.map(p => ({ lat: p.lat, lon: p.lon })), W, boxH, 130); let col = (window.GPS && GPS.config && GPS.config.trackColor) || "#2f6bff"; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.shadowColor = col; ctx.shadowBlur = 30; ctx.strokeStyle = col; ctx.lineWidth = 12; ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y + boxTop) : ctx.moveTo(p.x, p.y + boxTop)); ctx.stroke(); ctx.shadowBlur = 0; } // Stats. let cx = W / 2; function bigStat(y, value, label) { ctx.textAlign = "center"; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3; ctx.fillStyle = "#fff"; ctx.font = "700 100px system-ui, -apple-system, sans-serif"; ctx.fillText(value, cx, y); ctx.shadowBlur = 5; ctx.fillStyle = "rgba(255,255,255,0.85)"; ctx.font = "400 42px system-ui, -apple-system, sans-serif"; ctx.fillText(label, cx, y + 56); ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; } // Collect enabled stats; lay them out adaptively so turning some off leaves no gaps. let big = []; if (shareFieldOn(f, "distance")) big.push([(stats.distance / 1000).toFixed(1) + " km", "Distance"]); if (shareFieldOn(f, "time")) big.push([_shareDuration(stats.duration || 0), "Time"]); if (shareFieldOn(f, "speed")) big.push([(stats.avgSpeed || 0).toFixed(1) + " km/h", "Speed"]); if (shareFieldOn(f, "maxspeed") && stats.maxSpeed != null) big.push([(stats.maxSpeed || 0).toFixed(1) + " km/h", "Max speed"]); let footer = []; if (shareFieldOn(f, "elev") && stats.elevGain != null) footer.push("↑ " + Math.round(stats.elevGain) + " m"); if (shareFieldOn(f, "kcal") && stats.kcal != null) footer.push(Math.round(stats.kcal) + " kcal"); if (shareFieldOn(f, "date") && session && session.date) footer.push(session.date); // Position the stats+footer block: just under the track when it's shown, else centred. let blockH = big.length * 250 + (footer.length ? 90 : 0); let y0 = hasTrack ? Math.round(H * 0.55) : Math.round((H - blockH) / 2 + 90); big.forEach((s, i) => bigStat(y0 + i * 250, s[0], s[1])); if (footer.length) { ctx.textAlign = "center"; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 6; ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "400 36px system-ui, -apple-system, sans-serif"; ctx.fillText(footer.join(" · "), cx, y0 + big.length * 250 + 40); ctx.shadowBlur = 0; } return cv.toDataURL("image/png"); } // Send the generated PNG to the native layer: action "share" (system chooser) or "save" (SAF). async function exportShareImage(dataUrl, filename, action) { if (!dataUrl) return; let base64 = dataUrl.split(",")[1]; try { await window.__TAURI__.core.invoke("export_image", { filename, base64, action }); showToast(action === "share" ? "Открываю «Поделиться»…" : "Сохранение…"); } catch (e) { showToast("Экспорт картинки: " + (e.message || e)); } } // Preview modal with a transparent/photo toggle + Share/Save. function showShareModal(session, stats, points) { // Per-field on/off, remembered across shares. Missing keys fall back to defaults. let fields = {}; try { fields = JSON.parse(localStorage.getItem("baldav_share_fields") || "{}") || {}; } catch { fields = {}; } let fieldChecks = SHARE_FIELDS.map(k => `` ).join(""); let ov = document.createElement("div"); ov.className = "share-modal"; ov.innerHTML = ` `; document.body.appendChild(ov); // Register in the global window stack so the hardware/gesture back closes THIS // modal instead of paging the screens behind it (winPush adds a history entry). let base = winStack.length, closed = false; winPush("share", () => ov.remove()); let closeShare = () => { if (closed) return; closed = true; let count = winStack.length - base; for (let i = 0; i < count; i++) winStack.pop().close(); _skipPops = count; if (count > 0) history.go(-count); ov.remove(); }; let bg = "transparent", dataUrl = null; let img = ov.querySelector("#share-img"); let bt = ov.querySelector("#share-bg-t"), bp = ov.querySelector("#share-bg-p"); async function render() { dataUrl = await buildShareImage(session, stats, points, { bg, fields }); img.src = dataUrl; } ov.querySelectorAll(".share-field input").forEach(cb => cb.onchange = () => { fields[cb.dataset.field] = cb.checked; try { localStorage.setItem("baldav_share_fields", JSON.stringify(fields)); } catch {} render(); }); bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); }; bp.onclick = () => { if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана - задайте в Настройках"); return; } bg = "photo"; bp.classList.add("active"); bt.classList.remove("active"); render(); }; let filename = () => "baldav-" + ((session && session.date) || "track") + ".png"; ov.querySelector("#share-do").onclick = () => exportShareImage(dataUrl, filename(), "share"); ov.querySelector("#share-save").onclick = () => exportShareImage(dataUrl, filename(), "save"); ov.querySelector("#share-close").onclick = () => closeShare(); render(); } // Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries // Slope is coloured by DIRECTION of travel (point order = travel direction): // uphill climbs through a WARM fan (green -> yellow -> red -> brown -> black), // downhill drops through a COOL fan (green -> cyan -> blue -> navy). Flat (<1%) // is green either way. 1% bands: <1%=0 .. 9–10%=9, >=10% = last. let ROUTE_SLOPE_UP = [ "#2e7d32", "#7cb342", "#aeea00", "#ffeb3b", "#ffc107", "#ff9800", "#ff5722", "#e53935", "#b71c1c", "#6d4c41", "#000000", ]; let ROUTE_SLOPE_DOWN = [ "#2e7d32", "#2e9e5b", "#26a69a", "#00bcd4", "#29b6f6", "#1e88e5", "#1976d2", "#1565c0", "#0d47a1", "#0a357a", "#001f5c", ]; function routeSlopeColor(gradePct) { let g = gradePct || 0; let idx = Math.min(10, Math.floor(Math.abs(g))); return g < 0 ? ROUTE_SLOPE_DOWN[idx] : ROUTE_SLOPE_UP[idx]; } // Compact colour strips for the slope legend (every 1% band, 0 → 10%+), both fans. function slopeLegendHtml() { let strip = arr => arr .map(c => ``) .join(""); return `подъём ${strip(ROUTE_SLOPE_UP)} крутой  спуск ${strip(ROUTE_SLOPE_DOWN)} крутой`; } // Which BRouter profile to route with: the adventure variant (fords/grade5/bad trails // allowed) when the rider opted in, else the default trekking (avoids them). function trekProfile() { return localStorage.getItem("baldav_allow_adventure") === "1" ? "trekking-adventure" : "trekking"; } // Draw a route as a feature group: a wide colored casing (per segment — by slope // or by elevation, per baldav_route_color_mode) UNDER a thin identity-color line, // when the route has elevations; otherwise just the plain colored line. The casing // lives in a dedicated pane below the overlay pane, so a recorded track always // draws ON TOP of the route. Removable via map.removeLayer. function drawRoutePolyline(map, pts, color, mode) { mode = mode || localStorage.getItem("baldav_route_color_mode") || "slope"; // On a rotating map (leaflet-rotate) tile/overlay panes live INSIDE _rotatePane; // a pane created at the default mapPane level wouldn't rotate/zoom with the map // (route flashes then vanishes). Parent our pane to _rotatePane when it exists. if (!map.getPane("routePane")) { map.createPane("routePane", map._rotatePane || undefined); map.getPane("routePane").style.zIndex = 250; // between tilePane(200) and overlayPane(400) } let popts = { pane: "routePane", interactive: false }; let latlngs = pts.map(p => [p.lat, p.lon]); let group = L.featureGroup(); let st = GPS.routeElevStats(pts); if (st.hasEle) { let valid = st.smoothed.filter(e => e != null); let lo = Math.min(...valid), hi = Math.max(...valid), span = (hi - lo) || 1; for (let i = 0; i < pts.length - 1; i++) { // No elevation on this segment (e.g. a straight leg between planner waypoints) → leave // it as the thin identity line only, so road (casing) and straight (thin) read apart. if (st.smoothed[i] == null && st.smoothed[i+1] == null) continue; let col; if (mode === "elev") { let e = st.smoothed[i] ?? st.smoothed[i+1]; col = routeElevColor((e - lo) / span); } else { col = routeSlopeColor(st.grades[i]); } L.polyline([latlngs[i], latlngs[i+1]], { ...popts, color: col, weight: 14, opacity: 0.95 }).addTo(group); } } L.polyline(latlngs, { ...popts, color: color || "#e53935", weight: 3, opacity: 0.95 }).addTo(group); group.addTo(map); return group; } // Saved routes management page (import GPX, rename, color, delete, pick active). // Replaces the GPS container like showDrawRouteScreen; back returns to the GPS screen. async function showRoutesScreen(container) { let active = localStorage.getItem("baldav_active_route") || ""; async function render() { let routes = await DB.getRoutes(); let rows = routes.map(r => `
    ${esc(r.name)} ${r.wpts != null ? r.wpts + " тчк" : r.pts + " тчк"}${r.hasEle ? " · ⛰" : ""}
    `).join(""); container.innerHTML = `

    Сохранённые маршруты

    ${routes.length ? rows : `
    Нет маршрутов. Нажмите «Новый маршрут» — тапайте по карте опорные точки, линия проложится по дорогам (BRouter) прямо в телефоне. Или импортируйте готовый GPX.
    `}
    `; container.querySelector("#routes-back").onclick = () => renderScreen("gps"); container.querySelector("#routes-new").onclick = () => showRouteEditor(container, null); container.querySelector("#route-none").onchange = () => { active = ""; localStorage.removeItem("baldav_active_route"); showToast("Маршрут отключён"); }; container.querySelector("#routes-import").onclick = () => { pickFile(".gpx", async (text, file) => { let parsed; try { parsed = GPS.parseRouteGPX(text); } catch (e) { showToast("Ошибка чтения GPX"); return; } if (!parsed.points || parsed.points.length < 2) { showToast("В GPX нет линии маршрута"); return; } let defName = (parsed.name || (file.name || "маршрут")).replace(/\.gpx$/i, "").trim(); let name = await appPrompt("Название маршрута:", defName, "Импорт"); if (name == null) return; name = name.trim() || defName; let pts = parsed.points; // Best-effort: if the GPX had no , fetch terrain elevations now so the // route shows relief right away. Offline just skips it (import still works). if (!pts.some(p => p.ele != null)) { showToast("Загрузка высот..."); try { let eles = await fetchElevations(pts); if (eles) pts = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p)); } catch {} } await DB.addRoute(name, "#e53935", pts); showToast(`Импортирован: ${name} (${pts.length} тчк)`); render(); }); }; container.querySelectorAll(".route-row").forEach(row => { let id = row.getAttribute("data-id"); let curName = row.getAttribute("data-name"); row.querySelector(".route-name").onclick = () => showRouteDetail(container, id); row.querySelector("input[type=radio]").onchange = () => { active = id; localStorage.setItem("baldav_active_route", id); localStorage.setItem("baldav_route_shown", "1"); // selecting a route defaults its overlay ON showToast("Маршрут выбран активным"); }; row.querySelector(".route-color").onchange = async (e) => { await DB.setRouteColor(id, e.target.value); row.querySelector(".route-swatch").style.background = e.target.value; }; row.querySelector(".route-rename").onclick = async () => { let name = await appPrompt("Новое название:", curName, "Сохранить"); if (name == null || !name.trim()) return; await DB.renameRoute(id, name.trim()); render(); }; row.querySelector(".route-del").onclick = async () => { if (!(await appConfirm(`Удалить маршрут «${curName}»?`, "Удалить"))) return; await DB.deleteRoute(id); if (active === id) { active = ""; localStorage.removeItem("baldav_active_route"); } render(); }; }); } render(); } // Route preview: map (topo casing + tappable altitude/grade points) + elevation // profile chart + ascent/descent/max-grade — so you can see how hilly a hand-drawn // route is BEFORE riding it. Reuses fetchElevations / GPS.routeElevStats / Chart.js. // Elevation correction grid from ridden tracks (Rust get_elev_correction): per ~20m // cell, the median (measured − SRTM) offset over >= N passes. Cached per session. let _elevCorr = null; async function loadElevCorrection(force) { if (_elevCorr && !force) return _elevCorr; let minPasses = parseInt(localStorage.getItem("baldav_elev_corr_passes")) || 3; let raw = await window.__TAURI__.core.invoke("get_elev_correction", { minPasses }); let arr = JSON.parse(raw) || []; let dLat = 0.00018, dLon = 0.00027, cells = new Map(); for (let [lat, lon, delta] of arr) cells.set(Math.round(lat / dLat) + "," + Math.round(lon / dLon), delta); _elevCorr = { cells, dLat, dLon }; return _elevCorr; } // SRTM stays the base; nudge each point up/down by the SMOOTHED ridden offset where // you've ridden enough. No coverage -> delta 0 -> unchanged. Non-destructive (returns // a copy), so the stored route keeps its original elevations. async function applyRiddenElevCorrection(pts) { let corr; try { corr = await loadElevCorrection(); } catch { return pts; } if (!corr || !corr.cells.size) return pts; let deltas = pts.map(p => { if (p.ele == null) return 0; let ci = Math.round(p.lat / corr.dLat), cj = Math.round(p.lon / corr.dLon); let v0 = corr.cells.get(ci + "," + cj); if (v0 != null) return v0; for (let di = -1; di <= 1; di++) for (let dj = -1; dj <= 1; dj++) { let v = corr.cells.get((ci + di) + "," + (cj + dj)); if (v != null) return v; } return 0; // no ridden coverage here }); let half = 3; return pts.map((p, i) => { if (p.ele == null) return p; let sum = 0, cnt = 0; for (let j = Math.max(0, i - half); j <= Math.min(pts.length - 1, i + half); j++) { sum += deltas[j]; cnt++; } let d = cnt ? sum / cnt : 0; return d ? { ...p, ele: p.ele + d } : p; }); } async function showRouteDetail(container, routeId) { let route = await DB.getRoute(routeId); if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); showRoutesScreen(container); return; } let pts = await applyRiddenElevCorrection(route.points); let st = GPS.routeElevStats(pts); let mode = localStorage.getItem("baldav_route_color_mode") || "slope"; let legendHtml = mode === "slope" ? slopeLegendHtml() : `▬ низко → ▬ высоко`; container.innerHTML = `

    ${esc(route.name)}

    ${st.hasEle ? `
    ${legendHtml}
    ` : ""}
    ${GPS.formatDistance(st.distanceKm * 1000)}Длина
    ${st.hasEle ? "+" + st.ascent + " м" : "—"}Набор
    ${st.hasEle ? "−" + st.descent + " м" : "—"}Сброс
    ${st.hasEle ? (st.maxGrade >= 0 ? "+" : "") + st.maxGrade + " %" : "—"}Макс. уклон
    ${st.hasEle ? `
    Профиль высоты
    ` : `
    Высоты ещё не загружены — нажмите кнопку ниже (нужен интернет).
    `}
    `; container.querySelector("#rd-back").onclick = () => showRoutesScreen(container); container.querySelector("#rd-edit").onclick = () => showRouteEditor(container, routeId); container.querySelector("#rd-fetch").onclick = async (e) => { let btn = e.currentTarget, orig = btn.textContent; btn.disabled = true; btn.textContent = "Загрузка высот..."; let eles = null; try { eles = await fetchElevations(pts); } catch {} if (!eles) { showToast("Сервис высот недоступен"); btn.disabled = false; btn.textContent = orig; return; } let merged = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p)); await DB.updateRoutePoints(routeId, merged); showToast("Высоты загружены"); showRouteDetail(container, routeId); }; let mapEl = container.querySelector("#rd-map"); if (mapEl) { void mapEl.offsetHeight; // force layout so Leaflet reads the real size L.Icon.Default.imagePath = "lib/"; let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false }); // canvas: slope-coloured route vectors vs SVG compositor tile-memory limit map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing) new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map); let routeLayer = drawRoutePolyline(map, pts, route.color, mode); // Tappable vertices → altitude + grade to next point for (let i = 0; i < pts.length; i++) { let e = st.smoothed[i], g = st.grades[i]; let info = `#${i}${e != null ? " | " + Math.round(e) + " м" : ""}${i < pts.length - 1 ? " | " + (g >= 0 ? "+" : "") + g.toFixed(1) + "%" : ""}`; L.circleMarker([pts[i].lat, pts[i].lon], { radius: 4, color: "#fff", weight: 1, fillColor: "#000", fillOpacity: 0.5, interactive: true }) .bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map); } let bounds = L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.1); // On-map color-mode toggle (slope <-> elevation) — redraws the route in place. if (st.hasEle) { let colorCtl = L.control({ position: "topleft" }); colorCtl.onAdd = () => { let d = L.DomUtil.create("div", "sd-map-fs-btn"); d.innerHTML = mode === "slope" ? "∡" : "⛰"; d.title = "Раскраска: уклон / высота"; d.onclick = (e) => { e.stopPropagation(); mode = mode === "slope" ? "elev" : "slope"; localStorage.setItem("baldav_route_color_mode", mode); map.removeLayer(routeLayer); routeLayer = drawRoutePolyline(map, pts, route.color, mode); d.innerHTML = mode === "slope" ? "∡" : "⛰"; let leg = container.querySelector("#rd-legend"); if (leg) leg.innerHTML = mode === "slope" ? slopeLegendHtml() : `▬ низко → ▬ высоко`; }; return d; }; colorCtl.addTo(map); } // Fullscreen expand/collapse — reuses .sd-map-full + winStack, like the track map. let fsDiv; function toggleFs() { if (mapEl.classList.contains("sd-map-full")) { history.back(); return; } mapEl.classList.add("sd-map-full"); fsDiv.innerHTML = "✕"; map.invalidateSize(); map.fitBounds(bounds); winPush("fsRouteMap", () => { mapEl.classList.remove("sd-map-full"); fsDiv.innerHTML = "⛶"; map.invalidateSize(); map.fitBounds(bounds); }); } let fsCtl = L.control({ position: "topright" }); fsCtl.onAdd = () => { fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn"); fsDiv.innerHTML = "⛶"; fsDiv.title = "На весь экран"; fsDiv.onclick = (e) => { e.stopPropagation(); toggleFs(); }; return fsDiv; }; fsCtl.addTo(map); map.invalidateSize(); map.fitBounds(bounds); } if (st.hasEle) { let ctx = container.querySelector("#rd-elev-chart"); if (ctx) new Chart(ctx, { type: "line", data: { labels: st.cumKm.map(k => k.toFixed(1)), datasets: [{ data: st.smoothed, borderColor: "#ffb74d", borderWidth: 2, pointRadius: 0, fill: true, backgroundColor: "rgba(255,183,77,0.12)", tension: 0.3, // Steep (>8%) segments turn red — the sections that hurt on a singlespeed. segment: { borderColor: c => Math.abs(st.grades[c.p0DataIndex] || 0) > 8 ? "#e53935" : "#ffb74d" }, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { title: items => items[0].label + " км", label: it => Math.round(it.raw) + " м, уклон " + ((st.grades[it.dataIndex] || 0) >= 0 ? "+" : "") + (st.grades[it.dataIndex] || 0).toFixed(1) + "%", } }, }, scales: { x: { title: { display: true, text: "км", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, y: { title: { display: true, text: "м", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } }, }, }, }); } } // Draw a NEW saved route, or edit an existing one's geometry, right in the app — // tap to add points, undo the last, save. Elevations are fetched afterwards from // the preview screen (geometry-only here). New -> prompts a name; edit -> replaces // points_json (drops stale elevations — geometry changed) keeping name/color. async function showRouteEditor(container, routeId) { let editing = routeId != null; let route = editing ? await DB.getRoute(routeId) : null; // Editable waypoints. Planner-built routes store them separately (5-6 pts) + per-leg mode; // imported / legacy routes only have dense geometry → load that as waypoints (legs straight). let wpSaved = (editing && route && route.waypoints && route.waypoints.waypoints) ? route.waypoints : null; let points = wpSaved ? wpSaved.waypoints.map(p => ({ lat: p.lat, lon: p.lon })) : ((editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : []); let color = (editing && route) ? (route.color || "#e53935") : "#e53935"; let markers = []; // Per-leg road routing: each leg between consecutive waypoints is independently "road" // (BRouter geometry, with elevation, cached in legGeom[i]) or "straight". Default = road. let hitLines = []; // transparent fat polyline per leg — the tap/long-press target let displayLayer = null; // the visible route (casing+slope+thin line) via drawRoutePolyline let legMode = []; // "road" | "straight", length = points.length - 1 let legGeom = []; // routed [{lat,lon,ele}] per road leg, or null let legDangers = []; // [{lat,lon,type}] per road leg (fords/rough from BRouter tags) let dangerLayer = null; // danger markers overlay let defaultMode = "road"; let routeTimer = null; let dirty = new Set(); let nogos = []; // [{lat,lon,radius}] impassable circles the route must avoid let nogoLayer = null; // red circles overlay let nogoMode = false; // when on, a map tap drops a nogo instead of a waypoint container.innerHTML = `
    0 мДистанция
    0Точек
    тап карты — точка · тап линии — вставить точку · долгий тап — дорога/прямая · тап точки — удалить
    `; setTimeout(() => { let mapEl = document.querySelector("#re-map"); if (!mapEl) return; L.Icon.Default.imagePath = "lib/"; let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15); new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map); let distEl = document.querySelector("#re-dist"); let ptsEl = document.querySelector("#re-pts"); let dotIcon = L.divIcon({ className: "re-dot-wrap", html: `
    `, iconSize: [18, 18], iconAnchor: [9, 9], }); function normalizeLegs() { let need = Math.max(0, points.length - 1); while (legMode.length < need) { legMode.push(defaultMode); legGeom.push(null); legDangers.push(null); } legMode.length = need; legGeom.length = need; legDangers.length = need; } // Drawn/saved geometry for a leg: routed pts for a routed road leg (carry ele), else the // straight chord (also the fallback when a road leg couldn't be routed). function legPts(i) { if (legMode[i] === "road" && legGeom[i] && legGeom[i].length >= 2) return legGeom[i]; return [points[i], points[i + 1]]; } // Full dense geometry = concat of every leg (shared junction not duplicated); keeps ele. function fullGeom() { if (points.length < 2) return points.map(p => ({ lat: p.lat, lon: p.lon })); let out = [{ lat: points[0].lat, lon: points[0].lon }]; for (let i = 0; i < points.length - 1; i++) { let lp = legPts(i); for (let k = 1; k < lp.length; k++) out.push({ lat: lp[k].lat, lon: lp[k].lon, ele: lp[k].ele }); } return out; } function updateStats() { let g = fullGeom(), dist = 0; for (let i = 1; i < g.length; i++) dist += GPS.distance(g[i-1].lat, g[i-1].lon, g[i].lat, g[i].lon); distEl.textContent = GPS.formatDistance(dist); ptsEl.textContent = String(points.length); } // Visible route: casing (slope/elev colour) + thin identity line, like the detail view. function drawDisplay() { if (displayLayer) { map.removeLayer(displayLayer); displayLayer = null; } if (points.length >= 2) { let mode = localStorage.getItem("baldav_route_color_mode") || "slope"; displayLayer = drawRoutePolyline(map, fullGeom(), color, mode); } drawDangers(); } // Mark fords (water) and rough/abandoned tracks the route passes, so the rider sees the // "ад" BEFORE riding it. Counter in the stats row. Fed by BRouter WayTags per leg. function drawDangers() { if (dangerLayer) { map.removeLayer(dangerLayer); dangerLayer = null; } let all = []; for (let d of legDangers) if (Array.isArray(d)) all = all.concat(d); let fords = all.filter(x => x.type === "ford").length; let wet = all.filter(x => x.type === "wetcross").length; let rough = all.filter(x => x.type === "rough").length; let dEl = document.querySelector("#re-danger"); if (dEl) { let bits = []; if (fords) bits.push(`брод: ${fords}`); if (wet) bits.push(`возм.брод: ${wet}`); if (rough) bits.push(`грунт: ${rough}`); dEl.textContent = bits.length ? "⚠ " + bits.join(" · ") : ""; } if (!all.length) return; // ford = confirmed OSM ford (solid blue); wetcross = likely untagged ford from a high // BRouter river-class on a track (light cyan — a guess); rough = orange; ferry = purple. let COL = { ford: "#0288d1", wetcross: "#4dd0e1", rough: "#ff9800", ferry: "#9c27b0" }; let LBL = { ford: "брод", wetcross: "возм. брод", rough: "грунт/тропа", ferry: "паром" }; let g = L.layerGroup(); for (let d of all) { L.circleMarker([d.lat, d.lon], { radius: 7, color: "#fff", weight: 2, fillColor: COL[d.type] || "#9c27b0", fillOpacity: 1 }) .bindTooltip(LBL[d.type] || d.type, { direction: "top" }).addTo(g); } g.addTo(map); dangerLayer = g; } // Fat transparent hit target per leg (tap = insert, long-press = toggle road/straight). // pointer-events:stroke (CSS .re-hit) makes the invisible fat stroke clickable. function drawHitLines() { hitLines.forEach(l => map.removeLayer(l)); hitLines = []; for (let i = 0; i < points.length - 1; i++) { let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]), { color: "#000", weight: 26, opacity: 0, interactive: true, className: "re-hit" }).addTo(map); (function (idx) { line.on("click", (e) => { L.DomEvent.stop(e); if (nogoMode) placeNogo(e.latlng); else insertInLeg(idx, e.latlng); }); // tap → insert (or nogo) line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight })(i); hitLines.push(line); } } function redraw() { drawDisplay(); drawHitLines(); } function markDirty(i) { if (i >= 0 && i < points.length - 1) dirty.add(i); } function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(routeDirty, 450); } async function routeDirty() { let idxs = [...dirty]; dirty.clear(); for (let i of idxs) { if (i >= points.length - 1 || legMode[i] !== "road") continue; let a = points[i], b = points[i + 1]; // Prefer already-ridden roads; use BRouter (public OSM) only where there is no ridden path. let ridden = null; try { ridden = await rideRoute(a.lat, a.lon, b.lat, b.lon); } catch (e) { /* fall back */ } if (ridden && ridden.length >= 2) { legGeom[i] = ridden.map(p => ({ lat: p.lat, lon: p.lon })); continue; } try { // GeoJSON carries per-segment WayTags → detect fords / rough tracks on this leg. let gj = await BRouter.routeGeoJson([[a.lon, a.lat], [b.lon, b.lat]], trekProfile(), nogos); let parsed = GPS.parseRouteGeoJson(gj) || {}; let rp = parsed.points || []; legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope legDangers[i] = (rp.length >= 2) ? (parsed.dangers || []) : null; } catch (e) { legGeom[i] = null; legDangers[i] = null; } // silent fallback to straight } redraw(); updateStats(); } function toggleLeg(i) { if (i < 0 || i >= points.length - 1) return; legMode[i] = (legMode[i] === "road") ? "straight" : "road"; if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); scheduleRoute(); } redraw(); updateStats(); } function afterEdit() { normalizeLegs(); redraw(); updateStats(); scheduleRoute(); } function makeMarker(p) { let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon }); m.on("drag", () => { let idx = markers.indexOf(m); if (idx < 0) return; points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; if (idx - 1 >= 0) legGeom[idx - 1] = null; if (idx < points.length - 1) legGeom[idx] = null; redraw(); updateStats(); }); m.on("dragend", () => { let idx = markers.indexOf(m); if (idx < 0) return; markDirty(idx - 1); markDirty(idx); scheduleRoute(); }); m.on("click", (e) => { L.DomEvent.stop(e); deletePoint(m); }); m.addTo(map); return m; } function addPoint(latlng) { points.push({ lat: latlng.lat, lon: latlng.lng }); markers.push(makeMarker(points[points.length - 1])); if (points.length >= 2) { let li = points.length - 2; legMode[li] = defaultMode; legGeom[li] = null; markDirty(li); } afterEdit(); } function insertInLeg(legIdx, latlng) { if (legIdx < 0 || legIdx >= points.length - 1) return; let mode = legMode[legIdx] ?? defaultMode; points.splice(legIdx + 1, 0, { lat: latlng.lat, lon: latlng.lng }); markers.splice(legIdx + 1, 0, makeMarker(points[legIdx + 1])); legMode.splice(legIdx, 1, mode, mode); // one leg → two, same mode legGeom.splice(legIdx, 1, null, null); markDirty(legIdx); markDirty(legIdx + 1); afterEdit(); } function deletePoint(m) { let idx = markers.indexOf(m); if (idx < 0) return; points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m); if (legMode.length) { if (idx === 0) { legMode.splice(0, 1); legGeom.splice(0, 1); } else if (idx > points.length - 1) { legMode.splice(idx - 1, 1); legGeom.splice(idx - 1, 1); } else { let mode = legMode[idx - 1] ?? defaultMode; legMode.splice(idx - 1, 2, mode); legGeom.splice(idx - 1, 2, null); markDirty(idx - 1); } } afterEdit(); } // --- Map controls, reused from the track/detail views --- addDarkMapControl(map); // day/night tiles // Slope <-> elevation colour toggle (shared preference); redraws the route. let colorCtl = L.control({ position: "topleft" }); colorCtl.onAdd = () => { let d = L.DomUtil.create("div", "sd-map-fs-btn"); d.innerHTML = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "∡" : "⛰"; d.title = "Раскраска маршрута: уклон / высота"; L.DomEvent.disableClickPropagation(d); d.onclick = (e) => { e.stopPropagation(); let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope"; localStorage.setItem("baldav_route_color_mode", nm); d.innerHTML = nm === "slope" ? "∡" : "⛰"; drawDisplay(); }; return d; }; colorCtl.addTo(map); // Locate me let locCtl = L.control({ position: "topleft" }); locCtl.onAdd = () => { let d = L.DomUtil.create("div", "sd-map-fs-btn"); d.innerHTML = "◎"; d.title = "Моё местоположение"; L.DomEvent.disableClickPropagation(d); d.onclick = (e) => { e.stopPropagation(); navigator.geolocation?.getCurrentPosition( pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15), err => console.error("GPS error:", err.message), { enableHighAccuracy: true, timeout: 10000 }); }; return d; }; locCtl.addTo(map); // Nogo (impassable) circles: toggle the ⛔ button, then a map/line tap drops a red circle // the routing avoids. Persist in DB; every road leg re-routes to go around them. async function refreshNogos(reroute) { nogos = await getNogosForRouter(); nogoLayer = await drawNogoAreas(map, nogoLayer, () => refreshNogos(true)); if (reroute) { for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); } redraw(); updateStats(); scheduleRoute(); } } async function placeNogo(latlng) { await DB.addNogoArea(latlng.lat, latlng.lng, 25, null); await refreshNogos(true); } let nogoCtl = L.control({ position: "topleft" }); nogoCtl.onAdd = () => { let d = L.DomUtil.create("div", "sd-map-fs-btn"); d.innerHTML = "⛔"; d.title = "Пометить непроезжее: тап по карте"; L.DomEvent.disableClickPropagation(d); d.onclick = (e) => { e.stopPropagation(); nogoMode = !nogoMode; d.classList.toggle("active", nogoMode); showToast(nogoMode ? "Тап по карте — пометить непроезжее" : "Режим отметки выключен"); }; return d; }; nogoCtl.addTo(map); refreshNogos(false); // Existing route: load waypoints. Planner-built → its saved leg modes (re-route road legs // to get fresh geometry + elevations). Imported/legacy dense → all legs straight. if (points.length) { points.forEach(p => markers.push(makeMarker(p))); for (let i = 0; i < points.length - 1; i++) { legMode[i] = (wpSaved && wpSaved.legMode && wpSaved.legMode[i]) ? wpSaved.legMode[i] : (wpSaved ? "road" : "straight"); legGeom[i] = null; if (legMode[i] === "road") markDirty(i); } normalizeLegs(); redraw(); updateStats(); map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1)); if (dirty.size) scheduleRoute(); } else if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15), () => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 }); } map.on("click", e => { if (nogoMode) placeNogo(e.latlng); else addPoint(e.latlng); }); document.querySelector("#re-undo").onclick = () => { if (!points.length) return; let m = markers.pop(); points.pop(); if (m) map.removeLayer(m); if (legMode.length) { legMode.pop(); legGeom.pop(); } afterEdit(); }; document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container); // Global toggle: set ALL legs (and the default for new ones) to road or straight. let brBtn = document.querySelector("#re-brouter"); brBtn.onclick = () => { defaultMode = (defaultMode === "road") ? "straight" : "road"; brBtn.classList.toggle("active", defaultMode === "road"); for (let i = 0; i < legMode.length; i++) { legMode[i] = defaultMode; legGeom[i] = null; if (defaultMode === "road") markDirty(i); } redraw(); updateStats(); if (defaultMode === "road") scheduleRoute(); }; // Broды/сложные тропы: избегать (default) vs разрешить (adventure). Re-routes all road // legs with the chosen profile; danger markers show what the route then passes. let advBtn = document.querySelector("#re-adventure"); let advOn = localStorage.getItem("baldav_allow_adventure") === "1"; let syncAdv = () => { advBtn.textContent = advOn ? "Броды: разрешить" : "Броды: избегать"; advBtn.classList.toggle("active", advOn); }; syncAdv(); advBtn.onclick = () => { advOn = !advOn; localStorage.setItem("baldav_allow_adventure", advOn ? "1" : "0"); syncAdv(); for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; legDangers[i] = null; markDirty(i); } scheduleRoute(); }; document.querySelector("#re-save").onclick = async () => { if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; } let btn = document.querySelector("#re-save"); btn.disabled = true; btn.textContent = "Сохранение..."; let dense = fullGeom(); // routed geometry with elevations (for display + slope) if (dense.length < 2) { showToast("Маршрут пуст"); btn.disabled = false; btn.textContent = "Сохранить"; return; } // Store the editable waypoints + per-leg mode alongside the dense geometry, so re-editing // shows the 5-6 points again instead of the thousands of routed points. let wpOut = { waypoints: points.map(p => ({ lat: p.lat, lon: p.lon })), legMode: legMode.slice() }; if (editing) { await DB.updateRoutePoints(routeId, dense, wpOut); showToast("Маршрут обновлён"); showRouteDetail(container, routeId); } else { let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить"); if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; } let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", dense, wpOut); showToast("Маршрут создан"); showRouteDetail(container, id); } }; }, 200); } function showDrawRouteScreen(container) { let points = []; // {lat, lon} let markers = []; let polyline = null; container.innerHTML = `
    0 мДистанция
    0Точек
    `; setTimeout(() => { let mapEl = document.querySelector("#draw-map"); if (!mapEl) return; L.Icon.Default.imagePath = "lib/"; let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15); let CachedLayer = TileCache.createLayer(); new CachedLayer({ maxZoom: 19 }).addTo(map); // Try to center on last known position if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(pos => { map.setView([pos.coords.latitude, pos.coords.longitude], 15); }, () => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 }); } let distEl = document.querySelector("#draw-dist"); let ptsEl = document.querySelector("#draw-pts"); function updateStats() { let dist = 0; for (let i = 1; i < points.length; i++) { dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); } distEl.textContent = GPS.formatDistance(dist); ptsEl.textContent = String(points.length); } function updatePolyline() { let latlngs = points.map(p => [p.lat, p.lon]); if (polyline) map.removeLayer(polyline); if (latlngs.length >= 2) { polyline = L.polyline(latlngs, { color: GPS.config.trackColor || "#bb86fc", weight: 4 }).addTo(map); } } // Map click → add point map.on("click", e => { let { lat, lng } = e.latlng; points.push({ lat, lon: lng }); let marker = L.circleMarker([lat, lng], { radius: 6, color: GPS.config.trackColor || "#bb86fc", fillColor: GPS.config.trackColor || "#bb86fc", fillOpacity: 1, weight: 2 }).addTo(map); markers.push(marker); updatePolyline(); updateStats(); }); // Undo document.querySelector("#draw-undo").onclick = () => { if (points.length === 0) return; points.pop(); let m = markers.pop(); if (m) map.removeLayer(m); updatePolyline(); updateStats(); }; // Cancel document.querySelector("#draw-cancel").onclick = () => renderScreen("gps"); // Save document.querySelector("#draw-save").onclick = async () => { if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; } let saveBtn = document.querySelector("#draw-save"); saveBtn.textContent = "Сохранение..."; saveBtn.disabled = true; let activityType = document.querySelector("#draw-type").value; // Get elevation for each point let needApi = []; for (let p of points) { let alt = await DB.findNearestAltitude(p.lat, p.lon, 50); if (alt != null) { p.altitude = alt; } else { needApi.push(p); } } if (needApi.length > 0) { let elevs = await fetchElevations(needApi); if (elevs) needApi.forEach((p, i) => { if (elevs[i] != null) p.altitude = elevs[i]; }); } for (let p of points) { if (p.altitude == null) p.altitude = 0; } // Build points for DB with realistic timestamps based on activity speed let avgSpeedMs = (activityType === "bike" ? GPS.config.bikeAvgSpeedKmh : activityType === "run" ? GPS.config.runAvgSpeedKmh : GPS.config.walkAvgSpeedKmh) / 3.6; let now = Date.now(); let cumTime = 0; let dbPoints = points.map((p, i) => { if (i > 0) { let d = GPS.distance(points[i-1].lat, points[i-1].lon, p.lat, p.lon); cumTime += d / avgSpeedMs; // seconds to cover this segment } return { lat: p.lat, lon: p.lon, altitude_gps_m: p.altitude, speed_ms: avgSpeedMs, ts: new Date(now + cumTime * 1000).toISOString(), seq: i + 1, }; }); // Compute stats let latestW = await DB.getLatestWeight(); let profile = await DB.getFullProfile(); let weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; let bike = await DB.getActiveBike(); let opts = { activityType, bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type }; let stats = GPS.computeStats(dbPoints, weightKg, opts); // Create session let date = new Date().toISOString().slice(0, 10); let time = new Date().toTimeString().slice(0, 5); let session = { id: now, date, start_time: time, end_time: time, activity_type: activityType, source: "manual", total_distance_m: Math.round(stats.distance), total_kcal: Math.round(stats.kcal), avg_speed_kmh: stats.avgSpeed, max_speed_kmh: stats.maxSpeed, elevation_gain_m: Math.round(stats.elevGain), elevation_loss_m: Math.round(stats.elevLoss), bike_id: bike?.id ?? null, }; await DB.importGpxSession(session, dbPoints); showToast(`Маршрут сохранён: ${GPS.formatDistance(stats.distance)}, ${Math.round(stats.kcal)} ккал`); renderScreen("gps"); }; }, 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 }); } // HR-only "workout" session detail: no map — HR chart + load (kcal) + avg/max. function renderWorkoutDetail(session) { let samples = []; try { samples = JSON.parse(session.hr_samples || "[]"); } catch {} let hrs = samples.map(s => s[1]); let durSec = samples.length ? samples[samples.length - 1][0] : 0; let avg = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null; let max = hrs.length ? Math.max(...hrs) : null; let kcal = Math.round(session.total_kcal || 0); let html = `
    ${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}
    ${GPS.formatDuration(durSec)}Время
    ${kcal} ккалНагрузка
    ${avg ?? "—"}Средний пульс
    ${max ?? "—"}Макс пульс
    Пульс
    `; showModal(html, () => {}, (overlay) => { let ctx = overlay.querySelector("#wk-hr-chart"); if (ctx && samples.length) { new Chart(ctx, { type: "line", data: { labels: samples.map(s => Math.round(s[0] / 60) + "м"), datasets: [{ data: hrs, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3, segment: { borderColor: c => hrZoneColor(c.p1.parsed.y) } }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false } } }, }); } let del = overlay.querySelector("#wk-del"); if (del) del.onclick = async () => { if (!(await appConfirm("Удалить эту зарядку?", "Удалить"))) return; await DB.deleteGpsSession(session.id); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("gps"); }; }); } // HR-only "workout" recording screen (зарядка): no map/GPS, live heart rate + timer + kcal. function renderWorkoutInline(container) { container.innerHTML = `
    --
    уд/мин
    0:00Время
    0ккал
    --средний
    --макс
    Подключите пульсометр
    `; let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time"); let kcalEl = container.querySelector("#wk-kcal"), avgEl = container.querySelector("#wk-avg"); let maxEl = container.querySelector("#wk-max"), statusEl = container.querySelector("#wk-status"); GPS.recording.onHrUpdate = (reading) => { if (hrEl) hrEl.textContent = reading.hr; if (statusEl) statusEl.textContent = "Пульсометр на связи"; }; let tick = () => { if (!GPS.recording.active) { clearInterval(window._wkTimer); return; } let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0; if (timeEl) timeEl.textContent = GPS.formatDuration(sec); let samples = GPS.recording.hrSamples || [], hrs = samples.map(s => s[1]); if (kcalEl) kcalEl.textContent = GPS.hrKcalNet(samples, GPS.recording.hrProfile); if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--"; if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--"; if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr; if (statusEl && GPS.recording.currentHr) { let b = GPS.recording.currentBattery; statusEl.textContent = "Пульсометр на связи" + (b != null ? ` · заряд ${b}%` : ""); } }; clearInterval(window._wkTimer); window._wkTimer = setInterval(tick, 1000); tick(); container.querySelector("#wk-connect").onclick = async () => { let addr = state.profile?.ble_device_addr; if (!addr) { showToast("Сначала привяжите датчик: Настройки -> Пульсометр -> Поиск"); return; } let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth"); return; } if (statusEl) statusEl.textContent = "Подключение..."; try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: state.profile?.ble_device_type || "hr_standard" }); } catch (e) { showToast("Ошибка: " + (e.message || e)); } }; container.querySelector("#wk-stop").onclick = async () => { clearInterval(window._wkTimer); let res = await GPS.stopRecording(); if (res && res.sessionId) { showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`); showSessionDetail(res.sessionId); } else { renderScreen("gps"); } }; } // Send an SMS with the current position and a Yandex Maps pin link. Direct send if the system // granted SEND_SMS (the in-app confirm is the only tap), otherwise the native side opens the SMS // app prefilled. Works over 2G/EDGE with no internet — it is a plain text message. async function sendLocationSms() { let number = (localStorage.getItem("baldav_sms_number") || "").trim(); if (!number) { appAlert("Сначала укажи номер в профиле"); return; } let pts = GPS.recording.points || []; let last = GPS.recording.lastMovingPoint || pts[pts.length - 1]; if (!last || last.lat == null) { appAlert("Нет координат — подожди фикс GPS"); return; } let lat = (+last.lat).toFixed(5), lon = (+last.lon).toFixed(5); // Yandex Maps: pt=lon,lat drops a pin at the position. Link ONLY (ASCII, ~54 chars) // so the whole message is GSM-7 and fits in a single SMS — any Cyrillic would force // UCS-2 (70-char limit) and risk splitting into two. let text = `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map`; let esc = s => String(s).replace(/
    Кому: ${esc(number)}

    ${esc(text)}`, "Отправить"); if (!ok) return; try { await window.__TAURI__.core.invoke("send_sms", { number, text }); } catch (e) { appAlert("Ошибка отправки: " + e); } } // "Отдать смотрящему": hand the viewer the active route + ridden roads + track-so-far. Sent to // the server's RAM relay (NOT stored on its disk); the viewer pulls it once into their own // localStorage. One-shot; press again to resend (e.g. after the track grew). async function shareRideContext() { let url = (localStorage.getItem("baldav_live_url") || "").trim(); let token = (localStorage.getItem("baldav_live_token") || "").trim(); if (!url || !token) { appAlert("Сначала задай адрес и токен трансляции в профиле"); return; } let shareUrl = url.endsWith("/push") ? url.slice(0, -5) + "/share" : url.replace(/\/[^/]*$/, "/share"); // Round coords to 5 decimals (~1 m) — enough to draw, and cuts the payload a lot // (full doubles are ~2x longer). Roads can be big; this keeps the POST reasonable. let r5 = v => Math.round((+v) * 1e5) / 1e5; let track = (GPS.recording.points || []).map(p => [r5(p.lat), r5(p.lon)]); let route = []; try { let rid = localStorage.getItem("baldav_active_route"); if (rid) { let r = await DB.getRoute(rid); if (r && r.points) route = r.points.map(p => [r5(p.lat), r5(p.lon)]); } } catch (e) {} let roads = []; try { let segs = buildRoadSegments(await DB.getRoadPoints()); roads = segs.map(s => ({ p: s.pts.map(q => [r5(q.lat), r5(q.lon)]), t: s.atype || "b" })); } catch (e) {} // POI near the ride only (bbox of route+track + margin) — the full set is too big to send. let pois = []; try { let all = await POI.load(); let bpts = track.concat(route); if (all && all.length && bpts.length) { let minLa = 1e9, maxLa = -1e9, minLo = 1e9, maxLo = -1e9; for (let p of bpts) { if (p[0] < minLa) minLa = p[0]; if (p[0] > maxLa) maxLa = p[0]; if (p[1] < minLo) minLo = p[1]; if (p[1] > maxLo) maxLo = p[1]; } minLa -= 0.05; maxLa += 0.05; minLo -= 0.07; maxLo += 0.07; // ~5 km margin pois = all.filter(p => p.la >= minLa && p.la <= maxLa && p.lo >= minLo && p.lo <= maxLo) .map(p => ({ la: r5(p.la), lo: r5(p.lo), c: p.c, n: p.n })); } } catch (e) {} showToast("Отдаю смотрящему..."); try { let raw = await window.__TAURI__.core.invoke("http_request", { url: shareUrl, method: "POST", body: JSON.stringify({ token, route, roads, track, pois }) }); let res = JSON.parse(raw || "{}"); if (res.ok) showToast(`Отдано: маршрут ${route.length}, дороги ${roads.length}, трек ${track.length}, POI ${pois.length}`); else appAlert("Не отдалось: " + (res.error || "ошибка")); } catch (e) { appAlert("Не отдалось: " + (e.message || e)); } } async function renderRecordingInline(container) { if (!GPS.recording.active) { renderScreen("gps"); return; } // Keep screen on while recording is visible if (navigator.wakeLock) { navigator.wakeLock.request("screen").then(lock => { window._screenLock = lock; }).catch(() => {}); } // HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal. if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; } container.innerHTML = `
    0:00 — ${new Date().toTimeString().slice(0,5)}
    ${GPS.recording.state === "waiting_gps" ? '
    Поиск GPS...
    ' : ''}
    --Пульс
    0 мДистанция
    0км/ч
    —Высота
    `; let mapEl = container.querySelector("#rec-map"); if (!mapEl) return; L.Icon.Default.imagePath = "lib/"; // Use last known position from profile (reliable across sessions) let lastPt = GPS.recording.lastMovingPoint; let initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47; let initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07; // Remember the recording map's zoom across screen switches (persist on zoom, restore here). let recZoom = parseInt(localStorage.getItem("baldav_rec_zoom"), 10); if (!(recZoom >= 1 && recZoom <= 19)) recZoom = 15; let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], recZoom); map.on("zoomend", () => { try { localStorage.setItem("baldav_rec_zoom", String(map.getZoom())); } catch {} }); mapEl.addEventListener("wheel", (e) => { if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); } }, { passive: false }); addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map); // Heal blank tiles along the ridden route whenever OSM is reachable. TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon])); // Force recalc after flex layout settles — fixes black screen on init setTimeout(() => map.invalidateSize(), 200); let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map); let trailLine = L.polyline([], { color: GPS.config.trackColor || "#bb86fc", weight: 5 }).addTo(map); // Map orientation: a mode button picks the source — magnetic compass or GPS course. // Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show // 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) let prevCoursePt = null; // for the computed-bearing fallback let lastPos = null; // last position, for drawing the direction arrows let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course" // Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing // north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha). let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0; let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap let recScreen = container.querySelector(".rec-screen"); let btnWrap = mapEl.parentElement; // Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north. let compassEl = document.createElement("div"); compassEl.className = "rec-compass"; compassEl.innerHTML = '
    '; compassEl.title = "Тап = выставить север по текущему направлению"; compassEl.onclick = () => { northOffset = lastRawHeading; localStorage.setItem("baldav_north_offset2", String(northOffset)); smoothedHeading = 0; // snap to north-up immediately applyBearing(); 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)`; } // Mode button: compass ("К") ↔ GPS course ("GPS"). let modeBtn = document.createElement("button"); modeBtn.className = "rec-map-btn rec-mode-btn"; modeBtn.textContent = orientMode === "course" ? "GPS" : "К"; modeBtn.title = "Поворот карты: компас / GPS-курс"; modeBtn.onclick = () => { 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): // 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; // The map already rotates to put the active heading UP (course-up in GPS mode, // compass-up in compass mode), so travel direction is always screen-up. So the // puck triangle just points UP — no per-frame rotation. (Computing rotation from // heading + map bearing desynced and jittered badly.) The icon lives in // markerPane (norotatePane), which never rotates with the map, so "up" stays up. function haveHeading() { return (orientMode === "course" ? courseHeading : smoothedHeading) != null; } function updateMe(ll) { let wantTri = haveHeading(); if (!meMarker) { meMarker = L.marker(ll, { icon: posPuckIcon(puckFill, wantTri), pane: "markerPane", interactive: false, keyboard: false }).addTo(map); meHasTri = wantTri; } else { meMarker.setLatLng(ll); if (wantTri !== meHasTri) { meMarker.setIcon(posPuckIcon(puckFill, wantTri)); meHasTri = wantTri; } } } // Show/hide the direction triangle when a heading becomes (un)available. function refreshMe() { if (!meMarker) return; let wantTri = haveHeading(); if (wantTri !== meHasTri) { meMarker.setIcon(posPuckIcon(puckFill, wantTri)); meHasTri = wantTri; } } // 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). function baseHeading() { // Course-up: setBearing puts geo-dir (360-theta) up and net rotation = h, so to put // courseHeading at the top we need h = 360 - courseHeading (derived from leaflet-rotate). if (orientMode === "course" && courseHeading != null) return (360 - courseHeading + 360) % 360; return smoothedHeading != null ? smoothedHeading : 0; } function applyBearing() { let h = compassFrozen ? frozenHeading : baseHeading(); // Do NOT subtract rotOffset. The whole .rec-screen (map + panel) is CSS-rotated // by rotOffset in landscape; setBearing adds another CSS rotate on the map's // rotatePane. Subtracting rotOffset cancelled the screen rotation FOR THE MAP, // so the map stayed portrait-oriented (course glued to the narrow top edge) // while the panel rotated. Using h lets the map turn with the screen like the // panel → course points to the physical top. Portrait (rotOffset=0): h-0=h. let bearing = h; map.setBearing(bearing); updateNorthArrow(bearing); } // Update GPS course from a point. Prefer the chip's course-over-ground (p.bearing); // fall back to bearing between positions ≥15 m apart (long baseline = less jitter). function updateCourse(p) { if (!p) return; lastPos = { lat: p.lat, lon: p.lon }; let b = null; if (p.bearing != null && (p.speed_ms == null || p.speed_ms > 1.0)) { b = p.bearing; } else if (prevCoursePt && GPS.distance(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon) >= 15) { b = GPS.bearing(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon); prevCoursePt = { lat: p.lat, lon: p.lon }; } if (!prevCoursePt) prevCoursePt = { lat: p.lat, lon: p.lon }; if (b != null) { if (courseHeading === null) courseHeading = b; else { let diff = b - courseHeading; if (diff > 180) diff -= 360; if (diff < -180) diff += 360; courseHeading = ((courseHeading + diff * 0.4) % 360 + 360) % 360; } if (orientMode === "course" && !compassFrozen) applyBearing(); } refreshMe(); } function applyScreenRotation() { if (recScreen) { recScreen.classList.remove("rot-90", "rot-270"); if (rotOffset) recScreen.classList.add("rot-" + rotOffset); } let nav = document.querySelector(".bottom-nav"); if (nav) nav.style.display = rotOffset ? "none" : ""; map.invalidateSize(); } // Rotation button — 4 states, each tap +90°. Arrow shows chosen "up". let rotBtn = document.createElement("button"); rotBtn.className = "rec-map-btn rec-rotate-btn"; rotBtn.innerHTML = "↑"; // ↑ rotBtn.title = "Повернуть карту на 90°"; rotBtn.onclick = () => { // 3 readable states: portrait + both landscapes. 180° is skipped — // it would flip the panel text upside down ("низ" would end up on top). rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0); applyScreenRotation(); applyBearing(); }; btnWrap.appendChild(rotBtn); // Freeze button — X: lock/unlock compass following (does NOT rotate) let freezeBtn = document.createElement("button"); freezeBtn.className = "rec-map-btn rec-freeze-btn"; freezeBtn.textContent = "X"; freezeBtn.title = "Заморозить поворот карты"; freezeBtn.onclick = () => { compassFrozen = !compassFrozen; if (compassFrozen) frozenHeading = baseHeading(); freezeBtn.classList.toggle("active", compassFrozen); applyBearing(); }; 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); // Left-side group (bottom-left, opposite the right controls): live-location toggle + SMS. // Live sharing can be switched on/off right here even if it was never set in the profile. let liveBtn = document.createElement("button"); liveBtn.className = "rec-map-btn rec-live-btn"; liveBtn.textContent = "LIVE"; liveBtn.title = "Трансляция местоположения вкл/выкл"; let liveOn = localStorage.getItem("baldav_live_on") === "1"; liveBtn.classList.toggle("active", liveOn); liveBtn.onclick = async () => { liveOn = !liveOn; localStorage.setItem("baldav_live_on", liveOn ? "1" : "0"); liveBtn.classList.toggle("active", liveOn); let url = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push"; let token = localStorage.getItem("baldav_live_token") || ""; try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url, token }); } catch (e) { console.error("live toggle", e); } if (liveOn && !token) appAlert("Трансляция включена, но токен не задан в профиле - точки не уйдут, пока не впишешь токен."); }; btnWrap.appendChild(liveBtn); // SMS "я тут" button let smsBtn = document.createElement("button"); smsBtn.className = "rec-map-btn rec-sms-btn"; smsBtn.textContent = "СМС"; smsBtn.title = "Отправить СМС с местоположением"; smsBtn.onclick = () => sendLocationSms(); btnWrap.appendChild(smsBtn); // Hand the viewer the route + ridden roads + track (via the server's RAM relay, not its disk). let shareBtn = document.createElement("button"); shareBtn.className = "rec-map-btn rec-share-btn"; shareBtn.textContent = "Отд"; shareBtn.title = "Отдать смотрящему: маршрут, проезженные дороги и пройденный трек"; shareBtn.onclick = () => shareRideContext(); btnWrap.appendChild(shareBtn); // Roads toggle button. Roads are viewport-culled onto a canvas pane (only the segments in // view are drawn, redrawn on pan/zoom) so a huge road network doesn't jank the WebView. let roadsBtn = document.createElement("button"); roadsBtn.className = "rec-map-btn rec-roads-toggle"; roadsBtn.textContent = "R"; let roadLayers = []; let roadSegs = null, roadsOn = false, roadsRenderer = null; let redrawRoads = () => { if (roadsOn && roadSegs) cullRoadsInto(map, roadSegs, roadLayers, 0.2, { pane: "roadsPane", renderer: roadsRenderer }); }; let roadsMove = debounce(redrawRoads, 120); roadsBtn.onclick = async () => { if (roadsOn) { roadsOn = false; map.off("moveend zoomend", roadsMove); roadLayers.forEach(l => map.removeLayer(l)); roadLayers.length = 0; roadsBtn.classList.remove("active"); localStorage.setItem("baldav_show_roads", "0"); return; } roadsOn = true; roadsBtn.classList.add("active"); localStorage.setItem("baldav_show_roads", "1"); // Roads live in their own pane BELOW route/track: tiles(200) setRouteShown(!routeLayer); btnWrap.appendChild(routeBtn); // Dark-map toggle (moon): invert the tile layer only, remembered globally. let darkBtn = document.createElement("button"); darkBtn.className = "rec-map-btn rec-dark-toggle" + (isDarkMap() ? " active" : ""); darkBtn.textContent = "☾"; darkBtn.title = "Тёмная карта"; darkBtn.onclick = () => toggleDarkMap(); btnWrap.appendChild(darkBtn); // Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute // will avoid it). Shows existing nogo circles too. let recNogoLayer = null; async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); } refreshRecNogos(); let recNogoBtn = document.createElement("button"); recNogoBtn.className = "rec-map-btn rec-nogo-btn"; recNogoBtn.textContent = "⛔"; recNogoBtn.title = "Здесь нет проезда (отметить текущее место)"; recNogoBtn.onclick = async () => { let p = GPS.recording.lastMovingPoint; if (!p) { showToast("Нет позиции"); return; } await DB.addNogoArea(p.lat, p.lon, 25, null); await refreshRecNogos(); showToast("Отмечено: нет проезда"); }; btnWrap.appendChild(recNogoBtn); // Reroute back onto the active route past a blockage (nogo), from the current position, // avoiding nogos and 180° turns. Transient navigation overlay — does NOT touch the saved // route or the recording. Tap again to rebuild from the new position; long-press to clear. let rerouteLayer = null; let rerouteBtn = document.createElement("button"); rerouteBtn.className = "rec-map-btn rec-reroute-btn"; rerouteBtn.textContent = "↪"; rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом"; rerouteBtn.onclick = async () => { let rid = localStorage.getItem("baldav_active_route"); if (!rid) { showToast("Нет активного маршрута"); return; } let cur = GPS.recording.lastMovingPoint; if (!cur) { showToast("Нет позиции"); return; } rerouteBtn.disabled = true; rerouteBtn.classList.add("active"); try { let route = await DB.getRoute(rid); if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); return; } let ng = await getNogosForRouter(); let bearing = (typeof smoothedHeading === "number") ? smoothedHeading : null; let res = await rejoinReroute(cur, bearing, route.points, ng); if (res && res.noBlockage) { showToast("Впереди затыка нет (нет nogo на маршруте)"); rerouteBtn.classList.remove("active"); return; } if (!res) { showToast("Объезд не найден"); rerouteBtn.classList.remove("active"); return; } if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; } let ll = res.points.map(p => [p.lat, p.lon]); let g = L.layerGroup(); L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(g); L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(g); L.polyline(ll, { color: "#ff00ff", weight: 3, opacity: 1 }).addTo(g); L.circleMarker([res.target.lat, res.target.lon], { radius: 7, color: "#ff00ff", fillColor: "#fff", fillOpacity: 1, weight: 3 }).addTo(g); g.addTo(map); rerouteLayer = g; showToast("Объезд построен"); } catch (e) { showToast("Объезд: " + (e.message || e)); } finally { rerouteBtn.disabled = false; } }; rerouteBtn.oncontextmenu = (e) => { e.preventDefault(); if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; rerouteBtn.classList.remove("active"); showToast("Объезд убран"); } }; btnWrap.appendChild(rerouteBtn); POI.attach(map); // POI overlay + category filter on the recording map // The default-ON draw is deferred to the END of this function (after all the // critical wiring), where it awaits whenMapReady — so it can't delay setup. function handleOrientation(e) { let raw; if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion else return; lastRawHeading = raw; // for the calibration tap let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset // EMA smoothing to reduce needle jitter (factor 0.3). if (smoothedHeading === null) smoothedHeading = heading; else { let diff = heading - smoothedHeading; if (diff > 180) diff -= 360; if (diff < -180) diff += 360; smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360; } // DEBUG overlay: raw sensor values on screen (to verify calibration). // Only in debug builds — window.__BALDAV_DEBUG is set in init() from // get_app_info().debug (cfg!(debug_assertions)); silent in release. if (window.__BALDAV_DEBUG) { let dbg = document.getElementById("orient-dbg"); if (!dbg) { dbg = document.createElement("div"); dbg.id = "orient-dbg"; dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;"; mapEl.parentElement.appendChild(dbg); } dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading); } refreshMe(); // update the direction triangle from the live compass heading if (!compassFrozen) applyBearing(); } let throttledOrientation = L.Util.throttle(handleOrientation, 100); let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation'; window.addEventListener(orientEvent, throttledOrientation); applyBearing(); // initial: north-up until first compass reading GPS.recording._compassCleanup = () => { window.removeEventListener(orientEvent, throttledOrientation); if (recScreen) recScreen.classList.remove("rot-90", "rot-270"); let nav = document.querySelector(".bottom-nav"); if (nav) nav.style.display = ""; }; // State changes GPS.recording.onStateChange = (state, p) => { if (state === "ready") { let ph = document.getElementById('gps-placeholder'); if (ph) ph.remove(); statusEl.textContent = "GPS OK. Начните движение..."; statusEl.style.color = "#03dac6"; if (followMode) map.setView([p.lat, p.lon], 16); } else if (state === "recording") { statusEl.textContent = ""; statusEl.style.color = ""; } else if (state === "resumed") { statusEl.textContent = ""; statusEl.style.color = ""; } }; // Live point updates let distEl = document.querySelector("#rec-dist"); let spdEl = document.querySelector("#rec-speed"); let timeEl = document.querySelector("#rec-time"); let elevEl = document.querySelector("#rec-elev"); let statusEl = { style: {} }; // status line removed from the UI; keep a no-op sink so the existing statusEl.* writes don't crash // Speed buffer for median let speedBuf = []; GPS.recording.onPoint = (p) => { let ph = document.getElementById('gps-placeholder'); if (ph) ph.remove(); let ll = [p.lat, p.lon]; borderLine.addLatLng(ll); trailLine.addLatLng(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)); updateMe(ll); // Stats let pts = GPS.recording.points; let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts()); if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); // Show current state let recState = GPS.recording.state; if (recState === "waiting_gps") { statusEl.textContent = "Поиск GPS..."; statusEl.style.color = "#ffb74d"; if (followMode) map.setView([p.lat, p.lon], 15); return; } if (recState === "ready") { updateMe([p.lat, p.lon]); // position only (no heading yet) return; } // Orient map by direction of movement (GPS course) + update direction arrows updateCourse(p); // Speed let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0; speedBuf.push(curSpeed); if (speedBuf.length > 5) speedBuf.shift(); let sorted = speedBuf.slice().sort((a, b) => a - b); let median = sorted[Math.floor(sorted.length / 2)] || 0; if (spdEl) spdEl.textContent = median.toFixed(1); // Elevation let alt = p.altitude; if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м"; // Pause indicator if (GPS.recording.pauseStart) { statusEl.textContent = "Пауза"; statusEl.style.color = "#ffb74d"; } else { statusEl.textContent = ""; statusEl.style.color = ""; } }; // HR update from BLE GPS.recording.onHrUpdate = (reading) => { let hrEl = document.querySelector("#rec-hr-val"); // HR stat in the bottom row if (hrEl) { hrEl.textContent = reading.hr; hrEl.className = "rec-val " + hrZoneClass(reading.hr); } let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself if (hrNum) hrNum.textContent = reading.hr; setHrBatteryFill(document.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // strap charge }; setHrBatteryFill(container.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // on render, if already known // Timer — excludes pauses, starts only when recording let timer = setInterval(() => { if (!GPS.recording.active) { clearInterval(timer); return; } if (!GPS.recording.startTime) return; // not started yet let totalMs = Date.now() - GPS.recording.startTime.getTime(); let activeMs = totalMs - GPS.recording.totalPauseMs - (GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0); let sec = Math.max(0, Math.round(activeMs / 1000)); if (timeEl) timeEl.textContent = GPS.formatDuration(sec); // Update clock let clockEl = document.querySelector("#rec-clock"); if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5); }, 1000); // Fetch temperature — try multiple APIs (mobile internet blocks foreign services) async function fetchTemperature() { let pos = GPS.recording.lastMovingPoint || { lat: parseFloat(state.profile.last_lat) || 47.42, lon: parseFloat(state.profile.last_lon) || 40.08 }; let lat = pos.lat.toFixed(2), lon = pos.lon.toFixed(2); let invoke = window.__TAURI__.core.invoke; let apis = [ // Yandex Pogoda — works on Russian mobile internet, scrape HTML { url: `https://yandex.ru/pogoda/?lat=${lat}&lon=${lon}`, parse: r => { let m = r.match(/FactTemperature_value[^>]*>(-?\d+)/); return m ? parseInt(m[1]) : null; } }, // open-meteo — free API, may be blocked on mobile { url: `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m`, parse: r => { try { return JSON.parse(r).current?.temperature_2m; } catch { return null; } } }, // wttr.in — free, may be blocked on mobile { url: `https://wttr.in/${lat},${lon}?format=%t`, parse: r => { let n = parseFloat(r); return isNaN(n) ? null : n; } }, ]; for (let api of apis) { try { let resp = await invoke("http_request", { url: api.url, method: "GET", body: null }); let temp = api.parse(resp); if (temp != null && !isNaN(temp)) { let el = document.querySelector("#rec-temp"); if (el) el.textContent = Math.round(temp) + "°"; return; } } catch {} } } fetchTemperature(); let weatherTimer = setInterval(fetchTemperature, 15 * 60 * 1000); // Headwind label toggle: tap to open a "wind in the face" interval, tap to close. // Marks are ground truth for validating the from-track wind estimate — not kcal. // HR on/off toggle (replaces the old "Ветер" button). ON connects the paired strap + polls; // OFF disconnects. Grey = off, coloured = on. let hrToggle = container.querySelector("#rec-hr-toggle"); if (hrToggle) hrToggle.onclick = async () => { hrToggle.disabled = true; await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once) hrToggle.classList.toggle("active", !!GPS.recording.hrOn); if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill hrToggle.disabled = false; }; // Pause button container.querySelector("#rec-pause").onclick = () => { if (GPS.recording.manualPaused) { GPS.resumeRecording(); container.querySelector("#rec-pause").textContent = "Пауза"; statusEl.textContent = ""; statusEl.style.color = ""; } else { GPS.pauseRecording(); container.querySelector("#rec-pause").textContent = "Продолжить"; statusEl.textContent = "Пауза (ручная)"; statusEl.style.color = "#ffb74d"; } }; // Stop button container.querySelector("#rec-stop").onclick = async () => { if (GPS.recording._compassCleanup) GPS.recording._compassCleanup(); await GPS.stopRecording(); clearInterval(timer); renderScreen("gps"); }; // Restore trail if already recording — sort by seq/ts to avoid jumps if (GPS.recording.points.length > 0) { let sorted = GPS.recording.points.slice().sort((a, b) => { return new Date(a.ts) - new Date(b.ts); }); let coords = sorted.map(p => [p.lat, p.lon]); borderLine.setLatLngs(coords); trailLine.setLatLngs(coords); map.fitBounds(trailLine.getBounds().pad(0.1)); } // Batch update callback — called after poll reads multiple points at once. // Rebuilds polyline from all sorted points instead of adding one-by-one. GPS.recording.onBatchUpdate = () => { let sorted = GPS.recording.points.slice().sort((a, b) => { return new Date(a.ts) - new Date(b.ts); }); let coords = sorted.map(p => [p.lat, p.lon]); borderLine.setLatLngs(coords); trailLine.setLatLngs(coords); if (coords.length) { let last = coords[coords.length - 1]; if (followMode) map.panTo(last); 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); }; // Lightweight per-point draw for progressive import (screen-wake catch-up): // only extends the polyline so the track visibly grows. Heavy work (stats, // marker, course) is done periodically + at the end by onBatchUpdate. GPS.recording.onImportPoint = (p) => { let ll = [p.lat, p.lon]; borderLine.addLatLng(ll); trailLine.addLatLng(ll); }; // Finally: if a route is selected (and not toggled off), draw it once the map is // ready. Done LAST so awaiting map-load never delays the compass/handlers/buttons. if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") { await whenMapReady(map); await setRouteShown(true); } } function setupLiveUpdates(container) { let distEl = container.querySelector("#gs-dist"); let spdEl = container.querySelector("#gs-speed"); let timeEl = container.querySelector("#gs-time"); let timer = null; GPS.recording.onPoint = (p) => { if (!gpsTrailLine || !gpsMap) return; gpsTrailLine.addLatLng([p.lat, p.lon]); gpsMap.panTo([p.lat, p.lon]); let pts = GPS.recording.points; let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts()); if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); if (spdEl) { let curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed; spdEl.textContent = curSpd + " км/ч"; } }; GPS.recording.onBatchUpdate = () => { if (!gpsTrailLine || !gpsMap) return; let sorted = GPS.recording.points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts)); let coords = sorted.map(p => [p.lat, p.lon]); gpsTrailLine.setLatLngs(coords); if (coords.length) gpsMap.panTo(coords[coords.length - 1]); let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts()); if (distEl) distEl.textContent = GPS.formatDistance(stats.distance); if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч"; }; // Lightweight per-point draw for progressive import (see recording screen). GPS.recording.onImportPoint = (p) => { if (!gpsTrailLine) return; gpsTrailLine.addLatLng([p.lat, p.lon]); }; // Timer for duration display + auto-finish watchdog for a forgotten session timer = setInterval(async () => { if (!GPS.recording.active) { clearInterval(timer); return; } if (GPS.shouldAutoFinish()) { clearInterval(timer); if (GPS.recording._compassCleanup) GPS.recording._compassCleanup(); await GPS.stopRecording(); showToast("Тренировка завершена автоматически — нет движения"); renderScreen("gps"); return; } let sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000); if (timeEl) timeEl.textContent = GPS.formatDuration(sec); }, 1000); } // --------------------------------------------------------------------------- // Map download modal — pick region + zoom range // --------------------------------------------------------------------------- let MAP_REGIONS = [ { name: "Текущий вид карты", bounds: null }, { name: "--- Города ---", bounds: null, disabled: true }, { name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] }, { name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] }, { name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] }, { name: "Мой регион", bounds: [[47.35, 39.95], [47.52, 40.22]] }, { name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] }, { name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] }, { name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] }, { name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] }, { name: "--- Области ---", bounds: null, disabled: true }, { name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] }, { name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] }, { name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] }, { name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] }, ]; function showMapDownloadModal() { let regionOpts = MAP_REGIONS.map((r, i) => r.disabled ? `
    ${r.name.replace(/---? ?/g, "")}
    ` : `
    ${r.name}
    ` ).join(""); showModal(`

    Скачать карту

    ${regionOpts}
    `, async overlay => { let sel = overlay.querySelector(".supp-pick-item.selected"); if (!sel) return false; let idx = parseInt(sel.dataset.idx); let region = MAP_REGIONS[idx]; let zMin = parseInt(overlay.querySelector("#m-zmin").value); let zMax = parseInt(overlay.querySelector("#m-zmax").value); let bounds; if (region.bounds) { bounds = L.latLngBounds(region.bounds); } else if (gpsMap) { bounds = gpsMap.getBounds(); } else return false; let total = TileCache.countTilesForArea(bounds, zMin, zMax); let okBtn = overlay.querySelector("#modal-ok"); okBtn.textContent = `0 / ${total}`; okBtn.disabled = true; await TileCache.downloadArea(bounds, zMin, zMax, (done, tot) => { okBtn.textContent = `${done} / ${tot}`; }); okBtn.textContent = "Готово"; okBtn.disabled = false; }); let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); let countEl = overlay.querySelector("#m-tile-count"); // Selection overlay.querySelectorAll(".supp-pick-item").forEach(item => { item.onclick = () => { overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected")); item.classList.add("selected"); updateTileCount(); }; }); function updateTileCount() { let sel = overlay.querySelector(".supp-pick-item.selected"); if (!sel) return; let idx = parseInt(sel.dataset.idx); let region = MAP_REGIONS[idx]; let zMin = parseInt(overlay.querySelector("#m-zmin").value); let zMax = parseInt(overlay.querySelector("#m-zmax").value); let bounds; if (region.bounds) bounds = L.latLngBounds(region.bounds); else if (gpsMap) bounds = gpsMap.getBounds(); else return; let total = TileCache.countTilesForArea(bounds, zMin, zMax); countEl.textContent = `~${total} тайлов`; } overlay.querySelector("#m-zmin").oninput = updateTileCount; overlay.querySelector("#m-zmax").oninput = updateTileCount; } // --------------------------------------------------------------------------- // Session detail view // --------------------------------------------------------------------------- let _sessionDetailRender = 0; // Remember each track's last map view (centre+zoom) so a recompute/refine reopen — or coming // back to a track you looked at — restores where you were instead of re-fitting the whole track. let _sdMapView = {}; async function showSessionDetail(sessionId) { let renderToken = ++_sessionDetailRender; try { console.log("[SD] loading session", sessionId); let session = await DB.getGpsSession(sessionId); if (!session) { console.error("[SD] session not found"); return; } // HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path. if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; } console.log("[SD] loading points"); let rawPoints = await DB.getGpsPoints(sessionId); console.log("[SD] got", rawPoints.length, "raw points"); let points = GPS.filterNoise(rawPoints, session.activity_type); // If this track has a saved smoothed (de-jittered) variant active, display it — // rawPoints keeps the original lat/lon (for re-tuning / revert). if (session.smoothed) points = points.map(p => (p.lat_smoothed != null ? { ...p, lat: p.lat_smoothed, lon: p.lon_smoothed } : p)); console.log("[SD] filtered to", points.length, "points"); // Use stored session stats if available (computed correctly at import time), // fallback to recomputing from points let storedStats = session.total_distance_m != null; let stats = storedStats ? { distance: Math.round(session.total_distance_m || 0), avgSpeed: session.avg_speed_kmh || 0, maxSpeed: session.max_speed_kmh || 0, kcal: Math.round(session.total_kcal || 0), elevGain: Math.round(session.elevation_gain_m || 0), elevLoss: Math.round(session.elevation_loss_m || 0), duration: 0, } : GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); // Manual-pause total (ms) to exclude from displayed duration let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0); // Compute duration from points regardless (minus manual pauses) if (points.length >= 2) { stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000)); } // Always compute segments from points (needed for the Segments section) if (!stats.segments && points.length >= 3) { let fullStats = GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); stats.segments = fullStats.segments; } // Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based let rawSpeeds = []; for (let i = 1; i < points.length; i++) { let devSpd = points[i].speed_ms; if (devSpd != null && devSpd >= 0) { rawSpeeds.push(devSpd * 3.6); } else { let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000; rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0); } } // 31-point moving average — kills GPS jitter from phone-in-pocket let HALF_WIN = 15; let smoothed = rawSpeeds.map((_, i) => { let sum = 0, count = 0; for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) { sum += rawSpeeds[j]; count++; } return Math.round(sum / count * 10) / 10; }); // maxSpeed from smoothed graph (not raw) let graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0; // Elevation data // Prefer refined terrain altitude (srtm, written by "Уточнить высоты") over raw // GPS altitude — same priority computeStats uses, so the chart matches the stats. let elevations = GPS.filterAndSmoothAlt(points.map(p => p.altitude_srtm_m ?? p.altitude_gps_m)); let minElev = elevations.length ? Math.round(Math.min(...elevations)) : null; let maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null; // Time labels (minutes from start) let t0 = points.length ? new Date(points[0].ts).getTime() : 0; let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)); let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" }; let actName = actNames[session.activity_type] || session.activity_type || "Тренировка"; let duration = session.end_time && session.start_time ? GPS.formatDuration(stats.duration) : "—"; let tempo = stats.duration > 0 && stats.distance > 0 ? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км" : "—"; let kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—"; // HR-based kcal "what if" (Keytel) — comparison ONLY. The activity balance keeps the physics/MET // number (don't disturb the established diet); here we just show what HR would give, if present. let _t0hr = points.length ? new Date(points[0].ts).getTime() : 0; let hrSamples = points.filter(p => p.heart_rate > 0).map(p => [(new Date(p.ts).getTime() - _t0hr) / 1000, p.heart_rate]); let hasHr = hrSamples.length > 2; let hrProfile = { weightKg: session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 75, age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988), sex: state.profile?.sex || "m", heightCm: parseFloat(state.profile?.height_cm) || 178, }; let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null; let hrKcalCalib = hasHr ? Math.round(hrKcal * (GPS.config.hrCalibFactor ?? 0.65)) : null; // грубая справочная прикидка let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null; let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null; showModal(`

    ${actName}

    ID: ${session.id}
    Время
    ${session.date}Дата
    ${duration}Длительность
    ${session.start_time ?? "—"}Начало
    ${session.end_time ?? "—"}Конец
    ${GPS.formatDistance(stats.distance)}Расстояние
    ${tempo}Темп
    Маршрут
    Скорость
    ${stats.avgSpeed} км/чСредняя
    ${stats.maxSpeed} км/чМакс.
    Калории
    ${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"}
    ${kcalMin} ккал/минРасход
    ${hasHr ? `
    ${hrKcal} ккалПо ЧСС (Keytel)
    ${hrKcalCalib} ккалЧСС откалибр. (грубо)
    В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.
    ` : ""}
    ${elevations.length > 0 ? `
    Высота
    ${minElev} мМин.
    ${maxElev} мМакс.
    ${stats.elevGain} мПодъём
    ${stats.elevLoss} мСпуск
    ` : ""} ${hasHr ? `
    Пульс
    ${hrAvg}Средний
    ${hrMax}Макс.
    ` : ""} ${(stats.segments && stats.segments.length > 1) ? `
    Сегменты ${stats.segments.length}
    ${stats.segments.map(seg => { let icon = seg.type === "climb" ? "^" : seg.type === "descent" ? "v" : "-"; let distStr = seg.distance >= 1000 ? (seg.distance / 1000).toFixed(1) + " км" : seg.distance + " м"; let elevStr = seg.type === "climb" ? `+${seg.elevGain} м, ${seg.avgGradient}%` : seg.type === "descent" ? `-${seg.elevLoss} м, ${seg.avgGradient}%` : `+/-${Math.max(seg.elevGain, seg.elevLoss)} м`; let durStr = GPS.formatDuration(seg.duration); return `
    ${icon} ${distStr}, ${elevStr} ${durStr} | ${seg.avgSpeed} км/ч
    `; }).join("")}
    ` : ""} ${session.activity_type === 'bike' ? `
    Разбор повторов
    Считаю...
    ` : ""} ${session.bike_id ? `
    Велосипед
    ...Название
    ` : ''} ${session.activity_type === 'bike' ? `
    ` : ''} `, () => {}); if (renderToken !== _sessionDetailRender) return; // Render map let mapEl = document.querySelector("#sd-map"); if (mapEl && points.length) { void mapEl.offsetHeight; // force layout so Leaflet reads the real container size L.Icon.Default.imagePath = "lib/"; // preferCanvas: big tracks (10k+ points, multi-layer slope colouring) as SVG blow the // Chromium compositor's tile-memory limit on zoom/pan → map tiles stop drawing until a // restart ("tile memory limits exceeded, some content may not draw"). Canvas renders the // vectors into one viewport-bounded canvas instead of thousands of SVG paths. let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true }); mapEl._leafletMap = map; // save ref for winStack collapse // Remember the view for this track so a reopen (after recompute / refine) keeps the zoom. map.on("moveend zoomend", () => { _sdMapView[sessionId] = { c: map.getCenter(), z: map.getZoom() }; }); // Ctrl+wheel zoom on desktop mapEl.addEventListener("wheel", (e) => { if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); } }, { passive: false }); addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map); // Heal blank tiles along this track's route when OSM is reachable. TileCache.startHealer(map, tileLayer, () => points.map(p => [p.lat, p.lon])); addDarkMapControl(map); // dark-tiles toggle on the track view POI.attach(map); // POI overlay + category filter { let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles // Gap-fill: route GPS dropouts along roads (BRouter) and PREVIEW only the filled gaps // (not the whole track); then save (reversible, in-place) or discard. If the track is // already gap-filled, the button offers to remove the fill. let gapLayer = null; // L.layerGroup of the preview patches let gapPanel = null; // bottom preview panel (Save/Cancel) let gapCache = null; // { points } from fillGaps, kept for save let gapBtn = null; function clearGapPreview() { if (gapLayer) { map.removeLayer(gapLayer); gapLayer = null; } if (gapPanel) { gapPanel.remove(); gapPanel = null; } gapCache = null; if (gapBtn) gapBtn.classList.remove("active"); } function drawGapPatches(gaps) { let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0, skippedLong = 0, skippedNoroute = 0; for (let g of gaps) { if (g.status === "filled") { filledN++; addedM += g.addedM; let ll = [[g.a.lat, g.a.lon]].concat(g.routed.map(p => [p.lat, p.lon]), [[g.b.lat, g.b.lon]]); L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(group); // casing L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(group); L.polyline(ll, { color: "#00e5ff", weight: 3, opacity: 1 }).addTo(group); // core } else if (g.status === "skipped-long" || g.status === "skipped-noroute") { // a real gap we couldn't bridge — mark it red so the user knows it's unfilled. // "skipped-nogain" (road ≈ straight) is fine as-is → not drawn, not counted. skippedN++; if (g.status === "skipped-long") skippedLong++; else skippedNoroute++; L.polyline([[g.a.lat, g.a.lon], [g.b.lat, g.b.lon]], { color: "#e53935", weight: 3, opacity: 0.9, dashArray: "6,6" }).addTo(group); } } group.addTo(map); gapLayer = group; return { filledN, skippedN, addedM, skippedLong, skippedNoroute }; } function panel(html) { let el = document.createElement("div"); el.className = "gap-panel"; el.innerHTML = html; document.body.appendChild(el); return el; } let GapCtl = L.Control.extend({ options: { position: "topright" }, onAdd: function () { let b = L.DomUtil.create("button", "rec-map-btn"); gapBtn = b; b.textContent = "🧩"; b.title = session.gap_filled ? "Убрать достройку пропусков" : "Достроить пропуски по дорогам"; b.style.position = "static"; if (session.gap_filled) b.classList.add("active"); L.DomEvent.disableClickPropagation(b); b.onclick = async () => { if (gapPanel) { clearGapPreview(); return; } // toggle panel off // Already filled → offer revert. if (session.gap_filled) { b.classList.add("active"); gapPanel = panel(`
    Трек достроен по дорогам (голубые участки).
    Можно убрать и вернуть исходный трек.
    `); gapPanel.querySelector("#gap-close").onclick = () => clearGapPreview(); gapPanel.querySelector("#gap-revert").onclick = async () => { let rb = gapPanel.querySelector("#gap-revert"); rb.disabled = true; rb.textContent = "Убираю..."; try { await DB.revertGapFill(sessionId); await GPS.recalculateSession(sessionId, { noDelete: true }); showToast("Достройка убрана"); clearGapPreview(); reopenDetail(sessionId); } catch (e) { showToast("Откат: " + (e.message || e)); rb.disabled = false; rb.textContent = "Убрать достройку"; } }; return; } // Compute preview. b.disabled = true; b.classList.add("active"); try { // Prefer bridging a dropout over roads YOU actually rode (ride-graph); fall back to // the public BRouter only where you've never ridden. A ridden bridge is trusted (no // "detour too long" rejection); public routing keeps that guard. let res = await GPS.fillGaps(points, { routeFn: async (a, b) => { try { let r = await rideRoute(a.lat, a.lon, b.lat, b.lon); if (r && r.length >= 2) return { points: r, ridden: true }; } catch {} try { let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false }; } catch { return { points: [], ridden: false }; } } }); if (!res.gaps.length) { showToast("Пропусков для достройки нет"); b.classList.remove("active"); b.disabled = false; return; } gapCache = res; let s = drawGapPatches(res.gaps); if (!s.filledN && !s.skippedN) { showToast("Пропусков для достройки нет"); clearGapPreview(); b.disabled = false; return; } let km = (s.addedM / 1000).toFixed(2); // Clear reason for the ones we couldn't bridge: they aren't on roads you've ridden, // and the public router either detours far (skipped-long) or has no data (noroute). let why = []; if (s.skippedLong) why.push(`${s.skippedLong} — публичный маршрут уводит в объезд`); if (s.skippedNoroute) why.push(`${s.skippedNoroute} — нет дорожных данных`); let skippedTxt = s.skippedN ? ` Не достроено: ${s.skippedN} (нет твоей проеханной дороги${why.length ? "; " + why.join(", ") : ""}).` : ""; gapPanel = panel(`
    Достроено пропусков: ${s.filledN} (+${km} км).${skippedTxt}
    Так трек будет выглядеть после сохранения.
    `); gapPanel.querySelector("#gap-cancel").onclick = () => clearGapPreview(); gapPanel.querySelector("#gap-save").onclick = async () => { if (!s.filledN) { showToast("Нечего сохранять"); return; } let sv = gapPanel.querySelector("#gap-save"); sv.disabled = true; sv.textContent = "Сохранение..."; try { await DB.saveGapFill(sessionId, gapCache.points); await GPS.recalculateSession(sessionId, { noDelete: true }); showToast("Пропуски достроены"); clearGapPreview(); reopenDetail(sessionId); } catch (e) { showToast("Сохранение: " + (e.message || e)); sv.disabled = false; sv.textContent = "Сохранить"; } }; } catch (e) { showToast("Пропуски: " + (e.message || e)); clearGapPreview(); } b.disabled = false; }; return b; }, }); map.addControl(new GapCtl()); // Active saved-route overlay on the detail map + a toggle button (default ON when // a route is selected). Shares baldav_route_shown with the recording screen. let sdRouteLayer = null, sdRouteDiv = null; async function sdSetRoute(shown) { localStorage.setItem("baldav_route_shown", shown ? "1" : "0"); if (shown) sdRouteLayer = await drawActiveRouteOverlay(map, sdRouteLayer); else if (sdRouteLayer) { map.removeLayer(sdRouteLayer); sdRouteLayer = null; } if (sdRouteDiv) sdRouteDiv.classList.toggle("active", !!sdRouteLayer); } if (localStorage.getItem("baldav_active_route")) { let routeCtl = L.control({ position: "topleft" }); routeCtl.onAdd = () => { sdRouteDiv = L.DomUtil.create("div", "sd-map-fs-btn"); sdRouteDiv.innerHTML = "М"; sdRouteDiv.title = "Маршрут"; sdRouteDiv.onclick = (e) => { e.stopPropagation(); sdSetRoute(!sdRouteLayer); }; return sdRouteDiv; }; routeCtl.addTo(map); // Route colour mode toggle (slope <-> elevation), same as on the route-preview // screen. Flips the shared preference and redraws the overlay if it's shown. let sdColorCtl = L.control({ position: "topleft" }); sdColorCtl.onAdd = () => { let d = L.DomUtil.create("div", "sd-map-fs-btn"); let m0 = localStorage.getItem("baldav_route_color_mode") || "slope"; d.innerHTML = m0 === "slope" ? "∡" : "⛰"; d.title = "Раскраска маршрута: уклон / высота"; d.onclick = async (e) => { e.stopPropagation(); let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope"; localStorage.setItem("baldav_route_color_mode", nm); d.innerHTML = nm === "slope" ? "∡" : "⛰"; if (sdRouteLayer) sdRouteLayer = await drawActiveRouteOverlay(map, sdRouteLayer); }; return d; }; sdColorCtl.addTo(map); // Initial draw is deferred to after the track is fitted (below), so it awaits // a ready map and doesn't delay the track rendering. } // --- De-jitter (Savitzky-Golay) tuning panel: preview + save/revert --- let smParams = { W: session.smooth_window || 4, it: session.smooth_iters || 3 }; let smPreview = null, smPanel = null, smBtnDiv = null; let smSetActive = on => { if (smBtnDiv) smBtnDiv.classList.toggle("active", on); }; let drawPreview = () => { let sm = GPS.smoothTrackSG(rawPoints, smParams.W, smParams.it); if (smPreview) map.removeLayer(smPreview); smPreview = L.polyline(sm.map(p => [p.lat, p.lon]), { color: "#00e5ff", weight: 3, opacity: 0.95 }).addTo(map); let d = 0; for (let i = 1; i < sm.length; i++) d += GPS.distance(sm[i-1].lat, sm[i-1].lon, sm[i].lat, sm[i].lon); if (smPanel) smPanel.querySelector("#sm-stat").textContent = "Сглаж.: " + (d / 1000).toFixed(2) + " км"; return sm; }; async function recomputeStatsFor(pts) { let latestW = await DB.getLatestWeight(), profile = await DB.getFullProfile(); let weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80; let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); let CRR = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; let st = GPS.computeStats(pts, weightKg, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", surfaceCrr: CRR[bike?.surface_type] ?? 0.006, tirePressureBar: bike?.tire_pressure_bar ?? 3.0, chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, gearCount: bike?.gear_count, }); await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss); } let toggleSmoothPanel = () => { if (smPanel) { smPanel.remove(); smPanel = null; if (smPreview) { map.removeLayer(smPreview); smPreview = null; } smSetActive(false); return; } smSetActive(true); smPanel = document.createElement("div"); smPanel.className = "sd-smooth-panel"; smPanel.innerHTML = `Сглаживание (SG)
    ${session.smoothed ? '' : ''}`; mapEl.parentElement.appendChild(smPanel); drawPreview(); smPanel.querySelector("#sm-w").oninput = e => { smParams.W = +e.target.value; smPanel.querySelector("#sm-wv").textContent = smParams.W; drawPreview(); }; smPanel.querySelector("#sm-i").oninput = e => { smParams.it = +e.target.value; smPanel.querySelector("#sm-iv").textContent = smParams.it; drawPreview(); }; smPanel.querySelector("#sm-save").onclick = async () => { if (!(await appConfirm(`Сохранить сглаженный трек (окно ${smParams.W}, итераций ${smParams.it})? Оригинал сохранится, можно откатить.`, "Сохранить"))) return; let btn = smPanel.querySelector("#sm-save"); btn.disabled = true; let sm = GPS.smoothTrackSG(rawPoints, smParams.W, smParams.it); await DB.saveSmoothedTrack(sessionId, sm, smParams.W, smParams.it, (i, n) => { btn.textContent = `Сохранение ${i}/${n}`; }); await recomputeStatsFor(sm); showToast("Сглаженный трек сохранён"); reopenDetail(sessionId); }; let rev = smPanel.querySelector("#sm-revert"); if (rev) rev.onclick = async () => { rev.disabled = true; await DB.setSessionSmoothed(sessionId, false); await recomputeStatsFor(rawPoints); showToast("Возвращён оригинал"); reopenDetail(sessionId); }; }; let smCtl = L.control({ position: "topleft" }); smCtl.onAdd = () => { smBtnDiv = L.DomUtil.create("div", "sd-map-fs-btn"); smBtnDiv.innerHTML = "≈"; smBtnDiv.title = "Сглаживание трека"; // "active" reflects the PANEL-OPEN state only (set by smSetActive), not whether // the track is smoothed — otherwise it looks permanently pressed after a save. smBtnDiv.onclick = e => { e.stopPropagation(); toggleSmoothPanel(); }; return smBtnDiv; }; smCtl.addTo(map); // Sort by timestamp, build coords and draw in one pass // Keep original order from DB (seq from FTB import = XML order = clean track) let t0 = new Date(points[0].ts).getTime(); let tN = new Date(points[points.length - 1].ts).getTime(); let tRange = tN - t0 || 1; // Split into segments by GPS gap threshold // Manual routes: no splitting — points are connected as-is (synthetic timestamps have large gaps) let isManualRoute = session.source === "manual"; let gapSec = GPS.config.gapThresholdSec ?? 30; let maxGapConnectSec = 120; // beyond this, never draw a connecting line — split instead let segments = []; // array of arrays of [lat, lon] let gapLines = []; // dashed lines for accel-confirmed gaps let curSeg = [[points[0].lat, points[0].lon]]; for (let i = 1; i < points.length; i++) { if (!isManualRoute) { let gap = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000; if (gap > gapSec) { let accelBefore = points[i-1].accel_rms; let accelAfter = points[i].accel_rms; // Only bridge a SHORT gap (movement confirmed by accel) with a dashed line. // A long gap is not real travel — split with no connecting line, so we never // draw a straight line across half the city. if (gap <= maxGapConnectSec && accelBefore != null && accelAfter != null && accelBefore > 2 && accelAfter > 2) { curSeg.push([points[i].lat, points[i].lon]); gapLines.push([[points[i-1].lat, points[i-1].lon], [points[i].lat, points[i].lon]]); } else { segments.push(curSeg); curSeg = []; curSeg.push([points[i].lat, points[i].lon]); } continue; } } curSeg.push([points[i].lat, points[i].lon]); } segments.push(curSeg); // Draw each segment as separate polyline with start/end markers let allCoords = []; segments.forEach(seg => { if (seg.length < 2) { allCoords.push(...seg); return; } // smoothFactor: Douglas-Peucker tolerance in PIXELS, so it drops points that collapse // together on screen - full detail zoomed in, far fewer segments when zoomed out (where // a 10k-point track otherwise janks and floods the compositor). 2.5px is imperceptible. L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8, smoothFactor: 2.5 }).addTo(map); L.polyline(seg, { color: GPS.config.trackColor || "#bb86fc", weight: 5, smoothFactor: 2.5 }).addTo(map); L.circleMarker(seg[0], { radius: 5, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map); L.circleMarker(seg[seg.length - 1], { radius: 5, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map); allCoords.push(...seg); }); // Draw dashed lines for accel-confirmed gaps (GPS lost signal but person was moving) for (let gl of gapLines) { L.polyline(gl, { color: GPS.config.trackColor || "#bb86fc", weight: 3, opacity: 0.5, dashArray: "8, 8" }).addTo(map); } // Mark BRouter gap-fill runs (estimated points) in cyan so saved dropout-fills stay // visible — each run bracketed by the adjacent real points so it connects to the track. { let run = []; let flush = () => { if (run.length >= 2) L.polyline(run, { color: "#00e5ff", weight: 4, opacity: 0.95 }).addTo(map); run = []; }; for (let i = 0; i < points.length; i++) { if (points[i].estimated) { if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]); run.push([points[i].lat, points[i].lon]); } else if (run.length) { run.push([points[i].lat, points[i].lon]); flush(); } } flush(); } // Colour "walking" runs inside a ride (pushed bike / on foot across a field) so they stand // apart from riding; also report the pushed total. Walk/run sessions are all on foot already. if (session.activity_type !== "walk" && session.activity_type !== "run") { let walk = GPS.detectWalk(points); let run = [], flush = () => { if (run.length >= 2) L.polyline(run, { color: GPS.config.roadColorWalk || "#8B5E3C", weight: 6, opacity: 0.95 }).addTo(map); run = []; }; let wSec = 0, wDist = 0; for (let i = 0; i < points.length; i++) { if (walk[i]) { if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]); run.push([points[i].lat, points[i].lon]); if (i > 0) { wSec += Math.max(0, (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000); wDist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); } } else if (run.length) { run.push([points[i].lat, points[i].lon]); flush(); } } flush(); let ws = document.querySelector("#sd-walk-stat"); if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км"; } // Interval / repeat analysis: the same segment ridden several times in one ride // (intervals). Passes found by self-similarity (GPS.detectRepeats), per-pass stats // via computeStats, plus the last/first verdict; the corridor is highlighted on the // map. Length-normalised speed (segLength / pass time) cancels GPS length noise so // passes compare fairly. Bike only. if (session.activity_type === "bike") { (async () => { let body = document.querySelector("#sd-repeats-body"); if (!body) return; let rep = GPS.detectRepeats(points); if (!rep.found) { body.textContent = "Повторов не найдено (обычная поездка)."; return; } let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); let weightKg = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; let opts = { activityType: "bike", bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type, chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, gearCount: bike?.gear_count, }; let rows = rep.passes.map((p, i) => { let seg = points.slice(p.startIdx, p.endIdx + 1); let st = GPS.computeStats(seg, weightKg, opts); let T = Math.max(1, p.movingSec); // moving time only (stops trimmed) return { i: i + 1, T, normV: rep.segLength / T * 3.6, cad: st.avgCadence, pw: st.avgPower }; }); let ratio = rows[rows.length - 1].normV / rows[0].normV, pct = Math.round(ratio * 100); let verdict, vClass; if (ratio >= 1.0) { verdict = "последний не медленнее первого - можно повторить завтра"; vClass = "ok"; } else if (ratio >= 0.90) { verdict = "нормальная тяжёлая тренировка"; vClass = "warn"; } else { verdict = "просел больше 10% - два дня спокойно"; vClass = "bad"; } let segStr = rep.segLength >= 1000 ? (rep.segLength / 1000).toFixed(2) + " км" : rep.segLength + " м"; body.innerHTML = `
    Участок ${segStr}, заездов ${rep.passCount} (скорость нормирована на длину участка)
    ` + `` + rows.map(r => ``).join("") + `
    №времякм/чкаденсВт
    ${r.i}${GPS.formatDuration(Math.round(r.T))}${r.normV.toFixed(1)}${r.cad ?? "-"}${r.pw ?? "-"}
    ` + `
    ${pct}% последний / первый
    ${verdict}
    `; // Highlight the repeated corridor on the map (every pass draws the same line). try { for (let p of rep.passes) { let seg = points.slice(p.startIdx, p.endIdx + 1).map(q => [q.lat, q.lon]); if (seg.length >= 2) L.polyline(seg, { color: "#00e5ff", weight: 5, opacity: 0.85 }).addTo(map); } } catch {} })(); } // Invisible but tappable hit-targets on top of track for (let i = 0; i < points.length; i++) { let p = points[i]; let alt = p.altitude_srtm_m ?? p.altitude_gps_m ?? p.altitude; let spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : ""; let time = p.ts.split("T")[1].slice(0, 8); let hr = p.heart_rate ? `${p.heart_rate} bpm` : ""; let spo2 = p.spo2 ? `SpO2:${p.spo2}%` : ""; let bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : ""; let bleInfo = [hr, spo2, bp].filter(Boolean).join(" "); let info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`; // "Split here" button lives in every point popup but is shown only in split mode // (mapEl.split-mode, toggled by the "Разрезать трек" button) — see CSS. info += ``; L.circleMarker([p.lat, p.lon], { radius: 8, weight: 0, fillOpacity: 0, interactive: true, }).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map); } // Delegated: tap "Разрезать здесь" in a point popup → split the track at that point. mapEl.addEventListener("click", (ev) => { let b = ev.target.closest(".split-here"); if (b) { ev.stopPropagation(); confirmSplitAt(parseInt(b.dataset.idx)); } }); async function confirmSplitAt(k) { if (!(k >= 2 && k <= points.length - 3)) { showToast("Выберите точку не у самого края трека"); return; } try { map.closePopup(); } catch {} let tOf = (p) => (p.ts.split("T")[1] || "").slice(0, 8); let distToHere = 0; for (let i = 1; i <= k; i++) distToHere += GPS.distance(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon); if (!(await appConfirm("Разрезать трек после точки #" + k + " (время " + tOf(points[k]) + ", ~" + (distToHere / 1000).toFixed(2) + " км от старта)?\n\nВторой кусок станет отдельной тренировкой с тем же велосипедом. Потом можно сменить велосипед у неё и пересчитать.", "Разрезать"))) return; let part2Ids = points.slice(k + 1).map(p => p.id).filter(id => id != null); if (part2Ids.length < 2) { showToast("Во втором куске слишком мало точек"); return; } try { let newId = await DB.splitGpsSession(sessionId, part2Ids, tOf(points[k]), tOf(points[k + 1])); if (newId) { await GPS.recalculateSession(sessionId, { noDelete: true }); await GPS.recalculateSession(newId, { noDelete: true }); } showToast("Трек разрезан на две тренировки"); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("gps"); } catch (e) { console.error("[split]", e); showToast("Ошибка разреза: " + (e.message || e)); } } // Ensure Leaflet has correct container size, then fit map.invalidateSize(); let savedView = _sdMapView[sessionId]; if (savedView) { map.setView(savedView.c, savedView.z); // reopen after recompute/return → keep the zoom } else if (allCoords.length > 1) { map.fitBounds(L.latLngBounds(allCoords).pad(0.1)); } else if (points.length) { // Degenerate track (a single usable point): fitBounds needs 2+ coords, so // give the map a view explicitly — otherwise it never fires 'load' and the // route-overlay `await whenMapReady(map)` below hangs forever, blocking the // rest of showSessionDetail (including the Delete button binding). map.setView([points[0].lat, points[0].lon], 15); } mapEl._bounds = allCoords.length > 1 ? L.latLngBounds(allCoords).pad(0.1) : null; // Draw the active route (default ON) now that the map is sized & fitted — after // the track, awaiting a ready map so the line actually renders. if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") { await whenMapReady(map); await sdSetRoute(true); } // Fullscreen toggle button on map // Fullscreen map toggle function toggleMapFs() { if (mapEl.classList.contains("sd-map-full")) { // Collapse via history.back() — winStack close handler will reset CSS history.back(); } else { // Expand — push to history stack mapEl.classList.add("sd-map-full"); fsDiv.innerHTML = "✕"; map.dragging.enable(); map.scrollWheelZoom.enable(); map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); // Push fullscreen map onto winStack let parentOverlay = mapEl.closest(".modal-overlay"); winPush("fsMap", () => { mapEl.classList.remove("sd-map-full"); fsDiv.innerHTML = "⛶"; map.dragging.disable(); map.scrollWheelZoom.disable(); // Closing the map also closes the smoothing overlay panel (it's a fixed // element tied to the map, otherwise it lingers over the collapsed map). if (smPanel) toggleSmoothPanel(); map.invalidateSize(); if (mapEl._bounds) map.fitBounds(mapEl._bounds); }); winStack[winStack.length - 1].overlay = parentOverlay; } } let fsBtn = L.control({ position: "topright" }); let fsDiv; fsBtn.onAdd = () => { fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn"); fsDiv.innerHTML = "⛶"; fsDiv.onclick = (e) => { e.stopPropagation(); toggleMapFs(); }; return fsDiv; }; fsBtn.addTo(map); // Locate me button let locBtn = L.control({ position: "topleft" }); locBtn.onAdd = () => { let div = L.DomUtil.create("div", "sd-map-fs-btn"); div.innerHTML = "◎"; div.title = "Моё местоположение"; div.onclick = (e) => { e.stopPropagation(); navigator.geolocation?.getCurrentPosition( pos => { let ll = [pos.coords.latitude, pos.coords.longitude]; map.setView(ll, 15); L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map); }, err => console.error("GPS error:", err.message), { enableHighAccuracy: true, timeout: 10000 } ); }; return div; }; locBtn.addTo(map); // Click map to toggle fullscreen (same as button) map.on("click", () => { if (!mapEl.classList.contains("sd-map-full")) toggleMapFs(); }); } // Downsample arrays for smooth chart rendering (max ~500 points) function downsample(arr, maxPts) { if (arr.length <= maxPts) return arr; let step = arr.length / maxPts; let result = []; for (let i = 0; i < maxPts; i++) { let idx = Math.floor(i * step); result.push(arr[idx]); } return result; } let MAX_CHART_PTS = 500; let dsSmoothed = downsample(smoothed, MAX_CHART_PTS); let dsTimeLabels = downsample(timeLabels, MAX_CHART_PTS); let dsElevations = downsample(elevations, MAX_CHART_PTS); let dsElevLabels = downsample( points.filter(p => (p.altitude_srtm_m ?? p.altitude_gps_m) != null).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)), MAX_CHART_PTS ); // Render speed chart let speedCtx = document.querySelector("#sd-speed-chart"); if (speedCtx && smoothed.length) { let maxEl = document.querySelector("#sd-max-speed"); if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч"; new Chart(speedCtx, { type: "line", data: { labels: dsTimeLabels, datasets: [{ data: dsSmoothed, borderColor: "#bb86fc", borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.3, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, zoom: { zoom: { wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, pinch: { enabled: true }, mode: "x", limits: { x: { minRange: 0.5 } }, onZoomStart: ({chart, event}) => { // Allow wheel zoom (Ctrl+scroll) always; pinch only in fullscreen if (event && event.type === "wheel") return true; let wrap = chart.canvas.closest(".chart-wrap"); return wrap && wrap.classList.contains("chart-full"); }, }, pan: { enabled: true, mode: "x" }, }, }, scales: { x: { display: true, title: { display: true, text: "мин", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, y: { title: { display: true, text: "км/ч", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } }, }, }, }); } // Render elevation chart let elevCtx = document.querySelector("#sd-elev-chart"); if (elevCtx && elevations.length) { new Chart(elevCtx, { type: "line", data: { labels: dsElevLabels, datasets: [{ data: dsElevations, borderColor: "#ffb74d", borderWidth: 1.5, pointRadius: 0, fill: true, backgroundColor: "rgba(255,183,77,0.1)", tension: 0.3, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, zoom: { zoom: { wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, pinch: { enabled: true }, mode: "x", limits: { x: { minRange: 0.5 } }, onZoomStart: ({chart, event}) => { // Allow wheel zoom (Ctrl+scroll) always; pinch only in fullscreen if (event && event.type === "wheel") return true; let wrap = chart.canvas.closest(".chart-wrap"); return wrap && wrap.classList.contains("chart-full"); }, }, pan: { enabled: true, mode: "x" }, }, }, scales: { x: { display: true, title: { display: true, text: "мин", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, y: { title: { display: true, text: "м", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } }, }, }, }); } // Render HR chart let hrData = points.map(p => p.heart_rate || null); let hrCtx = document.querySelector("#sd-hr-chart"); if (hrData.some(v => v) && hrCtx) { let wrap = document.querySelector("#sd-hr-wrap"); if (wrap) wrap.style.display = ""; let dsHr = downsample(hrData, MAX_CHART_PTS); let dsHrLabels = downsample( points.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)), MAX_CHART_PTS ); new Chart(hrCtx, { type: "line", data: { labels: dsHrLabels, datasets: [{ data: dsHr, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3, spanGaps: false, segment: { borderColor: function(ctx2) { let v = ctx2.p1.parsed.y; if (v == null || v === 0) return "rgba(100,181,246,0.3)"; return hrZoneColor(v); } }, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, zoom: { zoom: { wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 }, pinch: { enabled: true }, mode: "x", onZoomStart: ({chart, event}) => { if (event && event.type === "wheel") return true; let w = chart.canvas.closest(".chart-wrap"); return w && w.classList.contains("chart-full"); }, }, pan: { enabled: true, mode: "x" }, }, }, scales: { x: { display: true, title: { display: true, text: "мин", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } }, y: { title: { display: true, text: "bpm", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } }, }, }, }); } // Buttons + bike selector { let exp = document.querySelector("#sd-export"); if (exp) exp.onclick = () => GPS.exportGPX(sessionId); let shareBtn = document.querySelector("#sd-share"); if (shareBtn) shareBtn.onclick = () => showShareModal(session, stats, points); // Track ID — click to copy to clipboard let idEl = document.querySelector("#sd-track-id"); if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId); // Chart fullscreen in modal — push to winStack, back collapses document.querySelectorAll(".chart-wrap").forEach(wrap => { wrap.onclick = () => { if (wrap.classList.contains("chart-full")) return; wrap.classList.add("chart-full"); let closeBtn = document.createElement("div"); closeBtn.className = "chart-close-btn"; closeBtn.innerHTML = "✕"; closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); }; wrap.appendChild(closeBtn); let parentOverlay = wrap.closest(".modal-overlay"); winPush("fsChart", () => { wrap.classList.remove("chart-full"); closeBtn.remove(); }); winStack[winStack.length - 1].overlay = parentOverlay; }; }); // Bike name display if (session.bike_id) { DB.getBike(session.bike_id).then(b => { let el = document.querySelector("#sd-bike-name"); if (el && b) el.textContent = b.name; else if (el) el.textContent = "—"; }); } // Bike selector (for bike sessions) let bikeSelect = document.querySelector("#sd-bike-select"); if (bikeSelect) { DB.getBikes().then(bikes => { for (let b of bikes) { let opt = document.createElement("option"); opt.value = b.id; opt.textContent = b.name + (b.is_active ? " [активный]" : ""); if (session.bike_id && session.bike_id === b.id) opt.selected = true; bikeSelect.appendChild(opt); } }); bikeSelect.onchange = async () => { let val = bikeSelect.value ? parseInt(bikeSelect.value) : null; await DB.updateSessionBike(sessionId, val); session.bike_id = val; }; } } // Session action buttons — bound OUTSIDE the map/points guard so a 0-point // (0 km) track can still be deleted / recalculated / exported. Previously these // sat inside `if (mapEl && points.length)`, so an empty track's Delete did nothing. // Compare button let compareBtn = document.querySelector("#sd-compare"); if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points); // Split-track button: toggles split mode on the map (shows a "Разрезать здесь" action // inside each point popup). Hidden for tracks too short to split. let splitBtn = document.querySelector("#sd-split"); if (splitBtn) { if (points.length < 5) splitBtn.style.display = "none"; else splitBtn.onclick = () => { let m = document.querySelector("#sd-map"); if (!m) return; let on = m.classList.toggle("split-mode"); splitBtn.textContent = on ? "Готово (выберите точку на карте)" : "Разрезать трек"; if (on) { showToast("Тапните точку на карте, затем «Разрезать здесь»"); m.scrollIntoView({ behavior: "smooth", block: "center" }); } }; } // Un-merge button: only for a merged session (points carry >1 orig_session_id). Splits it // back into the original rides, recomputing each; shown only when it's actually a merge. let unmergeBtn = document.querySelector("#sd-unmerge"); if (unmergeBtn) (async () => { let n = 0; try { n = await DB.countMergedOrigins(sessionId); } catch {} if (n < 2) return; unmergeBtn.style.display = ""; unmergeBtn.textContent = `Разделить склейку (${n})`; unmergeBtn.onclick = async () => { if (!(await appConfirm(`Разделить трек обратно на ${n} исходные поездки? Статистика каждой пересчитается (точную по физике можно потом «Пересчитать» в каждой).`, "Разделить"))) return; unmergeBtn.disabled = true; unmergeBtn.textContent = "Разделяю..."; try { let ids = await DB.unmergeGpsSession(sessionId); if (!ids) { showToast("Это не склейка"); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; return; } let w = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80; for (let id of ids) { let pts = await DB.getGpsPoints(id); if (!pts.length) continue; let st = GPS.computeStats(pts, w, { activityType: session.activity_type }); let endT = new Date(pts[pts.length - 1].ts).toTimeString().slice(0, 5); try { await DB.finishGpsSession(id, endT, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss, []); } catch (e) { console.error("[unmerge] recompute", e); } } showToast(`Разделено на ${ids.length} трека`); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("gps"); } catch (e) { showToast("Ошибка: " + (e.message || e)); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; } }; })(); // Recalculate button let recalcBtn = document.querySelector("#sd-recalc"); if (recalcBtn) recalcBtn.onclick = async () => { recalcBtn.textContent = "Пересчёт..."; recalcBtn.disabled = true; setBtnProgress(recalcBtn, 0); let newStats = await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(recalcBtn, p) }); setBtnProgress(recalcBtn, null); recalcBtn.textContent = "Пересчитать"; recalcBtn.disabled = false; // reopenDetail awaits the popstate before re-rendering (no blank map/charts race). if (newStats) reopenDetail(sessionId); }; let refineBtn = document.querySelector("#sd-refine"); if (refineBtn) refineBtn.onclick = async () => { let reset = (t) => { setBtnProgress(refineBtn, null); refineBtn.textContent = t; refineBtn.disabled = false; }; refineBtn.disabled = true; refineBtn.textContent = "Высоты..."; setBtnProgress(refineBtn, 0); try { let pts = await DB.getGpsPoints(sessionId); if (pts.length < 2) return reset("Уточнить высоты (онлайн)"); // Sample to ~300 points to limit API load; terrain is smooth → interpolate the rest. let step = Math.max(1, Math.ceil(pts.length / 300)); let sampleIdx = []; for (let i = 0; i < pts.length; i += step) sampleIdx.push(i); if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1); // The network fetch is the slow part → drive the bar 0-80% by fetched batches. let terr = await fetchElevations(sampleIdx.map(i => pts[i]), (done, total) => setBtnProgress(refineBtn, total ? done / total * 80 : 0)); if (!terr || !terr.some(x => x != null)) { if (typeof showToast === "function") showToast("Сервис высот недоступен"); return reset("Уточнить высоты (онлайн)"); } // Interpolate terrain to every point (linear by index between samples). let alt = new Array(pts.length).fill(null); for (let s = 0; s < sampleIdx.length - 1; s++) { let i0 = sampleIdx[s], i1 = sampleIdx[s + 1], a0 = terr[s], a1 = terr[s + 1]; if (a0 == null || a1 == null) continue; for (let i = i0; i <= i1; i++) alt[i] = a0 + (a1 - a0) * (i1 > i0 ? (i - i0) / (i1 - i0) : 0); } let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null); refineBtn.textContent = "Сохранение..."; setBtnProgress(refineBtn, 85); await DB.updatePointsSrtm(rows); await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(refineBtn, 85 + p * 0.15) }); setBtnProgress(refineBtn, null); if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам"); reopenDetail(sessionId); // awaits popstate → no blank map/charts race } catch (e) { console.log("[refine] fail:", e.message); if (typeof showToast === "function") showToast("Ошибка высот: " + e.message); reset("Уточнить высоты (онлайн)"); } }; let del = document.querySelector("#sd-delete"); if (del) del.onclick = async () => { await DB.deleteGpsSession(sessionId); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("gps"); }; // Goal markers — closest approach for each marker try { let goalSection = document.querySelector("#sd-goals-section"); let goalsList = document.querySelector("#sd-goals-list"); let markers = (goalSection && goalsList && points.length >= 2) ? await DB.getGoalMarkers() : []; if (markers.length > 0) { // For each marker, find closest point in this track let results = []; for (let marker of markers) { let minDist = Infinity; let closestIdx = 0; for (let i = 0; i < points.length; i++) { let d = GPS.distance(points[i].lat, points[i].lon, marker.lat, marker.lon); if (d < minDist) { minDist = d; closestIdx = i; } } // Check previous sessions for progress comparison let prevSessions = await DB.getGpsSessions(50); let bestPrevDist = Infinity; let bestPrevDate = null; for (let ps of prevSessions) { let psId = ps.id_str ?? String(ps.id); if (psId === String(sessionId)) continue; let psPts = await DB.getGpsPoints(psId); if (psPts.length < 2) continue; for (let pp of psPts) { let d = GPS.distance(pp.lat, pp.lon, marker.lat, marker.lon); if (d < bestPrevDist) { bestPrevDist = d; bestPrevDate = ps.date; } } } results.push({ name: marker.name, minDist: Math.round(minDist), closestIdx, bestPrevDist: bestPrevDist < Infinity ? Math.round(bestPrevDist) : null, bestPrevDate, }); } goalSection.style.display = ""; goalsList.innerHTML = results.map(r => { let distStr = r.minDist >= 1000 ? (r.minDist / 1000).toFixed(1) + " км" : r.minDist + " м"; let progressHtml = ""; if (r.bestPrevDist != null) { let diff = r.bestPrevDist - r.minDist; if (diff > 0) { progressHtml = `На ${diff >= 1000 ? (diff / 1000).toFixed(1) + " км" : diff + " м"} ближе (было ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"}, ${r.bestPrevDate})`; } else if (diff < 0) { progressHtml = `Рекорд: ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"} (${r.bestPrevDate})`; } else { progressHtml = `Повторение рекорда`; } } // Progress bar: ratio of current distance to a reasonable max (e.g. 5000m) let maxBar = Math.max(r.minDist, r.bestPrevDist ?? r.minDist, 100); let pct = Math.max(0, Math.min(100, 100 - (r.minDist / maxBar * 100))); return `
    ${esc(r.name)}
    Ближайшая точка: ${distStr} (точка #${r.closestIdx})
    ${progressHtml}
    `; }).join(""); } } catch (e) { console.error("[goals]", e); } // Food cost of workout try { let costSection = document.querySelector("#sd-food-cost-section"); let costEl = document.querySelector("#sd-food-cost"); let sessionKcal = session.total_kcal ?? stats.kcal; let foods = (costSection && costEl && sessionKcal > 0) ? await DB.getFrequentFoods(7) : []; // Build "cost" descriptions: N portions of food let parts = []; for (let f of foods) { if (!f.kcal || f.kcal <= 0) continue; // kcal per one serving (as user ate it) let servingKcal = Math.round(f.kcal); if (servingKcal <= 0) continue; let portions = Math.round(sessionKcal / servingKcal * 10) / 10; let amountStr = f.amount_g ? f.amount_g + "г" : ""; parts.push({ name: f.product_name, portions, servingKcal, amountStr, }); } if (parts.length > 0) { // Take first 3, build readable string let display = parts.slice(0, 3); let lines = display.map(p => `${p.portions} порц. ${esc(p.name)} (по ${p.amountStr || "?"}, ${p.servingKcal} кк/порц.)` ); costSection.style.display = ""; costEl.innerHTML = `
    ${Math.round(sessionKcal)} ккал — это как:
    ${lines.map(l => `
    ${l}
    `).join('
    или
    ')}
    `; } } catch (e) { console.error("[food-cost]", e); } } catch(e) { console.error("[showSessionDetail] error:", e, JSON.stringify(e)); } } // --------------------------------------------------------------------------- // Lap comparison — overlay two tracks on the same map // --------------------------------------------------------------------------- async function showCompareModal(sessionId, currentSession, currentPoints) { let dist = currentSession.total_distance_m || 0; let sessions = await DB.getGpsSessions(100); // Filter: same activity type, similar distance (+-30%), not the same session let similar = sessions.filter(s => (s.id_str ?? String(s.id)) !== String(sessionId) && s.activity_type === currentSession.activity_type && s.total_distance_m != null && s.total_distance_m > dist * 0.7 && s.total_distance_m < dist * 1.3 ); if (similar.length === 0) { showModal(`

    Сравнение

    Нет похожих тренировок (та же активность, расстояние +-30%).

    `, () => {}); return; } let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" }; showModal(`

    Сравнить с...

    ${similar.map(s => { let d = s.total_distance_m != null ? GPS.formatDistance(Math.round(s.total_distance_m)) : "?"; let spd = s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""; return `
    ${s.date} ${s.start_time ?? ""}
    ${d} | ${spd}
    `; }).join("")}
    `, () => {}); let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); if (!overlay) return; // Hide save/cancel buttons — this is a selection modal let btns = overlay.querySelector(".modal-btns"); if (btns) btns.style.display = "none"; overlay.querySelectorAll(".compare-item").forEach(item => { item.onclick = async () => { let compId = item.dataset.sid; // keep as string (large i64 id) // Close this modal, open comparison view if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); await showComparisonView(sessionId, compId, currentSession, currentPoints); }; }); } async function showComparisonView(currentId, compareId, currentSession, currentPoints) { let compSession = await DB.getGpsSession(compareId); if (!compSession) return; let compPoints = GPS.filterNoise(await DB.getGpsPoints(compareId), compSession.activity_type); if (compPoints.length < 2 || currentPoints.length < 2) return; // Determine which is newer — newer is always "current" for color coding // Red = newer is slower (regression), Green = newer is faster (progress) let curDate = currentSession.date || ""; let compDate = compSession.date || ""; let newerPoints, olderPoints, newerSession, olderSession; if (curDate >= compDate) { newerPoints = currentPoints; olderPoints = compPoints; newerSession = currentSession; olderSession = compSession; } else { newerPoints = compPoints; olderPoints = currentPoints; newerSession = compSession; olderSession = currentSession; } // Compute duration diff (newer - older) let newDur = (new Date(newerPoints[newerPoints.length-1].ts) - new Date(newerPoints[0].ts)) / 1000; let oldDur = (new Date(olderPoints[olderPoints.length-1].ts) - new Date(olderPoints[0].ts)) / 1000; let durDiff = newDur - oldDur; let durLabel = durDiff < 0 ? `На ${GPS.formatDuration(Math.abs(Math.round(durDiff)))} быстрее` : durDiff > 0 ? `На ${GPS.formatDuration(Math.round(durDiff))} медленнее` : "Одинаково"; let newAvg = newerSession.avg_speed_kmh ?? 0; let oldAvg = olderSession.avg_speed_kmh ?? 0; let spdDiff = Math.round((newAvg - oldAvg) * 10) / 10; showModal(`

    Сравнение треков

    Результат
    ${durLabel}Время
    ${newAvg} км/чНовый (${newerSession.date})
    ${oldAvg} км/чСтарый (${olderSession.date})
    ${spdDiff > 0 ? "+" : ""}${spdDiff}Разница
    Карта
    Зелёные участки — прогресс (новый быстрее). Красные — регресс (новый медленнее).
    `, () => {}); setTimeout(() => { let mapEl = document.querySelector("#compare-map"); if (!mapEl) return; // Hide save/cancel let btns = mapEl.closest(".modal-overlay")?.querySelector(".modal-btns"); if (btns) btns.style.display = "none"; L.Icon.Default.imagePath = "lib/"; let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false, touchZoom: true, dragging: true }); // canvas: two big tracks vs SVG compositor tile-memory limit mapEl._leafletMap = map; addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map); TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable // Build speed lookup for older track let olderCoords = olderPoints.map(p => [p.lat, p.lon]); // Pre-compute older track speeds let olderSpeeds = olderPoints.map(p => (p.speed_ms ?? 0) * 3.6); // Draw newer track colored by speed difference vs older // Green = newer faster (progress), Red = newer slower (regression) for (let i = 1; i < newerPoints.length; i++) { let cp = newerPoints[i]; let cpPrev = newerPoints[i - 1]; let newSpd = (cp.speed_ms ?? 0) * 3.6; // Find nearest older point let nearestIdx = 0, nearestDist = Infinity; for (let j = 0; j < olderPoints.length; j++) { let d = GPS.distance(cp.lat, cp.lon, olderPoints[j].lat, olderPoints[j].lon); if (d < nearestDist) { nearestDist = d; nearestIdx = j; } } let oldSpd = olderSpeeds[nearestIdx]; let segColor; if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare else if (newSpd > oldSpd + 1) segColor = "#4caf50"; // progress else if (newSpd < oldSpd - 1) segColor = "#f44336"; // regression else segColor = "#bb86fc"; // similar L.polyline( [[cpPrev.lat, cpPrev.lon], [cp.lat, cp.lon]], { color: segColor, weight: 5, opacity: 0.9 } ).addTo(map); } // Fit to both tracks let allCoords = newerPoints.map(p => [p.lat, p.lon]).concat(olderCoords); if (allCoords.length > 1) { map.fitBounds(L.latLngBounds(allCoords).pad(0.1)); } // Fullscreen toggle let fsState = false; let fsBtn = L.control({ position: "topright" }); let fsDiv; fsBtn.onAdd = () => { fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn"); fsDiv.innerHTML = "⛶"; fsDiv.onclick = (e) => { e.stopPropagation(); if (fsState) { fsState = false; mapEl.classList.remove("sd-map-full"); fsDiv.innerHTML = "⛶"; setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100); } else { fsState = true; mapEl.classList.add("sd-map-full"); fsDiv.innerHTML = "✕"; setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100); } }; return fsDiv; }; fsBtn.addTo(map); }, 300); } // --------------------------------------------------------------------------- // Log supplement modal (from Today screen) // --------------------------------------------------------------------------- async function showLogSupplementModal() { let supps = await DB.getSupplements(); if (supps.length === 0) { showModal(`

    Добавки

    Нет добавок. Создайте на вкладке "Добавки".

    `, () => {}); return; } showModal(`

    Принял добавку

    ${supps.map(s => `
    ${esc(s.name)}${s.daily_dose_g ? ` (${s.daily_dose_g}г/день)` : ""}
    `).join("")}
    `, async overlay => { let selected = overlay.querySelector(".supp-pick-item.selected"); if (!selected) return false; let sid = parseInt(selected.dataset.sid); let qty = parseFloat(overlay.querySelector("#m-qty").value) || 1; let time = overlay.querySelector("#m-time").value; await DB.logSupplement(state.date, time, sid, qty); }); let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); overlay.querySelectorAll(".supp-pick-item").forEach(item => { item.onclick = () => { overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected")); item.classList.add("selected"); }; }); } // --------------------------------------------------------------------------- // SUPPLEMENTS screen — manage supplements catalog // --------------------------------------------------------------------------- let MICRO_FIELDS = [ { key: "vit_a_mcg", label: "A / ретинол (мкг РЭ)" }, { key: "carotene_mcg", label: "Бета-каротин (мкг)" }, { key: "vit_c_mg", label: "C / аскорбинка (мг)" }, { key: "vit_d_mcg", label: "D3 / холекальциферол (мкг)" }, { key: "vit_e_mg", label: "E / токоферол (мг ТЭ)" }, { key: "vit_k_mcg", label: "K / филлохинон (мкг)" }, { key: "vit_b1_mg", label: "B1 / тиамин (мг)" }, { key: "vit_b2_mg", label: "B2 / рибофлавин (мг)" }, { key: "vit_b3_mg", label: "B3 / PP / ниацин (мг)" }, { key: "vit_b5_mg", label: "B5 / пантотенат (мг)" }, { key: "vit_b6_mg", label: "B6 / пиридоксин (мг)" }, { key: "vit_b7_mcg", label: "B7 / биотин (мкг)" }, { key: "folate_mcg", label: "B9 / фолиевая (мкг)" }, { key: "vit_b12_mcg", label: "B12 / цианокобаламин (мкг)" }, { key: "calcium_mg", label: "Кальций / Ca (мг)" }, { key: "magnesium_mg", label: "Магний / Mg (мг)" }, { key: "iron_mg", label: "Железо / Fe (мг)" }, { key: "zinc_mg", label: "Цинк / Zn (мг)" }, { key: "copper_mg", label: "Медь / Cu (мг)" }, { key: "selenium_mcg", label: "Селен / Se (мкг)" }, { key: "potassium_mg", label: "Калий / K (мг)" }, { key: "sodium_mg", label: "Натрий / Na (мг)" }, { key: "phosphorus_mg", label: "Фосфор / P (мг)" }, { key: "iodine_mg", label: "Йод / I (мкг)", mult: 0.001 }, { key: "chromium_mg", label: "Хром / Cr (мкг)", mult: 0.001 }, { key: "manganese_mg", label: "Марганец / Mn (мг)" }, { key: "molybdenum_mcg", label: "Молибден / Mo (мкг)" }, { key: "fish_oil_mg", label: "Рыбий жир (мг)" }, { key: "omega3_mg", label: "-- Омега-3 ПНЖК (мг)" }, { key: "epa_mg", label: "-- EPA / эйкозапентаеновая (мг)" }, { key: "dha_mg", label: "-- DHA / докозагексаеновая (мг)" }, { key: "omega6_mg", label: "Омега-6 (мг)" }, { key: "omega9_mg", label: "Омега-9 (мг)" }, { key: "silicon_mg", label: "Кремний / Si (мг)" }, { key: "vanadium_mg", label: "Ванадий / V (мг)" }, { key: "lutein_mg", label: "Лютеин (мг)" }, { key: "lycopene_mg", label: "Ликопин (мг)" }, { key: "ginseng_mg", label: "Женьшень / Panax (мг)" }, { key: "resveratrol_mg", label: "Ресвератрол (мг)" }, { key: "coq10_mg", label: "Коэнзим Q10 (мг)" }, ]; // Subset of MICRO_FIELDS that exist as columns in the products table let PRODUCT_MICRO_FIELDS = MICRO_FIELDS.filter(f => [ "vit_a_mcg", "vit_c_mg", "vit_d_mcg", "vit_e_mg", "vit_k_mcg", "vit_b1_mg", "vit_b2_mg", "vit_b3_mg", "vit_b6_mg", "vit_b12_mcg", "folate_mcg", "calcium_mg", "iron_mg", "magnesium_mg", "zinc_mg", "copper_mg", "selenium_mcg", "potassium_mg", "sodium_mg", "phosphorus_mg", "carotene_mcg", "ginseng_mg", "resveratrol_mg", "coq10_mg", ].includes(f.key)); async function renderSupplements(container) { let supps = await DB.getSupplements(); function suppSummary(s) { let parts = []; for (let f of MICRO_FIELDS) { if (s[f.key]) parts.push(`${f.label.split("(")[0].trim()} ${s[f.key]}`); } return parts.slice(0, 4).join(", ") + (parts.length > 4 ? " ..." : ""); } container.innerHTML = `

    Добавки

    ${supps.length ? supps.map(s => `
    ${esc(s.name)} ${s.daily_dose_g ? `${s.daily_dose_g} шт/день` : ""}
    ${suppSummary(s)}
    `).join("") : `
    Нет добавок. Нажмите + чтобы добавить.
    `}
    `; container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null); container.querySelector("#export-supps-btn").onclick = async () => { let supps = await DB.getSupplements(); downloadFile("baldav-supplements.json", JSON.stringify(supps, null, 2), "application/json"); }; container.querySelector("#import-supps-btn").onclick = () => { pickFile(".json", async (text) => { let data = JSON.parse(text); await importSupplements(data); renderSupplements(container); }); }; container.querySelectorAll(".prod-card[data-sid]").forEach(card => { card.querySelector(".prod-card-header").onclick = async () => { let sid = parseInt(card.dataset.sid); let s = await DB.getSupplement(sid); if (s) showEditSupplementModal(s); }; }); } function showEditSupplementModal(existing) { let isNew = !existing; let s = existing || {}; let microHtml = MICRO_FIELDS.map(f => { let displayVal = s[f.key] ?? ""; if (displayVal && f.mult) displayVal = Math.round(displayVal / f.mult * 1000) / 1000; // mcg→mg for display return `
    `; } ).join(""); showModal(`

    ${isNew ? "Новая добавка" : "Редактирование"}

    Содержание в 1 таблетке:

    ${microHtml}
    ${!isNew ? `` : ""}`, async overlay => { let name = overlay.querySelector("#m-name").value.trim(); if (!name) return false; let data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null }; for (let f of MICRO_FIELDS) { let val = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null; if (val && f.mult) val *= f.mult; // convert mg→mcg where needed data[f.key] = val; } if (isNew) { await DB.addSupplement(data); } else { // Update: delete + re-add (simple, keeps log intact via supplement_id) // Actually need an update function. For now, delete logs referencing old id is bad. // Just delete and re-add — log entries will be orphaned. Better to add updateSupplement. // Quick approach: raw execute let sets = ["name=?1", "daily_dose_g=?2"]; let vals = [name, data.daily_dose_g]; let idx = 3; for (let f of MICRO_FIELDS) { sets.push(`${f.key}=?${idx}`); vals.push(data[f.key]); idx++; } vals.push(existing.id); await window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`, values: vals, }); } }); if (!isNew) { setTimeout(() => { let delBtn = document.querySelector("#m-del-supp"); if (delBtn) { delBtn.onclick = async (e) => { e.stopPropagation(); await DB.deleteSupplement(existing.id); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("supplements"); }; } }, 100); } } // --------------------------------------------------------------------------- // SETTINGS screen // --------------------------------------------------------------------------- // Offline maps live on the router under /baldav/tiles/. The list is read from the // directory listing, so a new .db uploaded there appears without an app rebuild. let TILES_BASE_URL = "http://192.168.1.1:88/baldav/tiles/"; // Regions the packing tools know about. Shown even when the router has no .db for // them yet (greyed out) so nothing silently disappears from the list. let KNOWN_REGIONS = { novocherkassk: "Мой регион", rostov_city: "Ростов-на-Дону", rostov_oblast: "Ростовская обл.", moscow_city: "Москва", moscow_oblast: "Московская обл.", spb: "Санкт-Петербург", krasnodar_city: "Краснодар", krasnodar_krai: "Краснодарский край", sochi: "Сочи (+Адлер, Кр. Поляна)", kazan: "Казань", novosibirsk: "Новосибирск", ekaterinburg: "Екатеринбург", }; // Fetch [{ file, name, sizeMB }] from the router's tiles directory listing. async function fetchRouterTiles() { let html = await window.__TAURI__.core.invoke("http_request", { url: TILES_BASE_URL, method: "GET", body: null, }); let out = []; let re = /(tiles_[A-Za-z0-9_-]+\.db)<\/a>[\s\S]*?-\s*([\d.]+)\s*kbyte/g; let m; while ((m = re.exec(html)) !== null) { out.push({ file: m[1], name: m[1].replace(/^tiles_|\.db$/g, ""), sizeMB: (parseFloat(m[2]) / 1024).toFixed(1) }); } if (!out.length) { for (let f of [...new Set(html.match(/tiles_[A-Za-z0-9_-]+\.db/g) || [])]) { out.push({ file: f, name: f.replace(/^tiles_|\.db$/g, ""), sizeMB: null }); } } return out; } // Open the Android SAF file picker and return the path of the picked file. // A content:// URI has no path, so Kotlin copies the file into the app dir and // writes the path to open_file_result.txt, which we poll here. Returns null if // the user cancelled. Desktop has no picker — callers fall back to a path prompt. async function pickLocalFile(mime) { let invoke = window.__TAURI__.core.invoke; await invoke("pick_file_open", { mime: mime || "*/*" }); for (let i = 0; i < 600; i++) { // poll up to ~3 min while the picker is open await new Promise(r => setTimeout(r, 300)); let res = await invoke("get_picked_file"); if (!res) continue; if (res === "CANCELLED") return null; if (res.startsWith("ERROR:")) throw new Error(res.slice(6)); return res; } throw new Error("Таймаут выбора файла"); } // Download a tile DB by URL into the app dir, then import it into the tile cache. async function importTilesFromUrl(url, label, btn, container) { let invoke = window.__TAURI__.core.invoke; let origText = btn.textContent; btn.disabled = true; btn.textContent = "Скачивание..."; try { let appDir = await window.__TAURI__.path.appDataDir(); let dest = appDir + "/" + (label || "tiles_download.db"); let bytes = await invoke("download_file", { url, dest }); btn.textContent = `Импорт (${(parseInt(bytes) / 1048576).toFixed(1)} MB)...`; let count = await TileCache.importFromSqlite(dest, (done, total) => { btn.textContent = `${Math.round(done / total * 100)}% (${done}/${total})`; }); showToast(`${label}: ${count} тайлов импортировано`); TileCache.count().then(n => { let el = container.querySelector("#s-tile-count"); if (el) el.textContent = `${n} тайлов в кэше`; }); } catch (e) { showToast("Ошибка: " + String(e)); } btn.disabled = false; btn.textContent = origText; } async function renderSettings(container) { let p = state.profile; let profileSummary = [ p.height_cm ? `${p.height_cm} см` : null, p.birth_year ? `${p.birth_year} г.р.` : null, p.sex === "f" ? "Ж" : "М", ].filter(Boolean).join(", "); container.innerHTML = `

    Настройки

    Профиль
    ${profileSummary || "Не заполнен"}
    ›
    Нормы нутриентов
    Стандарт РСП
    Пересчитает % нутриентов
    Честный знак
    Сессия
    ${(() => { let s = ChZ.getSession(); if (!s) return "Не импортирована"; let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0)); return `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`; })()}
    Обновить сессию
    Продлить sid через refresh (проверка связи)
    Карты
    Оффлайн режим
    Тайлы только из кэша
    Импорт тайлов
    Загрузить .db файл с тайлами региона
    Фон для картинки «Похвастаться»
    Фото-подложка под форму трека и статы
    Резервная копия всех данных
    Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.
    Очистить продукты без магазина
    Удалить неиспользованные продукты без метки источника (health-diet, фастфуд). Съеденные и в рецептах остаются.
    Скачать с роутера
    LAN: 192.168.1.1
    Кэш тайлов
    ...
    Просмотр дорог
    Все проезженные маршруты на карте
    GPS (запись треков)
    Мин. скорость (км/ч)
    Ниже = пауза
    Макс. погрешность GPS (м)
    Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно. Применяется и при пересчёте трека — снизьте и нажмите «Пересчитать», чтобы отбросить неточные точки (обратимо: поднимите обратно).
    Мин. расстояние (м)
    Точки ближе = пропуск
    Макс. скорость (км/ч)
    Выше = глитч GPS, игнор
    Макс. пауза GPS (сек)
    Пробел длиннее = новый сегмент
    Аэросопротивление
    Город 0.6, спорт 0.4
    Плотность воздуха (кг/м3)
    Море 1.225, горы 1.0
    КПД педалирования (%)
    Человек ~25%
    Сглаживание высоты
    Полуокно (5=11 точек, 7=15)
    Ходьба / Бег
    Макс. скорость (км/ч)
    Выше = глитч GPS
    Мин. скорость (км/ч)
    Ниже = стоит
    Цвет текущего трека
    Линия записи/трека (чтобы не путать с дорогами)
    Цвет дорог: вело
    Цвет дорог: ходьба
    Цвет дорог: бег
    Толщина дорог по проездам
    Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)
    Fused-локация (Google Play)
    Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.
    GPXZ API ключ (высоты маршрутов)
    Точные высоты Copernicus GLO-30 (лучше SRTM для уклонов). Пусто = open-elevation. Ключ хранится только на устройстве, не в git.
    Сохранить настройки GPS
    Велосипеды
    + Добавить велосипед
    Разрешения
    Геолокация (GPS)
    Проверка...
    Фоновая геолокация
    Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».
    Нормы ЧСС и давления
    ЧСС покой (мин / макс)
    ЧСС макс
    По умолчанию 220 - возраст
    Давление норма (сис / диа)
    Пульсометр (BLE)
    ${state.profile.ble_device_name || "Не привязан"}
    ${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}
    ${state.profile.ble_device_addr ? `` : ""} ${state.profile.ble_device_addr ? `` : ""}
    Ремень ЧСС: наработка
    —
    Синхронизация
    ${Sync.userId ? "Подключено" : "Не настроено"}
    ${Sync.userId ? (state.profile.sync_last_time || "Не синхронизировано") : "Привяжите устройство"}
    О приложении
    BalDav
    ...
    `; // Profile button → open profile modal container.querySelector("#s-open-profile").onclick = () => showProfileModal(); // RDI standard selector let rdiSel = container.querySelector("#s-rdi-standard"); if (rdiSel) { rdiSel.value = state.profile.rdi_standard || "RU"; rdiSel.onchange = async () => { await DB.setProfileValue("rdi_standard", rdiSel.value); state.profile.rdi_standard = rdiSel.value; await BMR.loadRDI(rdiSel.value); }; } // Offline toggle let offToggle = container.querySelector("#s-offline-toggle"); offToggle.onclick = () => { let newVal = !TileCache.offlineMode; TileCache.setOfflineMode(newVal); offToggle.classList.toggle("on", newVal); }; // Tile cache count TileCache.count().then(n => { let el = container.querySelector("#s-tile-count"); if (el) el.textContent = `${n} тайлов в кэше`; }); // Catalog cleanup: drop unused products that carry no source badge (health-diet, // fast food, null source). Eaten / recipe-used products are protected. let cleanBtn = container.querySelector("#s-clean-products"); if (cleanBtn) cleanBtn.onclick = async () => { let n = await DB.countUnlabeledUnusedProducts(); if (!n) { showToast("Нечего чистить"); return; } if (!(await appConfirm(`Удалить ${n} неиспользованных продуктов без метки источника (health-diet, фастфуд)? Съеденные и в рецептах останутся.`, "Удалить"))) return; cleanBtn.disabled = true; cleanBtn.textContent = "Чистка..."; let removed = await DB.cleanupUnlabeledProducts(); cleanBtn.disabled = false; cleanBtn.textContent = "Очистить"; showToast(`Удалено ${removed} продуктов`); }; // Честный знак: import session JSON exported by tools/chzn_export.sh. function chznRefreshStatus() { let el = container.querySelector("#s-chzn-status"); if (!el) return; let s = ChZ.getSession(); if (!s) { el.textContent = "Не импортирована"; return; } let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0)); el.textContent = `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`; } container.querySelector("#s-chzn-import")?.addEventListener("click", () => { pickFile(".json,application/json", async (text) => { try { ChZ.importFromText(text); showToast("Сессия Честного знака импортирована"); chznRefreshStatus(); } catch (e) { showToast("Ошибка импорта: " + e.message); } }); }); container.querySelector("#s-chzn-refresh")?.addEventListener("click", async () => { let btn = container.querySelector("#s-chzn-refresh"); if (!ChZ.hasSession()) { showToast("Сначала импортируйте сессию"); return; } btn.disabled = true; btn.textContent = "..."; try { await ChZ.refresh(); showToast("Сессия обновлена"); chznRefreshStatus(); } catch (e) { showToast("Refresh не удался: " + e.message); } btn.disabled = false; btn.textContent = "Обновить"; }); // Import tiles from a file the user picks on the device (SAF picker on Android). container.querySelector("#s-import-tiles").onclick = async () => { let btn = container.querySelector("#s-import-tiles"); let filePath = null; if (/Android/i.test(navigator.userAgent)) { btn.disabled = true; let prev = btn.textContent; btn.textContent = "Выбор файла..."; try { filePath = await pickLocalFile("*/*"); } catch (e) { showToast("Ошибка выбора: " + (e.message || e)); } btn.disabled = false; btn.textContent = prev; if (!filePath) return; // cancelled } else { filePath = await appPrompt("Путь к файлу тайлов (.db):", ""); if (!filePath) return; } let origText = btn.textContent; btn.disabled = true; btn.textContent = "0%..."; try { let count = await TileCache.importFromSqlite(filePath, (done, total) => { let pct = Math.round(done / total * 100); btn.textContent = `${pct}% (${done}/${total})`; }); btn.textContent = origText; btn.disabled = false; showToast(`Импортировано ${count} тайлов`); let el = container.querySelector("#s-tile-count"); TileCache.count().then(n => { if (el) el.textContent = `${n} тайлов в кэше`; }); } catch (e) { btn.textContent = origText; btn.disabled = false; showToast("Ошибка: " + String(e)); } }; // Share-image background: pick a photo, downscale, store as a data URL in localStorage. let sbgBtn = container.querySelector("#s-share-bg"); if (sbgBtn) sbgBtn.onclick = async () => { let prev = sbgBtn.textContent, path; if (/Android/i.test(navigator.userAgent)) { sbgBtn.disabled = true; sbgBtn.textContent = "Выбор..."; try { path = await pickLocalFile("image/*"); } catch (e) { showToast("Ошибка выбора: " + (e.message || e)); } sbgBtn.disabled = false; sbgBtn.textContent = prev; } else { path = await appPrompt("Путь к фото-подложке:", ""); } if (!path) return; sbgBtn.disabled = true; sbgBtn.textContent = "Загрузка..."; try { let b64 = await window.__TAURI__.core.invoke("read_file_base64", { path }); let mime = /\.png$/i.test(path) ? "image/png" : "image/jpeg"; await new Promise((res, rej) => { let img = new Image(); img.onload = () => { let s = Math.min(1, 1080 / img.width); let c = document.createElement("canvas"); c.width = Math.round(img.width * s); c.height = Math.round(img.height * s); c.getContext("2d").drawImage(img, 0, 0, c.width, c.height); try { localStorage.setItem("baldav_share_bg", c.toDataURL("image/jpeg", 0.85)); res(); } catch (e) { rej(e); } }; img.onerror = () => rej(new Error("не удалось прочитать изображение")); img.src = "data:" + mime + ";base64," + b64; }); showToast("Подложка сохранена"); } catch (e) { showToast("Подложка: " + (e.message || e)); } sbgBtn.disabled = false; sbgBtn.textContent = prev; }; let sbgClear = container.querySelector("#s-share-bg-clear"); if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; // Full-DB backup: export the whole database to a file (SAF save), and restore from one. let expDbBtn = container.querySelector("#s-export-db"); if (expDbBtn) expDbBtn.onclick = async () => { let prev = expDbBtn.textContent; expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю..."; try { let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db"; let res = await window.__TAURI__.core.invoke("export_db", { filename }); let size = 0; try { size = JSON.parse(res).size || 0; } catch {} showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить"); } catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); } expDbBtn.disabled = false; expDbBtn.textContent = prev; }; let impDbBtn = container.querySelector("#s-import-db"); if (impDbBtn) impDbBtn.onclick = async () => { if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; } if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return; let prev = impDbBtn.textContent; impDbBtn.disabled = true; impDbBtn.textContent = "Выбор..."; try { let path = await pickLocalFile("*/*"); if (!path) { showToast("Файл не выбран"); return; } impDbBtn.textContent = "Проверка..."; await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it // No auto-restart: on a single-activity app the process-restart is unreliable (Android 12+ // blocks background activity starts). The staged DB is swapped on the next launch, so ask // the user to fully reopen the app — reliable, and they confirmed it works. await appAlert("База проверена и готова. Чтобы применить восстановление:\n\n1. Полностью закройте приложение (смахните его из списка недавних).\n2. Откройте снова.\n\nПосле этого данные будут заменены восстановленными."); } catch (e) { appAlert("Импорт не удался: " + (e.message || e)); } impDbBtn.disabled = false; impDbBtn.textContent = prev; }; // Download tiles from router — the list comes from the router's directory // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { let btn = container.querySelector("#s-download-tiles"); let origText = btn.textContent; btn.disabled = true; btn.textContent = "Список..."; let files = []; try { files = await fetchRouterTiles(); } catch (e) { btn.disabled = false; btn.textContent = origText; showToast("Роутер недоступен: " + (e.message || e)); return; } btn.disabled = false; btn.textContent = origText; // Show every known region. Ones present on the router are tappable; the rest // are greyed out so the list never silently loses entries. Any extra .db on // the router that isn't a known region is listed too. let byKey = {}; for (let f of files) byKey[f.name] = f; let rows = Object.keys(KNOWN_REGIONS).map(k => ({ key: k, label: KNOWN_REGIONS[k], f: byKey[k] })); for (let f of files) if (!KNOWN_REGIONS[f.name]) rows.push({ key: f.name, label: f.name, f }); let items = rows.map((r, i) => r.f ? `
    ${esc(r.label)} ${r.f.sizeMB ? r.f.sizeMB + " MB" : ""}
    ` : `
    ${esc(r.label)} нет на роутере
    ` ).join(""); // Tap a map to start downloading it; the OK button is unused here. showModal(`

    Карты на роутере

    Нажмите на карту, чтобы скачать и импортировать
    ${items}
    `, async () => "norender", overlay => { overlay.querySelectorAll("#t-file-list .supp-pick-item[data-idx]").forEach(el => { el.onclick = () => { let r = rows[parseInt(el.dataset.idx)]; overlay.querySelector("#modal-cancel").click(); // closes the modal importTilesFromUrl(TILES_BASE_URL + r.f.file, r.f.file, btn, container); }; }); }); }; // Roads map container.querySelector("#s-roads-map").onclick = () => showRoadsMap(); // GPS config inputs. Read every field into GPS.config and persist. On mobile a // bare `onchange` (fires on blur) is easy to miss and gives no feedback, so we // also bind `input`, and add an explicit "Save" button — both show a toast so // it's obvious the value was saved. computeStats reads GPS.config live, so a // recompute right after applies the new value. let readGpsConfig = () => { GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6; GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value); GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value); GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60; GPS.config.gapThresholdSec = parseFloat(container.querySelector("#s-gap-threshold").value) || 30; GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6; GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225; GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25; GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5; GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12; GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6; GPS.config.roadColorBike = container.querySelector("#s-road-color-bike").value; GPS.config.roadColorWalk = container.querySelector("#s-road-color-walk").value; GPS.config.roadColorRun = container.querySelector("#s-road-color-run").value; let roadsThickEl = container.querySelector("#s-roads-thick"); if (roadsThickEl) localStorage.setItem("baldav_roads_thick", roadsThickEl.checked ? "1" : "0"); let trackColorEl = container.querySelector("#s-track-color"); if (trackColorEl) GPS.config.trackColor = trackColorEl.value; let fusedEl = container.querySelector("#s-use-fused"); if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked; let gpxzEl = container.querySelector("#s-gpxz-key"); if (gpxzEl) GPS.config.gpxzKey = gpxzEl.value.trim(); GPS.saveConfig(); }; let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed", "s-track-color", "s-road-color-bike", "s-road-color-walk", "s-road-color-run", "s-gpxz-key"]; for (let id of gpsInputs) { let el = container.querySelector("#" + id); if (el) { el.onchange = readGpsConfig; el.oninput = readGpsConfig; } } let fusedEl = container.querySelector("#s-use-fused"); if (fusedEl) fusedEl.onchange = () => { readGpsConfig(); showToast("Настройки GPS сохранены"); }; let gpsSaveBtn = container.querySelector("#s-gps-save"); if (gpsSaveBtn) gpsSaveBtn.onclick = () => { readGpsConfig(); showToast("Настройки GPS сохранены"); }; // HR/BP norms — save on change for (let [id, key] of [["s-hr-rest-min","hr_rest_min"],["s-hr-rest-max","hr_rest_max"],["s-hr-max","hr_max"],["s-bp-sys","bp_sys_norm"],["s-bp-dia","bp_dia_norm"]]) { let el = container.querySelector("#" + id); if (el) el.onchange = async () => { await DB.setProfileValue(key, el.value); state.profile[key] = el.value; }; } // Bikes list async function loadBikesList() { let bikes = await DB.getBikes(); let listEl = container.querySelector("#s-bikes-list"); if (!listEl) return; listEl.innerHTML = bikes.map(b => `
    ${esc(b.name)}${b.is_active ? ' [активный]' : ''}
    ${b.weight_kg ? b.weight_kg + ' кг' : ''}${b.surface_type ? ' / ' + ({asphalt:'Асфальт',mixed:'Смешанный',gravel:'Грунт'}[b.surface_type] || b.surface_type) : ''}
    ›
    `).join(""); listEl.querySelectorAll("[data-bike-id]").forEach(item => { item.onclick = () => showBikeModal(parseInt(item.dataset.bikeId)); }); } loadBikesList(); container.querySelector("#s-add-bike").onclick = () => showBikeModal(null); // GPS permission check — works on both desktop and mobile let gpsStatus = container.querySelector("#s-gps-status"); let gpsToggle = container.querySelector("#s-gps-toggle"); // Check GPS availability if (!navigator.geolocation) { gpsStatus.textContent = "Недоступно"; } else { // Try to actually get position to check real permission gpsStatus.textContent = "Проверка..."; navigator.geolocation.getCurrentPosition( () => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); }, (err) => { if (err.code === 1) gpsStatus.textContent = "Нажмите для запроса"; else gpsStatus.textContent = "Ошибка: " + err.message; }, { enableHighAccuracy: false, timeout: 3000, maximumAge: 60000 } ); } gpsToggle.onclick = () => { gpsStatus.textContent = "Запрашиваю..."; navigator.geolocation.getCurrentPosition( () => { gpsStatus.textContent = "Разрешено"; gpsToggle.classList.add("on"); }, (err) => { gpsStatus.textContent = err.code === 1 ? "Запрещено" : "Ошибка: " + err.message; gpsToggle.classList.remove("on"); }, { enableHighAccuracy: true, timeout: 10000 } ); }; let bgLocBtn = container.querySelector("#s-bgloc-btn"); if (bgLocBtn) bgLocBtn.onclick = async () => { await GPS.requestBackgroundLocation(); showToast("Настройки → Разрешения → Геоданные → «Разрешать всегда»"); }; // BLE HR device scan and bind let bleUnbindBtn = container.querySelector("#s-ble-unbind"); if (bleUnbindBtn) { bleUnbindBtn.onclick = async () => { await DB.setProfileValue("ble_device_addr", ""); await DB.setProfileValue("ble_device_type", ""); await DB.setProfileValue("ble_device_name", ""); state.profile.ble_device_addr = ""; state.profile.ble_device_type = ""; state.profile.ble_device_name = ""; try { await window.__TAURI__.core.invoke("ble_save_device", { address: "", deviceType: "" }); } catch {} try { await window.__TAURI__.core.invoke("ble_disconnect"); } catch {} container.querySelector("#s-ble-name").textContent = "Не привязан"; container.querySelector("#s-ble-status").textContent = "Нажмите для поиска"; bleUnbindBtn.remove(); }; } // Manual connect to the already-paired band. Works any time, including during a ride. // Nothing auto-connects anymore (battery), so this is how HR starts flowing into the track. let bleConnectBtn = container.querySelector("#s-ble-connect"); if (bleConnectBtn) bleConnectBtn.onclick = async () => { let addr = state.profile.ble_device_addr, type = state.profile.ble_device_type || "hr_standard"; if (!addr) return; let statusEl = container.querySelector("#s-ble-status"); let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {} if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth, затем нажмите «Подключить»"); return; } if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; } bleConnectBtn.disabled = true; bleConnectBtn.textContent = "..."; if (statusEl) statusEl.textContent = "Подключение..."; try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); // If a ride is recording, make sure the HR poll runs so HR starts landing in the track. if (GPS.recording.active && !GPS.recording.hrPollInterval) { try { await GPS._startHrMonitor(); } catch {} } // Poll briefly (Kotlin scans+connects in the background) for a connection or first reading. let ok = false; for (let i = 0; i < 6; i++) { await new Promise(r => setTimeout(r, 1000)); try { if (await window.__TAURI__.core.invoke("ble_is_connected")) { ok = true; break; } let rd = await window.__TAURI__.core.invoke("ble_get_hr"); if (rd && rd.hr > 0) { ok = true; break; } } catch {} } if (statusEl) statusEl.textContent = ok ? addr + " (подключён)" : "браслет не найден"; showToast(ok ? "Пульсометр подключён" : "Браслет не найден - включите его рядом"); } catch (e) { if (statusEl) statusEl.textContent = "ошибка подключения"; showToast("Ошибка подключения: " + (e.message || e)); } bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить"; }; let dontAskBt = container.querySelector("#s-dontask-bt"); if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} }; let dontAskGps = container.querySelector("#s-dontask-gps"); if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} }; // Strap usage readout: total HR-delivering time since last "charged" reset + battery steps, // so we can judge real battery life against the claimed 300h (the % reading is coarse/laggy). let strapDesc = container.querySelector("#s-strap-usage-desc"); let fillStrapUsage = async () => { if (!strapDesc) return; try { let raw = await window.__TAURI__.core.invoke("ble_read_strap_uptime"); let u = JSON.parse(raw || "{}"); let sec = Math.round(u.active_sec || 0); let h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); let bat = (u.bat != null) ? `${u.bat}%` : "?"; let steps = Array.isArray(u.steps) ? u.steps : []; let since = u.since ? new Date(u.since * 1000).toLocaleDateString("ru-RU") : "—"; let stepStr = steps.length ? " · шаги: " + steps.map(s => `${s.bat}%`).join("->") : " · шагов заряда нет (ещё 100%?)"; strapDesc.textContent = `заряд ${bat} · в работе ${h}ч ${m}м с ${since}${stepStr}`; } catch (e) { strapDesc.textContent = "нет данных"; } }; fillStrapUsage(); let strapReset = container.querySelector("#s-strap-reset"); if (strapReset) strapReset.onclick = async () => { if (!(await appConfirm("Сбросить счётчик наработки ремня (отметить как заряженный)?", "Сбросить"))) return; try { await window.__TAURI__.core.invoke("ble_reset_strap_uptime"); } catch {} fillStrapUsage(); }; let bleScanBtn = container.querySelector("#s-ble-scan"); let bleResults = container.querySelector("#s-ble-results"); if (bleScanBtn) { bleScanBtn.onclick = async () => { bleScanBtn.textContent = "Поиск..."; bleScanBtn.disabled = true; bleResults.innerHTML = `
    Сканирование 5 сек...
    `; try { // Trigger scan (Android returns empty immediately, Kotlin scans in background) await window.__TAURI__.core.invoke("ble_scan", { durationSecs: 5 }); // Wait for Kotlin scan to complete, then read results await new Promise(r => setTimeout(r, 6000)); let devices = await window.__TAURI__.core.invoke("ble_scan_results"); if (!devices || !devices.length) { bleResults.innerHTML = `
    Устройства не найдены
    `; } else { bleResults.innerHTML = devices.map(d => `
    ${esc(d.name)}
    ${d.address} (${d.device_type}) ${d.rssi}dBm
    ›
    ` ).join(""); bleResults.querySelectorAll("[data-ble-addr]").forEach(item => { item.onclick = async () => { let addr = item.dataset.bleAddr; let type = item.dataset.bleType; let name = item.dataset.bleName; item.querySelector(".settings-item-desc").textContent = "Подключение..."; try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type }); await DB.setProfileValue("ble_device_addr", addr); await DB.setProfileValue("ble_device_type", type); await DB.setProfileValue("ble_device_name", name); // Write file for Kotlin to read at GPS start try { await window.__TAURI__.core.invoke("ble_save_device", { address: addr, deviceType: type }); } catch {} state.profile.ble_device_addr = addr; state.profile.ble_device_type = type; state.profile.ble_device_name = name; container.querySelector("#s-ble-name").textContent = name; container.querySelector("#s-ble-status").textContent = addr + " (подключён)"; bleResults.innerHTML = ""; } catch (e) { item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e; } }; }); } } catch (e) { bleResults.innerHTML = `
    ${e}
    `; } bleScanBtn.textContent = "Поиск"; bleScanBtn.disabled = false; }; } // Sync handlers let syncNowBtn = container.querySelector("#s-sync-now"); if (syncNowBtn) { syncNowBtn.onclick = async () => { if (!Sync.userId) { // Show setup container.querySelector("#s-sync-setup").style.display = ""; return; } syncNowBtn.textContent = "..."; syncNowBtn.disabled = true; let r = await Sync.now(); container.querySelector("#s-sync-desc").textContent = `Отправлено: ${r.pushed}, получено: ${r.pulled}`; syncNowBtn.textContent = "Синхр."; syncNowBtn.disabled = false; }; } container.querySelector("#s-sync-register")?.addEventListener("click", async () => { try { let code = await Sync.register(); container.querySelector("#s-sync-status").textContent = "Подключено"; container.querySelector("#s-sync-setup").style.display = "none"; container.querySelector("#s-sync-info").style.display = ""; container.querySelector("#s-sync-code-display").style.display = ""; container.querySelector("#s-sync-code-display").textContent = code; syncNowBtn.textContent = "Синхр."; showToast("Аккаунт создан. Код: " + code); } catch (e) { showToast("Ошибка: " + e.message); } }); container.querySelector("#s-sync-pair")?.addEventListener("click", async () => { let code = container.querySelector("#s-sync-code").value.trim().toUpperCase(); if (code.length !== 6) { showToast("Введите 6-символьный код"); return; } try { await Sync.pair(code); container.querySelector("#s-sync-status").textContent = "Подключено"; container.querySelector("#s-sync-setup").style.display = "none"; container.querySelector("#s-sync-info").style.display = ""; syncNowBtn.textContent = "Синхр."; showToast("Устройство привязано!"); await Sync.now(); } catch (e) { showToast("Ошибка: " + e.message); } }); container.querySelector("#s-sync-show-code")?.addEventListener("click", async () => { try { let code = await Sync.newCode(); let el = container.querySelector("#s-sync-code-display"); el.style.display = ""; el.textContent = code; } catch (e) { showToast("Ошибка: " + e.message); } }); // App version info + update check try { let info = await window.__TAURI__.core.invoke("get_app_info"); container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`; } catch { container.querySelector("#s-version").textContent = "dev"; } // Check for updates: trigger Kotlin re-check, poll until result changes let updateArea = container.querySelector("#s-update-area"); if (updateArea) { updateArea.innerHTML = `проверка...`; let oldTs = 0; try { let old = JSON.parse(await window.__TAURI__.core.invoke("get_update_info")); oldTs = old.ts || 0; } catch {} try { await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_update" }); } catch {} // Poll until Kotlin writes new result (ts changes) for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 200)); try { let raw = await window.__TAURI__.core.invoke("get_update_info"); let info = JSON.parse(raw); if (info.ts && info.ts !== oldTs) { await checkForUpdate(); break; } } catch {} } if (state.updateAvailable) { showUpdateButton(updateArea); } else { updateArea.innerHTML = ""; } } } function showUpdateButton(container) { container.innerHTML = ``; container.querySelector("#s-update-btn").onclick = () => startUpdate(); } function showProfileModal() { let p = state.profile; let selectedSex = p.sex ?? "m"; showModal(`

    Профиль

    ...
    Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.
    Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.
    Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.
    Во время записи появится кнопка «СМС»: по нажатию спросит подтверждение и отправит на этот номер текст с координатами и ссылкой на Яндекс.Карты. Номер можно вписать вручную или выбрать из контактов. Работает без интернета (по сотовой сети).
    `, async overlay => { let weight = parseFloat(overlay.querySelector("#m-weight").value) || null; let height = parseInt(overlay.querySelector("#m-height").value); let year = parseInt(overlay.querySelector("#m-year").value); if (isNaN(height) || isNaN(year)) return false; if (weight) await DB.setProfileValue("weight_kg", weight); await DB.setProfileValue("height_cm", height); await DB.setProfileValue("birth_year", year); await DB.setProfileValue("sex", selectedSex); let activityLevel = overlay.querySelector("#m-activity-level").value; await DB.setProfileValue("activity_level", activityLevel); let tefEl = overlay.querySelector("#m-tef"); if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); } let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000; await DB.setProfileValue("daily_water_ml", waterNorm); let hydrOn = !!overlay.querySelector("#m-hydr-on")?.checked; let hydrAlert = Math.min(3000, Math.max(100, parseInt(overlay.querySelector("#m-hydr-alert")?.value) || 600)); localStorage.setItem("baldav_hydr_on", hydrOn ? "1" : "0"); localStorage.setItem("baldav_hydr_alert", String(hydrAlert)); try { await window.__TAURI__.core.invoke("set_hydration_config", { enabled: hydrOn, alertMl: hydrAlert, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 }); } catch (e) { console.error("hydration cfg", e); } let liveOn = !!overlay.querySelector("#m-live-on")?.checked; let liveUrl = (overlay.querySelector("#m-live-url")?.value || "").trim(); let liveToken = (overlay.querySelector("#m-live-token")?.value || "").trim(); localStorage.setItem("baldav_live_on", liveOn ? "1" : "0"); localStorage.setItem("baldav_live_url", liveUrl); localStorage.setItem("baldav_live_token", liveToken); try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url: liveUrl, token: liveToken }); } catch (e) { console.error("live cfg", e); } let smsNumber = (overlay.querySelector("#m-sms-number")?.value || "").trim(); localStorage.setItem("baldav_sms_number", smsNumber); state.profile = { ...state.profile, weight_kg: weight ? weight : state.profile.weight_kg, height_cm: String(height), birth_year: String(year), sex: selectedSex, activity_level: activityLevel }; }); // Initialize modal controls setTimeout(() => { let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); if (!overlay) return; // Sex chips overlay.querySelectorAll("[data-sex]").forEach(btn => { btn.onclick = () => { selectedSex = btn.dataset.sex; overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected")); btn.classList.add("selected"); updateBmrDisplay(); }; }); // Activity level dropdown let actSelect = overlay.querySelector("#m-activity-level"); if (actSelect) actSelect.value = p.activity_level || "1.2"; // BMR display function updateBmrDisplay() { let w = parseFloat(overlay.querySelector("#m-weight").value) || parseFloat(p.weight_kg) || 80; let h = parseInt(overlay.querySelector("#m-height").value) || parseInt(p.height_cm) || 178; let y = parseInt(overlay.querySelector("#m-year").value) || parseInt(p.birth_year) || 1988; let bmrVal = BMR.calculateBmr(w, h, BMR.getAge(y), selectedSex); let el = overlay.querySelector("#m-bmr-display"); if (el) el.textContent = bmrVal + " ккал/день"; } updateBmrDisplay(); // Recalculate BMR when weight/height/year change for (let id of ["#m-weight", "#m-height", "#m-year"]) { let el = overlay.querySelector(id); if (el) el.oninput = updateBmrDisplay; } // Pick a phone number from the system contacts (fills the SMS number field) let pickBtn = overlay.querySelector("#m-sms-pick"); if (pickBtn) pickBtn.onclick = async () => { let field = overlay.querySelector("#m-sms-number"); pickBtn.disabled = true; try { await window.__TAURI__.core.invoke("pick_contact"); // The picker is an Activity; poll for its result for a few seconds. let got = null; for (let i = 0; i < 40 && !got; i++) { await new Promise(r => setTimeout(r, 300)); let raw = await window.__TAURI__.core.invoke("get_contact_result"); if (raw) got = JSON.parse(raw); } if (got && got.number) { field.value = got.number; } } catch (e) { appAlert("Не удалось выбрать контакт: " + e); } finally { pickBtn.disabled = false; } }; }, 100); } // --------------------------------------------------------------------------- // Bike profile modal // --------------------------------------------------------------------------- async function showBikeModal(bikeId) { let isNew = bikeId == null; let bike = isNew ? {} : await DB.getBike(bikeId); if (!isNew && !bike) return; let surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" }; let surfaceVal = bike.surface_type ?? "mixed"; showModal(`

    ${isNew ? "Новый велосипед" : "Редактирование"}

    ${!isNew ? `
    Обслуживание
    ` : ""} ${!isNew ? `` : ""}`, async overlay => { let name = overlay.querySelector("#m-bike-name").value.trim(); if (!name) return false; let data = { name, weight_kg: parseFloat(overlay.querySelector("#m-bike-weight").value) || null, wheel_diameter_inch: parseFloat(overlay.querySelector("#m-bike-wheel").value) || 26, chainring_teeth: parseInt(overlay.querySelector("#m-bike-chainring").value) || null, cog_teeth: parseInt(overlay.querySelector("#m-bike-cog").value) || null, surface_type: overlay.querySelector("#m-bike-surface").value, tire_pressure_bar: parseFloat(overlay.querySelector("#m-bike-pressure").value) || null, is_active: overlay.querySelector("#m-bike-active-yes").classList.contains("selected"), }; if (isNew) { await DB.saveBike(data); } else { await DB.updateBike(bikeId, data); } // Refresh the settings screen to show updated list renderScreen("settings"); }); // Active toggle chips setTimeout(() => { let overlay = [...document.querySelectorAll(".modal-overlay")].pop(); if (!overlay) return; overlay.querySelectorAll("#m-bike-active-yes, #m-bike-active-no").forEach(btn => { btn.onclick = (e) => { e.stopPropagation(); overlay.querySelector("#m-bike-active-yes").classList.toggle("selected", btn.dataset.active === "1"); overlay.querySelector("#m-bike-active-no").classList.toggle("selected", btn.dataset.active === "0"); }; }); }, 50); if (!isNew) { setTimeout(() => { let delBtn = document.querySelector("#m-del-bike"); if (delBtn) { delBtn.onclick = async (e) => { e.stopPropagation(); await DB.deleteBike(bikeId); if (winStack.length > 0) winStack.pop().close(); _skipPops = 1; history.back(); renderScreen("settings"); }; } }, 100); // Bike maintenance section setTimeout(async () => { let mileageEl = document.querySelector("#m-bike-mileage"); let listEl = document.querySelector("#m-bike-maint-list"); let addBtn = document.querySelector("#m-bike-maint-add"); let formEl = document.querySelector("#m-bike-maint-form"); if (!listEl) return; let totalKm = await DB.getBikeTotalMileage(bikeId); if (mileageEl) mileageEl.textContent = `Общий пробег: ${totalKm} км`; async function renderMaintList() { let entries = await DB.getBikeMaintenanceLog(bikeId); if (entries.length === 0) { listEl.innerHTML = '
    Нет записей
    '; return; } listEl.innerHTML = entries.map(e => { let overdue = e.next_service_km != null && totalKm >= e.next_service_km; let nextInfo = e.next_service_km != null ? `, след. при ${e.next_service_km} км` : ""; let dateShort = e.date.slice(5).replace("-", "."); return `
    ${dateShort} ${esc(e.description)} ${e.mileage_km != null ? `(${e.mileage_km} км${nextInfo})` : ""} ${overdue ? '[!]' : ""}
    `; }).join(""); listEl.querySelectorAll(".maint-del").forEach(btn => { btn.onclick = async (ev) => { ev.stopPropagation(); await DB.deleteBikeMaintenance(parseInt(btn.dataset.mid)); await renderMaintList(); }; }); } await renderMaintList(); if (addBtn) { addBtn.onclick = (e) => { e.stopPropagation(); formEl.style.display = formEl.style.display === "none" ? "block" : "none"; }; } let saveBtn = document.querySelector("#m-maint-save"); if (saveBtn) { saveBtn.onclick = async (e) => { e.stopPropagation(); let date = document.querySelector("#m-maint-date").value; let desc = document.querySelector("#m-maint-desc").value.trim(); if (!desc) return; let nextKm = parseFloat(document.querySelector("#m-maint-next").value) || null; let notes = document.querySelector("#m-maint-notes").value.trim() || null; // next_service_km = current mileage + interval let nextServiceKm = nextKm != null ? Math.round((totalKm + nextKm) * 10) / 10 : null; await DB.addBikeMaintenance(bikeId, date, desc, totalKm, nextServiceKm, notes); document.querySelector("#m-maint-desc").value = ""; document.querySelector("#m-maint-next").value = ""; document.querySelector("#m-maint-notes").value = ""; formEl.style.display = "none"; await renderMaintList(); }; } }, 150); } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Nutrient labels for display — maps DB column names to short UI labels let NUTRIENT_LABELS = { vit_a_mcg: "A", vit_c_mg: "C", vit_e_mg: "E", vit_b1_mg: "B1", vit_b2_mg: "B2", vit_b3_mg: "B3", vit_b5_mg: "B5", vit_b6_mg: "B6", vit_b7_mcg: "B7", folate_mcg: "B9", vit_b12_mcg: "B12", vit_d_mcg: "D", vit_k_mcg: "K", calcium_mg: "Ca", magnesium_mg: "Mg", zinc_mg: "Zn", iron_mg: "Fe", iodine_mg: "I", selenium_mcg: "Se", molybdenum_mcg: "Mo", manganese_mg: "Mn", copper_mg: "Cu", chromium_mg: "Cr", phosphorus_mg: "P", potassium_mg: "K+", sodium_mg: "Na", omega3_mg: "O-3", epa_mg: "EPA", dha_mg: "DHA", lutein_mg: "Lut", lycopene_mg: "Lik", silicon_mg: "Si", vanadium_mg: "V", ginseng_mg: "Ginseng", resveratrol_mg: "Resv", coq10_mg: "CoQ10", // carotene_mcg not shown separately — auto-converted to vit A }; // Build supplement micronutrient strip (existing horizontal chips on Today screen) function buildMicroStrip(totals) { let RDI = BMR.RDI; let items = []; for (let [key, rdi] of Object.entries(RDI)) { let val = totals[key]; if (val == null || val === 0) continue; let pct = Math.round((val / rdi) * 100); let cls = pct >= 100 ? "micro-ok" : pct >= 50 ? "micro-mid" : "micro-low"; items.push(`${NUTRIENT_LABELS[key] || key} ${pct}%`); } if (items.length === 0) return ""; return `
    ${items.join("")}
    `; } // Per-food-item nutrient line: shows non-zero nutrients as %RDI chips function foodNutrientLine(f) { if (!f.product_id) return ""; // manual entry or recipe — no per-product data let RDI = BMR.RDI; let factor = (f.amount_g || 100) / 100; let items = []; let check = (val, rdi, label) => { if (!val || !rdi) return; let pct = Math.round(val * factor / rdi * 100); if (pct >= 1) items.push(`${label} ${pct}%`); }; check(f.vit_c_mg, RDI.vit_c_mg, "C"); // Vitamin A = retinol + carotene/12 let vitA = (f.vit_a_mcg || 0) + (f.carotene_mcg || 0) / 12; check(vitA, RDI.vit_a_mcg, "A"); check(f.vit_b1_mg, RDI.vit_b1_mg, "B1"); check(f.vit_b2_mg, RDI.vit_b2_mg, "B2"); check(f.vit_b3_mg, RDI.vit_b3_mg, "B3"); check(f.vit_b6_mg, RDI.vit_b6_mg, "B6"); check(f.vit_b12_mcg, RDI.vit_b12_mcg, "B12"); check(f.folate_mcg, RDI.folate_mcg, "B9"); check(f.vit_d_mcg, RDI.vit_d_mcg, "D"); check(f.vit_e_mg, RDI.vit_e_mg, "E"); check(f.vit_k_mcg, RDI.vit_k_mcg, "K"); check(f.calcium_mg, RDI.calcium_mg, "Ca"); check(f.iron_mg, RDI.iron_mg, "Fe"); check(f.magnesium_mg, RDI.magnesium_mg, "Mg"); check(f.potassium_mg, RDI.potassium_mg, "K+"); check(f.zinc_mg, RDI.zinc_mg, "Zn"); check(f.copper_mg, RDI.copper_mg, "Cu"); check(f.selenium_mcg, RDI.selenium_mcg, "Se"); check(f.sodium_mg, RDI.sodium_mg, "Na"); if (items.length === 0) return ""; return `
    ${items.join(", ")}
    `; } // Calculate daily nutrient totals from food (product micronutrients) + supplements // Returns array sorted by %RDI ascending (most deficient first) function calcDailyNutrients(food, suppTotals) { let RDI = BMR.RDI; let totals = {}; // From food: product micronutrients * (amount_g / 100) for (let f of food) { if (!f.product_id) continue; let factor = (f.amount_g || 100) / 100; for (let key of Object.keys(RDI)) { if (f[key]) { totals[key] = (totals[key] || 0) + f[key] * factor; } } // Convert beta-carotene → vitamin A (12 mcg carotene = 1 mcg retinol equivalent) if (f.carotene_mcg) { totals.vit_a_mcg = (totals.vit_a_mcg || 0) + (f.carotene_mcg * factor) / 12; } } // From recipes (cached_nutrients JSON in recipes table) for (let f of food) { if (!f.recipe_id || !f.cached_nutrients) continue; try { let rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients; for (let key of Object.keys(RDI)) { if (rn[key]) totals[key] = (totals[key] || 0) + rn[key]; } } catch {} } // From supplements if (suppTotals) { for (let key of Object.keys(RDI)) { if (suppTotals[key]) { totals[key] = (totals[key] || 0) + suppTotals[key]; } } // Convert supplement beta-carotene → vitamin A if (suppTotals.carotene_mcg) { totals.vit_a_mcg = (totals.vit_a_mcg || 0) + suppTotals.carotene_mcg / 12; } } // Build array sorted by %RDI ascending let result = []; for (let [key, rdi] of Object.entries(RDI)) { let amount = totals[key] || 0; let pct = Math.round(amount / rdi * 100); let label = NUTRIENT_LABELS[key] || key; let normType = BMR.RDI_TYPES[key] || "rdi"; result.push({ key, label, amount, rdi, pct, normType }); } result.sort((a, b) => a.pct - b.pct); return result; } // Calculate today's reserves = (yesterday_reserves + yesterday_intake) * decay // decay = 0.5^(1/halflife_days) per nutrient async function calcReservesForDate(date) { let HL = BMR.HALFLIFE; let RDI = BMR.RDI; if (!HL || !Object.keys(HL).length) return null; let yesterday = shiftDate(date, -1); let prevReserves = await DB.getReserves(yesterday); // Get yesterday's intake (food + supplements) let food = await DB.getFoodForDate(yesterday); let suppTotals = await DB.getSupplementTotalsForDate(yesterday); let intake = calcDailyNutrientTotals(food, suppTotals); // Apply decay: reserves = (prev_reserves + intake) * 0.5^(1/hl) let reserves = {}; for (let key of Object.keys(RDI)) { let hl = HL[key]; if (!hl || hl <= 0) continue; let prev = (prevReserves[key] || 0) + (intake[key] || 0); if (prev <= 0) continue; let decayed = prev * Math.pow(0.5, 1 / hl); if (decayed >= 0.001) reserves[key] = Math.round(decayed * 1000) / 1000; } return reserves; } // Raw totals (no RDI %, just amounts) — reused by reserves calc function calcDailyNutrientTotals(food, suppTotals) { let RDI = BMR.RDI; let totals = {}; for (let f of food) { if (f.product_id) { let factor = (f.amount_g || 100) / 100; for (let key of Object.keys(RDI)) { if (f[key]) totals[key] = (totals[key] || 0) + f[key] * factor; } if (f.carotene_mcg) totals.vit_a_mcg = (totals.vit_a_mcg || 0) + (f.carotene_mcg * factor) / 12; } if (f.recipe_id && f.cached_nutrients) { try { let rn = typeof f.cached_nutrients === "string" ? JSON.parse(f.cached_nutrients) : f.cached_nutrients; for (let key of Object.keys(RDI)) { if (rn[key]) totals[key] = (totals[key] || 0) + rn[key]; } } catch {} } } if (suppTotals) { for (let key of Object.keys(RDI)) { if (suppTotals[key]) totals[key] = (totals[key] || 0) + suppTotals[key]; } if (suppTotals.carotene_mcg) totals.vit_a_mcg = (totals.vit_a_mcg || 0) + suppTotals.carotene_mcg / 12; } return totals; } // One-time migration: fill nutrient_reserves for all historical days async function migrateReserves() { let existing = await DB.getAllReservesDates(); if (existing.length > 0) return; // already migrated let days = await DB.getDaysWithFood(); if (!days.length) return; console.log(`[Reserves] migrating ${days.length} days...`); for (let row of days) { let reserves = await calcReservesForDate(row.date); if (reserves && Object.keys(reserves).length > 0) { await DB.saveReserves(row.date, reserves); } } console.log("[Reserves] migration done"); } // Render nutrient chips from an array of {label, pct} objects function renderNutrientChips(nutrients, title) { if (!nutrients || nutrients.length === 0) return ""; let header = title ? `
    ${title}
    ` : ""; return header + nutrients.map(n => { let cls = n.pct < 30 ? "nut-low" : n.pct < 80 ? "nut-mid" : "nut-ok"; let star = n.normType === "aup" ? "*" : ""; return `
    ${n.label}${star} ${n.pct}%
    `; }).join(""); } // --------------------------------------------------------------------------- // Reusable product search dropdown component // parentEl: element to insert into (must have position:relative or will be set) // onSelect(product): called when user picks a product from results // opts.placeholder, opts.onClear (called when input cleared after selection) // Returns { el, focus() } // --------------------------------------------------------------------------- function createProductSearch(parentEl, onSelect, opts) { opts = opts || {}; parentEl.style.position = "relative"; let wrap = document.createElement("div"); wrap.innerHTML = ` `; parentEl.appendChild(wrap); let input = wrap.querySelector("input"); let dropdown = wrap.querySelector(".search-dropdown"); let debounce = null; input.oninput = () => { clearTimeout(debounce); debounce = setTimeout(async () => { let q = input.value.trim(); if (q.length < 2) { dropdown.style.display = "none"; dropdown.innerHTML = ""; if (opts.onClear) opts.onClear(); return; } let results = await DB.searchProducts(q); dropdown.style.display = ""; dropdown.innerHTML = results.map(p => `
    ${esc(p.name)} ${sourceBadge(p.source)} ${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}
    ` ).join("") || `
    Не найдено
    `; dropdown.querySelectorAll(".sd-item").forEach(item => { item.onclick = async () => { let pid = parseInt(item.dataset.pid); let product = results.find(r => r.id === pid) || await DB.getProduct(pid); dropdown.style.display = "none"; dropdown.innerHTML = ""; input.value = product.name; onSelect(product); }; }); }, 300); }; return { el: wrap, input, focus() { input.focus(); } }; } function calcBmr(recentWeights) { let p = state.profile; let height = parseInt(p.height_cm); let birthYear = parseInt(p.birth_year); if (!height || !birthYear) return null; // Moving average weight over recent days (smooths daily fluctuations) let kg; if (recentWeights && recentWeights.length >= 2) { kg = recentWeights.reduce((s, w) => s + parseFloat(w.weight_kg), 0) / recentWeights.length; } else if (recentWeights && recentWeights.length === 1) { kg = parseFloat(recentWeights[0].weight_kg); } else { kg = parseFloat(state.latestWeight?.weight_kg ?? p.weight_kg); } if (!kg) return null; return BMR.calculateBmr(kg, height, BMR.getAge(birthYear), p.sex ?? "m"); } function hrZoneClass(hr) { let p = state.profile; let age = BMR.getAge(parseInt(p.birth_year) || 1988); let maxHr = parseInt(p.hr_max) || (220 - age); let restMax = parseInt(p.hr_rest_max) || 100; if (hr <= restMax) return "hr-zone-green"; if (hr <= 140) return "hr-zone-yellow"; if (hr <= maxHr * 0.85) return "hr-zone-orange"; return "hr-zone-red"; } function hrZoneColor(hr) { let cls = hrZoneClass(hr); if (cls === "hr-zone-green") return "#81c784"; if (cls === "hr-zone-yellow") return "#ffb74d"; if (cls === "hr-zone-orange") return "#ff8a65"; return "#ef5350"; } function balanceClass(balance) { if (balance == null) return ""; return balance > 100 ? "surplus" : balance < -100 ? "deficit" : "balanced"; } function formatSleepInfo(s) { let lines = []; if (s.asleep_time && s.wake_time) { let a = toMin(s.asleep_time), w = toMin(s.wake_time); let h = ((a >= 720 ? 1440 - a + w : w - a) / 60).toFixed(1); lines.push(`Сон: ${h} ч (${s.asleep_time} – ${s.wake_time})`); } if (s.nap_minutes) lines.push(`Дневной: ${s.nap_minutes} мин`); if (s.notes) lines.push(esc(s.notes)); return lines.map(l => `${l}`).join("") || `нет данных`; } function toMin(t) { let [h, m] = (t || "0:0").split(":").map(Number); return h * 60 + m; } function todayIso() { return new Date().toISOString().slice(0, 10); } function nowTime() { return new Date().toTimeString().slice(0, 5); } function shiftDate(iso, days) { let d = new Date(iso); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); } function formatDate(iso) { return new Date(iso + "T00:00:00") .toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" }); } // BRouter road-segment (.rd5) download manager. Tiles for offline routing are pulled // from the mirror (LAN -> WAN -> brouter.de) by BRouter.downloadTileAndWait. async function showBrouterSegments() { let region = [ { name: "E35_N45", label: "E35_N45 — запад (35–40°E, 45–50°N)" }, { name: "E40_N45", label: "E40_N45 — восток (40–45°E)" }, ]; let haveSet = new Set(); try { (await BRouter.listTiles()).forEach(t => haveSet.add(t.name)); } catch (e) {} let rows = region.map(t => { let dl = haveSet.has(t.name); return `
    ${t.label} ${dl ? "✓ есть" : ""}
    `; }).join(""); showModal(`

    Сегменты дорог (BRouter)

    Дорожные тайлы для оффлайн-роутинга. Качаются с зеркала (LAN→WAN→brouter.de), ~14–24 МБ каждый. Нужны один раз.
    ${rows}`); document.querySelectorAll(".bseg-dl").forEach(btn => { btn.onclick = async () => { let tile = btn.dataset.tile, row = btn.closest(".bseg-row"), st = row.querySelector(".bseg-status"); btn.disabled = true; try { await BRouter.downloadTileAndWait(tile, (b, tot) => { st.textContent = tot ? Math.round(100 * b / tot) + "%" : (Math.round(b / 1024 / 1024) + " МБ"); }); st.textContent = "✓ есть"; btn.textContent = "Обновить"; } catch (e) { st.textContent = "ошибка"; showToast("Сегмент: " + (e.message || e)); } btn.disabled = false; }; }); } async function showRoadsMap() { let container = document.querySelector(".screen-content"); container.innerHTML = `
    Дороги
    `; document.querySelector("#roads-back").onclick = () => renderScreen("settings"); document.querySelector("#roads-segments").onclick = () => showBrouterSegments(); let mapEl = document.querySelector("#roads-map"); L.Icon.Default.imagePath = "lib/"; let map = L.map(mapEl, { preferCanvas: true, attributionControl: false }).setView([47.42, 40.08], 13); // canvas: many road polylines vs SVG compositor tile-memory limit addZoomDisplay(map); let CachedLayer = TileCache.createLayer(); let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map); TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable addDarkMapControl(map); // dark-tiles toggle on the roads map POI.attach(map); // POI overlay + category filter let infoEl = document.querySelector("#roads-info"); infoEl.textContent = "Загрузка..."; // Rebuild button — always visible let rebuildBtn = document.querySelector("#roads-rebuild"); async function doRebuild() { rebuildBtn.disabled = true; rebuildBtn.textContent = "Построение..."; setBtnProgress(rebuildBtn, 0); infoEl.textContent = "Построение (может занять минуту)..."; // Rust emits roads_progress [current,total] as it scans points → fill the button by %. let unlisten = null; try { try { unlisten = await window.__TAURI__.event.listen("roads_progress", (ev) => { let cur = ev.payload?.[0] ?? 0, tot = ev.payload?.[1] ?? 0; if (tot > 0) { setBtnProgress(rebuildBtn, cur / tot * 100); infoEl.textContent = `Построение: ${cur} / ${tot}`; } }); } catch {} let count = await DB.rebuildRoads(); if (unlisten) unlisten(); setBtnProgress(rebuildBtn, null); infoEl.textContent = `${count} точек`; rebuildBtn.textContent = "Перестроить"; rebuildBtn.disabled = false; showRoadsMap(); } catch (e) { if (unlisten) unlisten(); setBtnProgress(rebuildBtn, null); infoEl.textContent = "Ошибка: " + e; rebuildBtn.textContent = "Перестроить"; rebuildBtn.disabled = false; } } rebuildBtn.onclick = doRebuild; let pts; try { pts = await DB.getRoadPoints(); } catch (e) { // Table missing or schema mismatch — need rebuild infoEl.textContent = "Нужно построить дороги"; return; } if (pts.length === 0) { infoEl.textContent = "Нет дорог — нажмите Перестроить"; return; } // Split into segments (with bbox) for viewport culling; thickness follows pass count. let segments = buildRoadSegments(pts); // Roads in their own canvas pane so the dark-map CSS filter (body.dark-map .leaflet-roadsPane) // can invert/shift road colours like the tiles. Only segments in view are drawn, redrawn on move. if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; } let roadsRenderer = L.canvas({ pane: "roadsPane", padding: 0.2 }); let drawn = []; let drawExtra = { renderer: roadsRenderer, base: 4, opacity: 1, smoothFactor: 2.5 }; let redraw = () => cullRoadsInto(map, segments, drawn, 0.2, drawExtra); map.on("moveend zoomend", debounce(redraw, 120)); if (pts.length > 0) map.fitBounds(L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.05), { animate: false }); redraw(); let walkSegs = segments.filter(s => s.atype === "w").length; let bikeSegs = segments.length - walkSegs; infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`; } function addZoomDisplay(map) { let ctrl = L.control({ position: "bottomright" }); ctrl.onAdd = () => { let div = L.DomUtil.create("div", "zoom-display"); div.textContent = map.getZoom(); map.on("zoomend", () => { div.textContent = map.getZoom(); }); return div; }; ctrl.addTo(map); } // --------------------------------------------------------------------------- // Export/Import: recipes, supplements // --------------------------------------------------------------------------- async function downloadFile(filename, content) { // Use Tauri save_file (works on Android via SAF) try { if (window.__TAURI__) { await window.__TAURI__.core.invoke("save_file", { filename, content }); return; } } catch (e) { console.error("[download] Tauri save error:", e); } // Fallback for desktop browsers let blob = new Blob([content], { type: "application/json" }); let a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); a.remove(); } async function exportAllRecipes() { let recipes = await DB.getRecipes(); let result = []; for (let r of recipes) { let ings = await DB.getRecipeIngredients(r.id); result.push({ name: r.name, cooking_method_id: r.cooking_method_id, cooking_method_name: r.cooking_method_name, servings: r.servings, notes: r.notes, ingredients: ings.map(i => ({ product_name: i.product_name, custom_name: i.custom_name, amount_g: i.amount_g, cooking_method_id: i.cooking_method_id, cooking_method_name: i.cooking_method_name, // Store product nutrients so recipient can create product if missing kcal_per_100g: i.kcal_per_100g, protein_per_100g: i.protein_per_100g, fat_per_100g: i.fat_per_100g, carbs_per_100g: i.carbs_per_100g, })), }); } return result; } async function importRecipes(data) { let arr = Array.isArray(data) ? data : [data]; for (let r of arr) { if (!r.name) continue; let rid = await DB.saveRecipe(r.name, r.cooking_method_id ?? null, r.servings ?? 1, r.notes ?? null); if (!rid) continue; for (let ing of (r.ingredients || [])) { // Try to find product by name let pid = 0; if (ing.product_name) { let found = await DB.searchProducts(ing.product_name, 1); if (found.length) { pid = found[0].id; } else if (ing.kcal_per_100g != null) { // Create product from exported data pid = await DB.addProduct({ name: ing.product_name, kcal_per_100g: ing.kcal_per_100g, protein_per_100g: ing.protein_per_100g, fat_per_100g: ing.fat_per_100g, carbs_per_100g: ing.carbs_per_100g, source: "recipe_import", }); } } await DB.addRecipeIngredient(rid, pid, ing.custom_name ?? null, ing.amount_g ?? 100, ing.cooking_method_id ?? null); } } } async function importSupplements(data) { let arr = Array.isArray(data) ? data : [data]; for (let s of arr) { if (!s.name) continue; // Remove id — will get new one let clean = { ...s }; delete clean.id; await DB.addSupplement(clean); } } function pickFile(accept, callback, asBinary) { console.log("[pickFile] accept:", accept, "asBinary:", asBinary); let input = document.createElement("input"); input.type = "file"; if (accept) input.accept = accept; input.style.display = "none"; document.body.appendChild(input); input.addEventListener("change", async () => { console.log("[pickFile] change event, files:", input.files.length); let file = input.files[0]; document.body.removeChild(input); if (!file) return; console.log("[pickFile] file:", file.name, file.size); try { if (asBinary) { let buf = await file.arrayBuffer(); await callback(buf, file); } else { let text = await file.text(); await callback(text, file); } } catch (e) { console.error("[pickFile] error:", e); } }); input.click(); } function sourceBadge(source) { let map = { magnit: ["Магнит", "src-magnit"], "5ka": ["5ка", "src-5ka"], kdv: ["KDV", "src-kdv"], ion: ["ИОН", "src-ion"], chzn: ["ЧЗ", "src-chzn"], manual: ["Свой", "src-manual"], recipe_import: ["Импорт", "src-manual"], }; let [label, cls] = map[source] || ["", ""]; if (!label) return ""; return `${label}`; } function showToast(msg, duration) { let el = document.createElement("div"); el.textContent = msg; el.style.cssText = "position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:99999;opacity:0;transition:opacity 0.3s"; document.body.appendChild(el); requestAnimationFrame(() => el.style.opacity = "1"); let hide = () => { el.style.opacity = "0"; setTimeout(() => el.remove(), 300); }; el.onclick = hide; setTimeout(hide, duration || 6000); } // Copy text to clipboard (navigator.clipboard with a hidden-textarea fallback for // WebViews where the async API is unavailable). Optional toast on success. function copyToClipboard(text, toastMsg) { let ok = () => { if (toastMsg) showToast(toastMsg); }; let fallback = () => { try { let ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand("copy"); ta.remove(); ok(); } catch (e) { showToast("Не удалось скопировать"); } }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(ok).catch(fallback); } else fallback(); } // Dark map: invert the tile layer only (overlays keep their colours). User-tuned filter // on OSM standard tiles — invert(1) hue-rotate(180deg) brightness(1.5). Toggled by the // user, remembered in localStorage, applied globally to every Leaflet tile pane. No // tile repacking: works the same on offline (tiles_*.db) and online tiles. function isDarkMap() { try { return localStorage.getItem("baldav_dark_map") === "1"; } catch (e) { return false; } } function applyDarkMap() { document.body.classList.toggle("dark-map", isDarkMap()); } function toggleDarkMap() { let on = !isDarkMap(); try { localStorage.setItem("baldav_dark_map", on ? "1" : "0"); } catch (e) {} document.body.classList.toggle("dark-map", on); document.querySelectorAll(".map-dark-btn, .rec-dark-toggle").forEach(b => b.classList.toggle("active", on)); return on; } // Leaflet control button (top-right) for maps without a custom button stack. function addDarkMapControl(map) { let Ctl = L.Control.extend({ options: { position: "topright" }, onAdd: function () { let b = L.DomUtil.create("button", "rec-map-btn map-dark-btn" + (isDarkMap() ? " active" : "")); b.textContent = "☾"; b.title = "Тёмная карта"; b.style.position = "static"; L.DomEvent.disableClickPropagation(b); b.onclick = () => toggleDarkMap(); return b; }, }); map.addControl(new Ctl()); } function esc(str) { return String(str ?? "") .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- // Single global popstate handler window.addEventListener("popstate", (e) => { // closeModal used history.go(-N) — skip N popstate events if (_skipPops > 0) { _skipPops--; return; } // winStack has entries — pop the top one (close modal/fullscreen) if (winPopTop()) return; // Stale entries from previous app session — skip if (e.state && (e.state.modal || e.state.chartFull || e.state.modalFs || e.state.win)) { history.back(); return; } // Navigate between screens via navStack if (navStack.length > 1) { navStack.pop(); persistNavStack(); renderScreen(navStack[navStack.length - 1], true); } else if (e.state && e.state.screen) { renderScreen(e.state.screen, true); } }); init().catch(err => { console.error(err); app.innerHTML = `
    Ошибка: ${esc(String(err))}
    `; });