- Zoom slider: many Android WebViews don't report a `zoom` track capability (torch works, zoom doesn't), so the slider never showed. Add a digital-zoom fallback (always-visible slider 1-5x): CSS-scale the preview + crop the decode ROI tighter to center. Hardware zoom still used when available. - Back gesture did nothing after scanning: onDecoded closed the scan modal with the async history.go(-1) and then synchronously opened the add-product modal, so history.go unwound the NEW modal's entry and desynced the winStack/history. Add closeThen(): await the close's popstate(s) before opening the next modal.
7601 lines
334 KiB
JavaScript
7601 lines
334 KiB
JavaScript
// 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() {
|
||
if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") {
|
||
app.innerHTML = `<div class="loading" style="color:#cf6679">
|
||
Ошибка: db.js не загрузился.<br>
|
||
window.DB = ${JSON.stringify(typeof window.DB)}<br>
|
||
__TAURI__ = ${JSON.stringify(typeof window.__TAURI__)}
|
||
</div>`;
|
||
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 = `<div class="seed-progress">
|
||
<div class="seed-label" id="boot-label">Загрузка...</div>
|
||
<div class="seed-bar"><div class="seed-bar-fill" id="seed-fill"></div></div>
|
||
<div class="seed-count" id="seed-count"></div>
|
||
</div>`;
|
||
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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; }
|
||
|
||
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,
|
||
};
|
||
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 = `<span class="rec-bar-dot"></span><span id="rec-bar-text">Запись...</span>`;
|
||
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);
|
||
}
|
||
|
||
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 = `<div class="loading">Загрузка...</div>`;
|
||
|
||
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 = `<div class="loading" style="color:#cf6679">Ошибка загрузки: ${esc(String(e))}</div>`;
|
||
return;
|
||
}
|
||
|
||
let recentWeightsForBmr = await DB.getRecentWeights(7);
|
||
let bmr = calcBmr(recentWeightsForBmr);
|
||
let actLevel = parseFloat(state.profile.activity_level) || 1.2;
|
||
let dailyBase = bmr ? Math.round(bmr * actLevel) : null;
|
||
let intake = Math.round(foodTotals.kcal ?? 0);
|
||
let actKcal = Math.round(actTotals.kcal ?? 0);
|
||
let expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null;
|
||
let balance = expend != null ? intake - expend : null;
|
||
container.innerHTML = `
|
||
<div class="day-header">
|
||
<button class="day-nav" id="prev-day">←</button>
|
||
<h2>${formatDate(state.date)}</h2>
|
||
<button class="day-nav" id="next-day">→</button>
|
||
</div>
|
||
|
||
<div class="summary-row">
|
||
<div class="summary-card">
|
||
<span class="summary-val">${intake}</span>
|
||
<span class="summary-lbl">ккал приход</span>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-val">${expend ?? "—"}</span>
|
||
<span class="summary-lbl">ккал расход</span>
|
||
</div>
|
||
<div class="summary-card ${balanceClass(balance)}">
|
||
<span class="summary-val">${balance != null ? (balance >= 0 ? "+" : "") + balance : "—"}</span>
|
||
<span class="summary-lbl">баланс</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="balance-trend" id="balance-trend"></div>
|
||
|
||
<div class="drink-bar-wrap" id="drink-bar-wrap"></div>
|
||
|
||
<div class="nutrient-strip ${localStorage.getItem("nut_intake_open") ? "" : "nutrient-collapsed"}" id="daily-nutrients"></div>
|
||
<div class="nutrient-strip ${localStorage.getItem("nut_reserves_open") ? "" : "nutrient-collapsed"}" id="daily-reserves" style="margin-top:2px"></div>
|
||
|
||
<section class="log-section">
|
||
<div class="section-header"><h3>Вес</h3>
|
||
<button class="add-btn" id="add-weight-btn">+</button></div>
|
||
<ul class="log-list">
|
||
${weights.map(w =>
|
||
`<li class="log-item">
|
||
<span class="log-time">${w.time ?? ""}</span>
|
||
<span class="log-desc">${w.weight_kg} кг</span>
|
||
<button class="del-btn" data-table="weight" data-id="${w.id}">✕</button>
|
||
</li>`
|
||
).join("") || `<li class="log-empty">нет данных</li>`}
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="log-section">
|
||
<div class="section-header"><h3>Еда: Продукты</h3>
|
||
<button class="add-btn" id="add-food-btn">+</button>
|
||
</div>
|
||
<ul class="log-list">
|
||
${food.filter(f => !f.recipe_id).map(f =>
|
||
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
|
||
<span class="log-time">${f.time ?? ""}</span>
|
||
<span class="log-desc">${esc(f.product_name ?? f.note ?? "—")}
|
||
${sourceBadge(f.product_source)}
|
||
${f.unit_amount && f.unit_short
|
||
? `<small>${f.unit_amount} ${f.unit_short}</small>`
|
||
: f.amount_g ? `<small>${f.amount_g}г</small>` : ""}
|
||
${foodNutrientLine(f)}</span>
|
||
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
|
||
<button class="del-btn" data-table="food" data-id="${f.id}">✕</button>
|
||
</li>`
|
||
).join("") || `<li class="log-empty">нет данных</li>`}
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="log-section">
|
||
<div class="section-header"><h3>Еда: Блюда</h3>
|
||
<button class="add-btn" id="add-recipe-food-btn" title="Из рецепта">+</button>
|
||
</div>
|
||
<ul class="log-list">
|
||
${food.filter(f => f.recipe_id).map(f =>
|
||
`<li class="log-item food-edit-item${f.nutrients_guessed ? " guessed" : ""}" data-food-id="${f.id}">
|
||
<span class="log-time">${f.time ?? ""}</span>
|
||
<span class="log-desc">${esc(f.recipe_name ?? f.note ?? "—")}
|
||
${f.amount_g ? `<small>${f.amount_g}г</small>` : ""}</span>
|
||
<span class="log-kcal">${f.kcal != null ? Math.round(f.kcal) + " кк" : ""}</span>
|
||
<button class="del-btn" data-table="food" data-id="${f.id}">✕</button>
|
||
</li>`
|
||
).join("") || `<li class="log-empty">нет данных</li>`}
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="log-section">
|
||
<div class="section-header"><h3>Активность</h3>
|
||
<button class="add-btn" id="add-act-btn">+</button></div>
|
||
<ul class="log-list">
|
||
${gpsSessions.map(s => {
|
||
let dist = s.total_distance_m ? (s.total_distance_m / 1000).toFixed(1) + " км" : "";
|
||
let kcal = s.total_kcal ? Math.round(s.total_kcal) + " кк" : "";
|
||
let dur = s.end_time && s.start_time ? s.start_time + "–" + s.end_time : s.start_time || "";
|
||
return `<li class="log-item" data-gps-session="${s.id_str ?? s.id}" style="cursor:pointer">
|
||
<span class="log-time">${dur}</span>
|
||
<span class="log-desc">${dist}${dist && kcal ? " / " : ""}${kcal}
|
||
${s.elevation_gain_m ? `<small>+${Math.round(s.elevation_gain_m)}м</small>` : ""}</span>
|
||
<span class="log-kcal" style="color:var(--primary)">${s.avg_speed_kmh ? s.avg_speed_kmh.toFixed(1) + " км/ч" : ""}</span>
|
||
</li>`;
|
||
}).join("")}
|
||
${activities.map(a => {
|
||
let at = a.activity_type || "manual";
|
||
let name = ACTIVITY_NAMES[at] || a.desc || "—";
|
||
let detail = "";
|
||
if (at === "squats" && a.sets && a.reps) detail = `${a.sets}x${a.reps}, `;
|
||
if (at === "swim" && a.distance_m) detail = `${a.distance_m} м, `;
|
||
if (a.minutes) detail += `${a.minutes} мин`;
|
||
if (at === "manual" && a.desc) detail = esc(a.desc) + (a.minutes ? `, ${a.minutes} мин` : "");
|
||
return `<li class="log-item">
|
||
<span class="log-time">${a.time ?? ""}</span>
|
||
<span class="log-desc">${at !== "manual" ? name : ""}${detail ? ` <small>${detail}</small>` : ""}</span>
|
||
<span class="log-kcal">${a.kcal && !isNaN(a.kcal) ? "−" + Math.round(a.kcal) + " кк" : ""}</span>
|
||
<button class="del-btn" data-table="activity" data-id="${a.id}">✕</button>
|
||
</li>`;
|
||
}).join("")}
|
||
${!gpsSessions.length && !activities.length ? `<li class="log-empty">нет данных</li>` : ""}
|
||
</ul>
|
||
</section>
|
||
|
||
<section class="log-section">
|
||
<div class="section-header"><h3>Добавки</h3>
|
||
<button class="add-btn" id="add-supp-btn">+</button></div>
|
||
<ul class="log-list">
|
||
${suppLog.map(s =>
|
||
`<li class="log-item">
|
||
<span class="log-time">${s.time ?? ""}</span>
|
||
<span class="log-desc">${esc(s.supplement_name)}${s.dose_g ? ` <small>${s.dose_g} шт</small>` : ""}</span>
|
||
<button class="del-btn" data-table="supp" data-id="${s.id}">✕</button>
|
||
</li>`
|
||
).join("") || `<li class="log-empty">нет данных</li>`}
|
||
</ul>
|
||
</section>
|
||
|
||
<!-- nutrient strip moved to top (after balance trend) -->
|
||
|
||
<section class="log-section">
|
||
<div class="section-header"><h3>Сон</h3>
|
||
<button class="add-btn" id="add-sleep-btn">✎</button></div>
|
||
<div class="sleep-info">
|
||
${sleep ? formatSleepInfo(sleep) : `<span class="log-empty">нет данных</span>`}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="log-section">
|
||
<div class="section-header">
|
||
<h3>Статистика</h3>
|
||
<div class="stats-period">
|
||
<button class="type-chip selected" data-days="30">30д</button>
|
||
<button class="type-chip" data-days="90">90д</button>
|
||
</div>
|
||
</div>
|
||
<div id="stats-charts"></div>
|
||
</section>
|
||
`;
|
||
|
||
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),
|
||
DB.getDailyActivityTotals(7),
|
||
]);
|
||
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 + dayAct);
|
||
balances.push(dayBalance);
|
||
}
|
||
if (balances.length === 0) return;
|
||
let avg = Math.round(balances.reduce((a, b) => a + b, 0) / balances.length);
|
||
let cls = avg <= 0 ? "trend-deficit" : "trend-surplus";
|
||
trendEl.innerHTML = `
|
||
<span class="balance-trend-label">Среднее за ${balances.length} дн.:</span>
|
||
<span class="balance-trend-val ${cls}">${avg >= 0 ? "+" : ""}${avg} ккал/день</span>`;
|
||
} 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 = `
|
||
<span class="drink-label">${waterMl} / ${norm} мл</span>
|
||
<div class="drink-bar"><div class="drink-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
|
||
} 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, "Приход")
|
||
|| `<div class="log-empty" style="padding:6px 14px">нет данных о нутриентах</div>`;
|
||
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) {
|
||
renderStatsCharts(statsContainer, 30, foodTotals, dailyBase);
|
||
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);
|
||
};
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stats charts
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let statsChartInstances = [];
|
||
|
||
async function renderStatsCharts(container, days, foodTotals, bmr) {
|
||
// Destroy old charts
|
||
statsChartInstances.forEach(c => c.destroy());
|
||
statsChartInstances.length = 0;
|
||
|
||
container.innerHTML = `<div class="loading">Загрузка...</div>`;
|
||
|
||
let weights, dailyFood, dailyActivity;
|
||
try {
|
||
[weights, dailyFood, dailyActivity] = await Promise.all([
|
||
DB.getRecentWeights(days),
|
||
DB.getDailyFoodTotals(days),
|
||
DB.getDailyActivityTotals(days),
|
||
]);
|
||
} catch (e) {
|
||
container.innerHTML = `<div class="log-empty">Ошибка загрузки статистики</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = "";
|
||
|
||
// --- Weight chart (line) ---
|
||
if (weights.length > 1) {
|
||
let block = document.createElement("div");
|
||
block.className = "stats-chart-block";
|
||
block.innerHTML = `
|
||
<div class="sd-title">Вес</div>
|
||
<div class="chart-wrap"><canvas id="stats-weight-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
|
||
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 = `
|
||
<div class="sd-title">Макронутриенты (сегодня)</div>
|
||
<div class="chart-wrap"><canvas id="stats-macros-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
|
||
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 = `
|
||
<div class="sd-title">Баланс ккал</div>
|
||
<div class="chart-wrap"><canvas id="stats-kcal-chart" height="180" style="touch-action:pan-y"></canvas></div>`;
|
||
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 = `
|
||
<div class="sd-title">Вес и баланс калорий</div>
|
||
<div class="chart-wrap"><canvas id="stats-corr-chart" height="200" style="touch-action:pan-y"></canvas></div>`;
|
||
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);
|
||
}
|
||
|
||
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",
|
||
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 = `<div class="log-empty">Недостаточно данных для графиков</div>`;
|
||
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 = `
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<button class="modal-back" id="modal-back">←</button>
|
||
</div>
|
||
<div class="modal-body">${html}</div>
|
||
<div class="modal-btns">
|
||
<button id="modal-cancel" class="btn-secondary">Отмена</button>
|
||
<button id="modal-ok" class="btn-primary">Сохранить</button>
|
||
</div>
|
||
</div>`;
|
||
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<bool>.
|
||
function appConfirm(message, okText = "OK") {
|
||
return new Promise(resolve => {
|
||
let ov = document.createElement("div");
|
||
ov.className = "modal-overlay confirm-overlay";
|
||
ov.innerHTML = `
|
||
<div class="confirm-box">
|
||
<div class="confirm-msg">${message}</div>
|
||
<div class="confirm-btns">
|
||
<button class="btn-secondary" id="cf-cancel">Отмена</button>
|
||
<button class="btn-primary" id="cf-ok">${okText}</button>
|
||
</div>
|
||
</div>`;
|
||
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<string|null>.
|
||
function appPrompt(message, defaultValue = "", okText = "OK") {
|
||
return new Promise(resolve => {
|
||
let ov = document.createElement("div");
|
||
ov.className = "modal-overlay confirm-overlay";
|
||
ov.innerHTML = `
|
||
<div class="confirm-box">
|
||
<div class="confirm-msg">${message}</div>
|
||
<input class="confirm-input" id="cf-input" type="text" value="${esc(defaultValue)}" />
|
||
<div class="confirm-btns">
|
||
<button class="btn-secondary" id="cf-cancel">Отмена</button>
|
||
<button class="btn-primary" id="cf-ok">${okText}</button>
|
||
</div>
|
||
</div>`;
|
||
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 = `
|
||
<div class="confirm-box">
|
||
<div class="confirm-msg">${message}</div>
|
||
<div class="confirm-btns">
|
||
<button class="btn-primary" id="cf-ok">${okText}</button>
|
||
</div>
|
||
</div>`;
|
||
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).
|
||
function reopenDetail(sessionId) {
|
||
let count = 0;
|
||
while (winStack.length > 0) {
|
||
let top = winStack.pop();
|
||
count++;
|
||
try { top.close(); } catch {}
|
||
if (top.type === "modal") break;
|
||
}
|
||
if (count > 0) { _skipPops = count; history.go(-count); }
|
||
showSessionDetail(sessionId);
|
||
}
|
||
|
||
function showWeightModal() {
|
||
showModal(`
|
||
<h3>Вес</h3>
|
||
<div class="form-group">
|
||
<label>кг</label>
|
||
<input id="m-kg" type="number" step="0.1" inputmode="decimal" placeholder="88.5" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
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);
|
||
});
|
||
}
|
||
|
||
function showFoodModal() {
|
||
let selectedProduct = null;
|
||
let selectedUnit = null; // { id, name, name_short, grams_per_unit }
|
||
let availableUnits = [];
|
||
|
||
showModal(`
|
||
<h3>Еда</h3>
|
||
<div id="m-search-wrap"></div>
|
||
<div id="m-selected" class="modal-selected" style="display:none"></div>
|
||
<div id="m-unit-chips" class="unit-chips" style="display:none"></div>
|
||
<div id="m-manual-fields">
|
||
<div class="form-group">
|
||
<label>Или вручную: описание</label>
|
||
<input id="m-desc" type="text" placeholder="Тесто для чебуреков" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>ккал на 100г</label>
|
||
<input id="m-kcal" type="number" inputmode="numeric" placeholder="260" />
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<div class="form-group" style="flex:1">
|
||
<label>Б</label>
|
||
<input id="m-protein" type="number" inputmode="decimal" step="0.1" placeholder="7" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Ж</label>
|
||
<input id="m-fat" type="number" inputmode="decimal" step="0.1" placeholder="4" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>У</label>
|
||
<input id="m-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label id="m-amount-label">Количество (г)</label>
|
||
<input id="m-amount" type="text" inputmode="text" placeholder="100 или 4*30" value="100" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
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 = `<button class="unit-chip${!selectedUnit ? " selected" : ""}" data-unit-id="0">г</button>`;
|
||
for (let u of availableUnits) {
|
||
if (u.grams_per_unit == null) continue; // skip units without conversion
|
||
html += `<button class="unit-chip${selectedUnit && selectedUnit.id === u.id ? " selected" : ""}" data-unit-id="${u.id}">${esc(u.name_short || u.name)}</button>`;
|
||
}
|
||
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 = `<span>${esc(product.name)}</span>`;
|
||
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(`
|
||
<h3>Редактирование</h3>
|
||
<div id="m-current" class="modal-selected">
|
||
<span>${esc(origLabel)}</span>
|
||
</div>
|
||
<div id="m-search-block" style="display:none">
|
||
<div class="form-group">
|
||
<label>Заменить на</label>
|
||
<input id="m-prod-search" type="search" placeholder="Поиск продукта..." />
|
||
<div id="m-prod-results" class="modal-search-results"></div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label id="m-amount-label">Количество (${(entry.water_pct && entry.water_pct > 80) ? "мл" : "г"})</label>
|
||
<input id="m-amount" type="text" inputmode="text" value="${entry.amount_g || 100}" />
|
||
</div>
|
||
${!entry.product_id && !entry.recipe_id ? `
|
||
<div class="form-group" id="m-kcal-group">
|
||
<label>ккал</label>
|
||
<input id="m-kcal" type="number" inputmode="numeric" value="${entry.kcal != null ? Math.round(entry.kcal) : ""}" />
|
||
</div>` : ""}
|
||
<div class="form-group">
|
||
<label>Заметка</label>
|
||
<input id="m-note" type="text" value="${esc(entry.note ?? "")}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${entry.time ?? ""}" />
|
||
</div>`,
|
||
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 =>
|
||
`<div class="modal-prod-item" data-pid="${p.id}">
|
||
<span>${esc(p.name)} ${sourceBadge(p.source)}</span>
|
||
<span class="prod-card-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
|
||
</div>`
|
||
).join("") || `<div class="prod-hint">Не найдено</div>`;
|
||
|
||
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(`
|
||
<h3>Активность</h3>
|
||
<div class="form-group">
|
||
<label>Тип</label>
|
||
<select id="m-atype" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||
${types.map(([k,v]) => `<option value="${k}">${v}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="m-desc-row">
|
||
<label>Описание</label>
|
||
<input id="m-desc" type="text" placeholder="прогулка" />
|
||
</div>
|
||
<div class="form-group" id="m-sets-row" style="display:none">
|
||
<label>Подходы x Повторения</label>
|
||
<div style="display:flex;gap:8px">
|
||
<input id="m-sets" type="number" inputmode="numeric" placeholder="4" style="width:50%" />
|
||
<input id="m-reps" type="number" inputmode="numeric" placeholder="15" style="width:50%" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="m-dist-row" style="display:none">
|
||
<label>Дистанция (м)</label>
|
||
<input id="m-dist" type="number" inputmode="numeric" placeholder="800" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Минуты</label>
|
||
<input id="m-min" type="number" inputmode="numeric" placeholder="20" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>ккал расход</label>
|
||
<input id="m-kcal" type="number" inputmode="numeric" placeholder="авто" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
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(`
|
||
<h3>Сон</h3>
|
||
<div class="form-group">
|
||
<label>Лёг в кровать</label>
|
||
<input id="m-bed" type="time" value="${existing?.bed_time ?? ""}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Уснул</label>
|
||
<input id="m-asleep" type="time" value="${existing?.asleep_time ?? ""}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Проснулся</label>
|
||
<input id="m-wake" type="time" value="${existing?.wake_time ?? ""}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Дневной сон (мин)</label>
|
||
<input id="m-nap" type="number" inputmode="numeric" value="${existing?.nap_minutes ?? 0}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Заметки</label>
|
||
<input id="m-notes" type="text" value="${esc(existing?.notes ?? "")}" />
|
||
</div>`,
|
||
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 = `
|
||
<div class="products-screen">
|
||
<div class="sub-tabs">
|
||
<button class="sub-tab active" data-tab="products">Продукты</button>
|
||
<button class="sub-tab" data-tab="recipes">Рецепты</button>
|
||
</div>
|
||
<div id="products-tab">
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input id="prod-search" class="search-input" type="search" style="flex:1"
|
||
placeholder="Название, бренд или ингредиент..." />
|
||
<button class="add-btn" id="export-all-custom" style="font-size:12px" title="Экспорт своих продуктов и рецептов">⇩</button>
|
||
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">⇧</button>
|
||
<button class="add-btn" id="scan-chzn" style="font-size:14px" title="Скан «Честный знак»">▦</button>
|
||
<button class="add-btn" id="add-custom-product" title="Свой продукт">+</button>
|
||
</div>
|
||
<div id="prod-count" class="prod-count"></div>
|
||
<div id="prod-results" class="prod-list"></div>
|
||
</div>
|
||
<div id="recipes-tab" style="display:none"></div>
|
||
</div>`;
|
||
|
||
// 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 = `<div class="prod-hint">Введите название продукта</div>`;
|
||
countEl.textContent = "";
|
||
lastResults = [];
|
||
return;
|
||
}
|
||
if (resetExpanded) expandedId = null;
|
||
lastResults = await DB.searchProducts(q);
|
||
countEl.textContent = lastResults.length ? `Найдено: ${lastResults.length}` : "";
|
||
if (!lastResults.length) {
|
||
listEl.innerHTML = `<div class="prod-hint">Ничего не найдено</div>`;
|
||
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 `
|
||
<div class="prod-card ${open ? "open" : ""}" data-pid="${p.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">
|
||
${esc(p.name)}
|
||
${p.brand ? `<span class="prod-brand">${esc(p.brand)}</span>` : ""}
|
||
${p.weight_g ? `<span class="prod-weight">${p.weight_g}г</span>` : ""}
|
||
${sourceBadge(p.source)}
|
||
</div>
|
||
<div class="prod-card-kcal">${kcal != null ? kcal + " кк" : "?"}</div>
|
||
</div>
|
||
${open ? renderProdDetails(p) : ""}
|
||
</div>`;
|
||
}
|
||
|
||
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: `<div class="nut-item ${cls}">${label} ${pct}%</div>` });
|
||
}
|
||
microChips.sort((a, b) => a.pct - b.pct);
|
||
return `
|
||
<div class="prod-details">
|
||
<div class="prod-macros">
|
||
<div class="macro"><span class="macro-val">${r(p.protein_per_100g)}</span><span class="macro-lbl">Б</span></div>
|
||
<div class="macro"><span class="macro-val">${r(p.fat_per_100g)}</span><span class="macro-lbl">Ж</span></div>
|
||
<div class="macro"><span class="macro-val">${r(p.carbs_per_100g)}</span><span class="macro-lbl">У</span></div>
|
||
<div class="macro"><span class="macro-val">${r(p.fiber_per_100g)}</span><span class="macro-lbl">Кл</span></div>
|
||
</div>
|
||
${microChips.length ? `<div class="nutrient-strip" style="padding:4px 0">${microChips.map(c => c.html).join("")}</div>` : ""}
|
||
${p.composition ? `<div class="prod-composition">${esc(p.composition)}</div>` : ""}
|
||
<div style="display:flex;gap:8px;margin-top:8px">
|
||
<button class="btn-primary prod-add-btn" data-pid="${p.id}" style="flex:1">Добавить в дневник</button>
|
||
<button class="btn-secondary prod-edit-btn" data-pid="${p.id}" style="flex:0">Ред.</button>
|
||
<button class="btn-secondary prod-del-btn" data-pid="${p.id}" style="flex:0;color:#cf6679">Уд.</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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(`
|
||
<h3>Добавить в дневник</h3>
|
||
<div class="form-group">
|
||
<label>Количество (г)</label>
|
||
<input id="m-amount" type="text" inputmode="text" placeholder="100 или 4*30" value="100" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Заметка</label>
|
||
<input id="m-note" type="text" placeholder="" />
|
||
</div>`,
|
||
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 = `<div class="prod-hint">Введите название продукта</div>`;
|
||
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 `<div class="form-group micro-field">
|
||
<label>${f.label}</label>
|
||
<input id="np-${f.key}" type="number" inputmode="decimal" step="any" value="${v(raw)}" placeholder="—" />
|
||
</div>`;
|
||
}).join("");
|
||
|
||
// Честный знак source: header note + composition/weight hint (not stored as columns).
|
||
let chznNote = pf.source === "chzn"
|
||
? `<div style="background:var(--surface,#1c1c1e);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:8px;font-size:12px;color:var(--muted)">
|
||
Данные из Честного знака${pf.gtin ? " · GTIN " + esc(pf.gtin) : ""}${pf.net_weight_g ? " · нетто " + esc(pf.net_weight_g) + " г" : ""}
|
||
${pf.composition ? `<div style="margin-top:4px"><b>Состав:</b> ${esc(pf.composition)}</div>` : ""}
|
||
</div>`
|
||
: "";
|
||
|
||
showModal(`
|
||
<h3>Новый продукт</h3>
|
||
${chznNote}
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="np-name" type="text" value="${esc(v(pf.name))}" placeholder="Тесто для чебуреков" />
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<div class="form-group" style="flex:1">
|
||
<label>ккал/100г</label>
|
||
<input id="np-kcal" type="number" inputmode="decimal" value="${v(pf.kcal_per_100g)}" placeholder="260" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Б</label>
|
||
<input id="np-protein" type="number" inputmode="decimal" step="0.1" value="${v(pf.protein_per_100g)}" placeholder="7" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Ж</label>
|
||
<input id="np-fat" type="number" inputmode="decimal" step="0.1" value="${v(pf.fat_per_100g)}" placeholder="4" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>У</label>
|
||
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" value="${v(pf.carbs_per_100g)}" placeholder="48" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:8px">
|
||
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
|
||
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${v(pf.water_pct)}" placeholder="" />
|
||
</div>
|
||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г (опционально):</p>
|
||
<div class="micro-grid">${microHtml}</div>`,
|
||
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 = `
|
||
<div class="modal">
|
||
<div class="modal-header"><button class="modal-back" id="cz-close">←</button></div>
|
||
<div class="modal-body">
|
||
<h3>Скан «Честный знак»</h3>
|
||
<div style="position:relative;background:#000;border-radius:8px;overflow:hidden;aspect-ratio:1/1;max-height:60vh">
|
||
<video id="cz-video" playsinline muted style="width:100%;height:100%;object-fit:cover"></video>
|
||
<div style="position:absolute;inset:4%;border:1px solid rgba(255,255,255,.35);border-radius:8px;pointer-events:none"></div>
|
||
<button id="cz-torch" class="btn-secondary" style="display:none;position:absolute;top:8px;right:8px;padding:6px 10px;font-size:16px;line-height:1">💡</button>
|
||
</div>
|
||
<div id="cz-zoom-wrap" style="display:none;align-items:center;gap:10px;margin-top:10px">
|
||
<span style="font-size:16px">🔍</span>
|
||
<input id="cz-zoom" type="range" style="flex:1" />
|
||
</div>
|
||
<div id="cz-status" style="margin:10px 0;font-size:13px;color:var(--muted);min-height:18px">Запуск камеры...</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button id="cz-photo" class="btn-secondary" style="flex:1;font-size:13px">Из фото</button>
|
||
<button id="cz-manual" class="btn-secondary" style="flex:1;font-size:13px">Ввести код</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
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 `<div class="form-group micro-field">
|
||
<label>${f.label}</label>
|
||
<input id="ep-${f.key}" type="number" inputmode="decimal" step="any" value="${val}" placeholder="—" />
|
||
</div>`;
|
||
}).join("");
|
||
|
||
showModal(`
|
||
<h3>Редактирование продукта</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="ep-name" type="text" value="${esc(p.name)}" />
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<div class="form-group" style="flex:1">
|
||
<label>ккал/100г</label>
|
||
<input id="ep-kcal" type="number" inputmode="decimal" value="${p.kcal_per_100g ?? ""}" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Б</label>
|
||
<input id="ep-protein" type="number" inputmode="decimal" step="0.1" value="${p.protein_per_100g ?? ""}" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>Ж</label>
|
||
<input id="ep-fat" type="number" inputmode="decimal" step="0.1" value="${p.fat_per_100g ?? ""}" />
|
||
</div>
|
||
<div class="form-group" style="flex:1">
|
||
<label>У</label>
|
||
<input id="ep-carbs" type="number" inputmode="decimal" step="0.1" value="${p.carbs_per_100g ?? ""}" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-top:8px">
|
||
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
|
||
<input id="ep-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${p.water_pct ?? ""}" placeholder="" />
|
||
</div>
|
||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г:</p>
|
||
<div class="micro-grid">${microHtml}</div>
|
||
<button id="ep-export" class="btn-secondary full-width" style="margin-top:12px;font-size:13px">Экспорт продукта</button>`,
|
||
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 = `
|
||
<div class="products-screen">
|
||
<div class="section-header">
|
||
<h2>Рецепты</h2>
|
||
<span style="display:flex;gap:6px">
|
||
<button class="add-btn" id="import-recipes-btn" style="font-size:12px">Импорт</button>
|
||
<button class="add-btn" id="export-recipes-btn" style="font-size:12px">Экспорт</button>
|
||
<button class="add-btn" id="add-recipe-btn">+</button>
|
||
</span>
|
||
</div>
|
||
<div id="recipe-list" class="prod-list">
|
||
${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 `
|
||
<div class="prod-card recipe-card" data-rid="${r.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">
|
||
${esc(r.name)}
|
||
${r.cooking_method_name ? `<span class="prod-brand">${esc(r.cooking_method_name)}</span>` : ""}
|
||
${r.servings > 1 ? `<span class="prod-weight">${r.servings} порц.</span>` : ""}
|
||
</div>
|
||
<div class="prod-card-kcal">${kcalStr}</div>
|
||
</div>
|
||
${macroStr ? `<div class="recipe-macros-hint">${macroStr}</div>` : ""}
|
||
</div>`;
|
||
}).join("") : `<div class="prod-hint">Нет рецептов. Нажмите + чтобы создать.</div>`}
|
||
</div>
|
||
</div>`;
|
||
|
||
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 `<li class="log-item">
|
||
<span class="log-desc">${esc(name)}${cm}</span>
|
||
<span class="log-kcal">${ing.amount_g} г</span>
|
||
</li>`;
|
||
}).join("");
|
||
|
||
showModal(`
|
||
<h3>${esc(recipe.name)}</h3>
|
||
${recipe.cooking_method_name ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Способ: ${esc(recipe.cooking_method_name)}</p>` : ""}
|
||
${recipe.servings > 1 ? `<p style="color:var(--muted);font-size:13px;margin-bottom:8px">Порций: ${recipe.servings}</p>` : ""}
|
||
${recipe.notes ? `<p style="color:var(--muted);font-size:13px;margin-bottom:12px">${esc(recipe.notes)}</p>` : ""}
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Ингредиенты</div>
|
||
<ul class="log-list">${ingList || `<li class="log-empty">нет ингредиентов</li>`}</ul>
|
||
</div>
|
||
|
||
${nutr ? `
|
||
<div class="sd-section" style="margin-top:12px">
|
||
<div class="sd-title">Пищевая ценность (на порцию)</div>
|
||
<div class="prod-macros">
|
||
<div class="macro"><span class="macro-val">${nutr.kcal}</span><span class="macro-lbl">ккал</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.protein}</span><span class="macro-lbl">Б</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.fat}</span><span class="macro-lbl">Ж</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.carbs}</span><span class="macro-lbl">У</span></div>
|
||
<div class="macro"><span class="macro-val">${nutr.fiber}</span><span class="macro-lbl">Кл</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="sd-section" style="margin-top:8px">
|
||
<div class="sd-title">Микронутриенты (на порцию, % РСП)</div>
|
||
<div class="nutrient-strip">${(() => {
|
||
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: '<div class="nut-item ' + cls + '">' + label + " " + pct + "%</div>" });
|
||
}
|
||
chips.sort((a, b) => a.pct - b.pct);
|
||
return chips.map(c => c.html).join("");
|
||
})()}</div>
|
||
</div>` : ""}
|
||
|
||
<div class="form-group" style="margin-top:16px">
|
||
<label>Сколько съел (г)</label>
|
||
<input id="rd-amount" type="text" inputmode="numeric" placeholder="${nutr ? Math.round(nutr.totalWeight / (recipe.servings || 1)) : 100} или 2*${nutr ? Math.round(nutr.totalWeight / (recipe.servings || 1)) : 100}" value="${nutr ? Math.round(nutr.totalWeight / (recipe.servings || 1)) : 100}" />
|
||
${nutr ? `<small style="color:var(--muted)">1 порция = ${Math.round(nutr.totalWeight / (recipe.servings || 1))} г</small>` : ""}
|
||
</div>
|
||
<button id="rd-log" class="btn-primary full-width" style="margin-top:8px">Записать в дневник</button>
|
||
<button id="rd-edit" class="btn-secondary full-width" style="margin-top:8px">Редактировать</button>
|
||
<button id="rd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт рецепта</button>
|
||
<button id="rd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить рецепт</button>
|
||
`, () => {});
|
||
|
||
// 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 =>
|
||
`<option value="${cm.id}">${esc(cm.name)}</option>`
|
||
).join("");
|
||
|
||
function renderIngredientRows() {
|
||
if (ingredients.length === 0) return `<div class="log-empty">Добавьте ингредиенты</div>`;
|
||
return ingredients.map((ing, idx) => {
|
||
let name = ing.product_name ?? ing.custom_name ?? "?";
|
||
return `<div class="recipe-ing-row" data-idx="${idx}">
|
||
<div class="recipe-ing-info">
|
||
<span class="recipe-ing-name">${esc(name)}</span>
|
||
<span class="recipe-ing-weight">${ing.weight_g} г</span>
|
||
${ing.cooking_method_name ? `<span class="recipe-ing-cm">${esc(ing.cooking_method_name)}</span>` : ""}
|
||
</div>
|
||
<button class="del-btn recipe-ing-del" data-idx="${idx}">✕</button>
|
||
</div>`;
|
||
}).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(`
|
||
<h3>${isNew ? "Новый рецепт" : "Редактирование рецепта"}</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="re-name" type="text" value="${esc(recipe.name)}" placeholder="Борщ, Оливье..." />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Способ приготовления</label>
|
||
<select id="re-method">
|
||
<option value="">-- нет --</option>
|
||
${cmOptions}
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Порций</label>
|
||
<input id="re-servings" type="number" inputmode="decimal" step="0.5" min="0.5"
|
||
value="${recipe.servings ?? 1}" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Заметки</label>
|
||
<input id="re-notes" type="text" value="${esc(recipe.notes ?? "")}" placeholder="Бабушкин рецепт..." />
|
||
</div>
|
||
|
||
<div class="sd-section" style="margin-top:12px">
|
||
<div class="section-header" style="padding-left:0">
|
||
<div class="sd-title">Ингредиенты</div>
|
||
<button class="add-btn" id="re-add-ing">+</button>
|
||
</div>
|
||
<div id="re-ing-list">${renderIngredientRows()}</div>
|
||
</div>
|
||
|
||
<div id="re-totals" class="recipe-totals"></div>
|
||
`, 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 =>
|
||
`<option value="${cm.id}">${esc(cm.name)}</option>`
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>Добавить ингредиент</h3>
|
||
<div id="ing-search-wrap"></div>
|
||
<div id="ing-selected" class="modal-selected" style="display:none"></div>
|
||
<div id="ing-custom-block">
|
||
<div class="form-group">
|
||
<label>Или вручную: название</label>
|
||
<input id="ing-custom" type="text" placeholder="масло оливковое" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Вес (г)</label>
|
||
<input id="ing-weight" type="number" inputmode="decimal" value="100" placeholder="100" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Обработка (для этого ингредиента)</label>
|
||
<select id="ing-method">
|
||
<option value="">-- как рецепт --</option>
|
||
${cmOptions}
|
||
</select>
|
||
</div>
|
||
`, 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 = `<span>${esc(product.name)}</span>`;
|
||
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(`
|
||
<h3>Записать рецепт</h3>
|
||
<div id="rfr-list" class="prod-list"><div class="loading">Загрузка...</div></div>
|
||
<div class="form-group" style="margin-top:12px">
|
||
<label>Сколько съел (г)</label>
|
||
<input id="rfr-amount" type="text" inputmode="numeric" placeholder="100 или 2*150" value="" />
|
||
<small id="rfr-hint" style="color:var(--muted)"></small>
|
||
</div>
|
||
<div class="form-group" style="margin-top:8px">
|
||
<label>Время</label>
|
||
<input id="rfr-time" type="time" value="${nowTime()}" />
|
||
</div>
|
||
`, 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 = `<div class="prod-hint">Нет рецептов. Создайте в Продукты → Рецепты.</div>`;
|
||
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 =>
|
||
`<div class="supp-pick-item recipe-pick-item" data-rid="${r.id}">
|
||
${esc(r.name)}
|
||
${r.nutr ? `<small style="color:var(--muted);margin-left:6px">${r.nutr.kcal} кк</small>` : ""}
|
||
</div>`
|
||
).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;
|
||
|
||
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) => `
|
||
<div class="gps-session-item" data-sid="${s.id_str ?? s.id}">
|
||
<div class="gps-session-head">
|
||
<span>${s.date} ${s.start_time ?? ""}</span>
|
||
<span class="gps-session-type">${s.activity_type ?? ""}</span>
|
||
</div>
|
||
<div class="gps-session-stats">
|
||
${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) + "м" : ""}
|
||
</div>
|
||
</div>`;
|
||
let sessions = await DB.getGpsSessions(PAGE_SIZE, 0);
|
||
let isRec = GPS.recording.active;
|
||
|
||
container.innerHTML = `
|
||
<div class="gps-screen">
|
||
<div class="gps-header">
|
||
<div class="gps-btns">
|
||
<select id="gps-type" class="gps-type-sel">
|
||
<option value="bike"${(localStorage.getItem("baldav_gps_type")||"bike")==="bike"?" selected":""}>Вело</option>
|
||
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
|
||
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
|
||
</select>
|
||
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
|
||
${isRec ? "Трекинг" : "Старт"}
|
||
</button>
|
||
<button id="gps-goals-btn" class="btn-secondary" style="padding:8px 12px;font-size:13px">Цели</button>
|
||
<div class="gps-menu-wrap">
|
||
<button id="gps-menu-btn" class="gps-menu-btn">⋮</button>
|
||
<div id="gps-menu" class="gps-menu hidden">
|
||
<div class="gps-menu-item" id="gps-draw-route">Нарисовать маршрут</div>
|
||
<div class="gps-menu-item" id="gps-download-map">Скачать карту</div>
|
||
<div class="gps-menu-item" id="gps-import">Импорт тренировки</div>
|
||
<div class="gps-menu-item" id="gps-routes">Сохранённые маршруты</div>
|
||
<div class="gps-menu-item" id="gps-recover">Восстановить точки из файлов</div>
|
||
<div class="gps-menu-item" id="gps-recalc-stats">Пересчитать высоту/калории</div>
|
||
<div class="gps-menu-item" id="gps-clear-cache">Очистить кэш карт</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="gps-sessions">
|
||
<h3>История</h3>
|
||
<div id="gps-session-list">${sessions.length ? sessions.map(sessionCard).join("") : `<div class="prod-hint">Нет записей</div>`}</div>
|
||
<button id="gps-more" class="btn-secondary" style="width:100%;margin-top:10px;${sessions.length < PAGE_SIZE ? "display:none" : ""}">Показать ещё</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
await GPS.startRecording(type, wkg);
|
||
renderScreen("recording");
|
||
}
|
||
};
|
||
|
||
// Session click → detail view. Keep sid as a STRING (numeric id from the
|
||
// SQL plugin is corrupted off-by-1 for large i64 values).
|
||
let bindSessionClick = (item) => { item.onclick = () => showSessionDetail(item.dataset.sid); };
|
||
container.querySelectorAll(".gps-session-item").forEach(bindSessionClick);
|
||
|
||
// 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 = `
|
||
<h3>Цели (маркеры)</h3>
|
||
<div id="gm-list" class="goal-markers-list">
|
||
${markers.length ? markers.map(m => `
|
||
<div class="goal-marker-card" data-mid="${m.id}">
|
||
<div class="goal-marker-info">
|
||
<div class="goal-marker-name">${esc(m.name)}</div>
|
||
<div class="goal-marker-coords">${m.lat.toFixed(5)}, ${m.lon.toFixed(5)}</div>
|
||
${m.notes ? `<div class="goal-marker-notes">${esc(m.notes)}</div>` : ""}
|
||
</div>
|
||
<button class="del-btn gm-del" data-mid="${m.id}">✕</button>
|
||
</div>
|
||
`).join("") : `<div class="prod-hint">Нет маркеров. Нажмите "Добавить" для создания.</div>`}
|
||
</div>
|
||
<button id="gm-add" class="btn-primary full-width" style="margin-top:12px">Добавить маркер</button>`;
|
||
|
||
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(`
|
||
<h3>Новый маркер</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="gm-name" type="text" placeholder="Вершина холма, парк..." />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Заметка</label>
|
||
<input id="gm-notes" type="text" placeholder="" />
|
||
</div>
|
||
<div id="gm-map-wrap" class="gm-map-wrap">
|
||
<div id="gm-map" class="gm-map"></div>
|
||
<div id="gm-coords" class="gm-coords">Нажмите на карту для выбора точки</div>
|
||
</div>
|
||
<input id="gm-lat" type="hidden" />
|
||
<input id="gm-lon" type="hidden" />
|
||
`, 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) {
|
||
// 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];
|
||
}
|
||
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";
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
|
||
// kept — one colour per segment): green -> yellow -> red -> brown -> black.
|
||
// <1%=0 .. 9–10%=9, >=10% = black. Both up- and downhill coloured by magnitude.
|
||
let ROUTE_SLOPE_BANDS = [
|
||
"#2e7d32", "#7cb342", "#aeea00", "#ffeb3b", "#ffc107",
|
||
"#ff9800", "#ff5722", "#e53935", "#b71c1c", "#6d4c41", "#000000",
|
||
];
|
||
function routeSlopeColor(gradePct) {
|
||
let idx = Math.min(10, Math.floor(Math.abs(gradePct || 0)));
|
||
return ROUTE_SLOPE_BANDS[idx];
|
||
}
|
||
// Compact colour strip for the slope legend (every 1% band, 0 → 10%+).
|
||
function slopeLegendHtml() {
|
||
let cells = ROUTE_SLOPE_BANDS
|
||
.map(c => `<span style="display:inline-block;width:11px;height:10px;background:${c};vertical-align:middle"></span>`)
|
||
.join("");
|
||
return `уклон 0% ${cells} 10%+`;
|
||
}
|
||
|
||
// 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++) {
|
||
let col;
|
||
if (mode === "elev") {
|
||
let e = st.smoothed[i] ?? st.smoothed[i+1];
|
||
if (e == null) continue;
|
||
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 => `
|
||
<div class="settings-item route-row" data-id="${r.id}" data-name="${esc(r.name)}" style="gap:8px">
|
||
<input type="radio" name="active-route" value="${r.id}" ${String(r.id) === active ? "checked" : ""} title="Сделать активным" style="width:18px;height:18px;flex:none">
|
||
<span class="route-swatch" style="width:16px;height:16px;border-radius:3px;background:${esc(r.color)};flex:none"></span>
|
||
<span class="route-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;min-width:0" title="Открыть (высоты, профиль)">${esc(r.name)} <span class="prod-hint">${r.pts} тчк${r.hasEle ? " · ⛰" : ""}</span></span>
|
||
<input type="color" class="route-color" value="${esc(r.color)}" style="width:30px;height:26px;border:none;background:none;padding:0;flex:none">
|
||
<button class="route-rename btn-secondary" style="padding:4px 8px;flex:none">✎</button>
|
||
<button class="route-del btn-secondary" style="padding:4px 8px;color:#cf6679;flex:none">✕</button>
|
||
</div>`).join("");
|
||
|
||
container.innerHTML = `
|
||
<div class="routes-screen" style="padding:12px">
|
||
<div class="day-header">
|
||
<button class="day-nav" id="routes-back">←</button>
|
||
<h2>Сохранённые маршруты</h2>
|
||
<span style="width:36px"></span>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin:8px 0 14px">
|
||
<button id="routes-new" class="btn-primary" style="flex:1">Новый маршрут</button>
|
||
<button id="routes-import" class="btn-secondary" style="flex:1">Импорт GPX</button>
|
||
</div>
|
||
<label class="settings-item" style="cursor:pointer">
|
||
<input type="radio" name="active-route" id="route-none" value="" ${!active ? "checked" : ""} style="width:18px;height:18px;margin-right:10px">
|
||
<span>— Без маршрута —</span>
|
||
</label>
|
||
<div id="routes-list">${routes.length ? rows : `<div class="prod-hint">Нет маршрутов. Импортируйте GPX-файл — линию можно нарисовать заранее (напр. в Яндекс.Конструкторе карт).</div>`}</div>
|
||
</div>`;
|
||
|
||
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 <ele>, 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.
|
||
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 = route.points;
|
||
let st = GPS.routeElevStats(pts);
|
||
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
|
||
let legendHtml = mode === "slope"
|
||
? slopeLegendHtml()
|
||
: `<b style="color:#2e7d32">▬</b> низко → <b style="color:#6d4c41">▬</b> высоко`;
|
||
|
||
container.innerHTML = `
|
||
<div class="route-detail" style="padding:12px">
|
||
<div class="day-header">
|
||
<button class="day-nav" id="rd-back">←</button>
|
||
<h2 style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(route.name)}</h2>
|
||
<span style="width:36px"></span>
|
||
</div>
|
||
<div id="rd-map" class="sd-map" style="height:300px"></div>
|
||
${st.hasEle ? `<div class="prod-hint" id="rd-legend" style="margin-top:8px;text-align:center">${legendHtml}</div>` : ""}
|
||
<div class="sd-row" style="margin-top:10px">
|
||
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(st.distanceKm * 1000)}</span><span class="sd-lbl">Длина</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${st.hasEle ? "+" + st.ascent + " м" : "—"}</span><span class="sd-lbl">Набор</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${st.hasEle ? "−" + st.descent + " м" : "—"}</span><span class="sd-lbl">Сброс</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${st.hasEle ? (st.maxGrade >= 0 ? "+" : "") + st.maxGrade + " %" : "—"}</span><span class="sd-lbl">Макс. уклон</span></div>
|
||
</div>
|
||
${st.hasEle
|
||
? `<div class="sd-section"><div class="sd-title">Профиль высоты</div><div class="chart-wrap" id="rd-elev-wrap"><canvas id="rd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div></div>`
|
||
: `<div class="prod-hint" style="margin-top:12px">Высоты ещё не загружены — нажмите кнопку ниже (нужен интернет).</div>`}
|
||
<button id="rd-fetch" class="btn-primary" style="width:100%;margin-top:12px">${st.hasEle ? "Обновить высоты (онлайн)" : "Загрузить высоты (онлайн)"}</button>
|
||
<button id="rd-edit" class="btn-secondary" style="width:100%;margin-top:8px">Редактировать маршрут</button>
|
||
</div>`;
|
||
|
||
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, { attributionControl: false, zoomControl: false });
|
||
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()
|
||
: `<b style="color:#2e7d32">▬</b> низко → <b style="color:#6d4c41">▬</b> высоко`;
|
||
};
|
||
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;
|
||
let points = (editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : [];
|
||
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
|
||
let markers = [];
|
||
let polyline = null;
|
||
|
||
container.innerHTML = `
|
||
<div class="draw-screen">
|
||
<div id="re-map" class="draw-map"></div>
|
||
<div class="rec-stats">
|
||
<div class="rec-stat"><span id="re-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
|
||
<div class="rec-stat"><span id="re-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
|
||
</div>
|
||
<div class="rec-bottom">
|
||
<span class="prod-hint" style="flex:1;align-self:center;line-height:1.2">тап карты — точка · тап линии — вставить · тащи — двигать · тап точки — удалить</span>
|
||
<button id="re-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
|
||
<button id="re-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
|
||
<button id="re-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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: `<div style="width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 2px #000"></div>`,
|
||
iconSize: [16, 16], iconAnchor: [8, 8],
|
||
});
|
||
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, weight: 4, interactive: true }).addTo(map);
|
||
// Tap the LINE to insert a point at that spot — a distinct target from
|
||
// markers/empty-map, so it doesn't clash with add-at-end / drag / delete.
|
||
polyline.on("click", (e) => { L.DomEvent.stop(e); insertAt(e.latlng); });
|
||
}
|
||
}
|
||
// Draggable + deletable point marker. Markers stay parallel to `points` (same
|
||
// index) — drag updates points[idx], click (delete) splices both. Returns the
|
||
// marker; caller pushes (end) or splices (mid-insert) into `markers`.
|
||
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) { points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng }; updatePolyline(); }
|
||
});
|
||
m.on("dragend", updateStats);
|
||
m.on("click", (e) => {
|
||
L.DomEvent.stop(e); // don't fall through to a map-click (which would add a point)
|
||
let idx = markers.indexOf(m);
|
||
if (idx < 0) return;
|
||
points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m);
|
||
updatePolyline(); updateStats();
|
||
});
|
||
m.addTo(map);
|
||
return m;
|
||
}
|
||
// Insert a point where the line was tapped: pick the nearest segment [i,i+1]
|
||
// (planar lat/lon distance — fine at city scale) and splice at i+1.
|
||
function insertAt(latlng) {
|
||
if (points.length < 2) return;
|
||
let best = -1, bestD = Infinity;
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
let a = points[i], b = points[i + 1];
|
||
let dx = b.lon - a.lon, dy = b.lat - a.lat;
|
||
let t = (dx || dy) ? ((latlng.lng - a.lon) * dx + (latlng.lat - a.lat) * dy) / (dx * dx + dy * dy) : 0;
|
||
t = Math.max(0, Math.min(1, t));
|
||
let cx = a.lon + t * dx, cy = a.lat + t * dy;
|
||
let d = Math.hypot(latlng.lng - cx, latlng.lat - cy);
|
||
if (d < bestD) { bestD = d; best = i; }
|
||
}
|
||
if (best < 0) return;
|
||
let np = { lat: latlng.lat, lon: latlng.lng };
|
||
points.splice(best + 1, 0, np);
|
||
markers.splice(best + 1, 0, makeMarker(np));
|
||
updatePolyline(); updateStats();
|
||
}
|
||
|
||
if (points.length) {
|
||
points.forEach(p => markers.push(makeMarker(p)));
|
||
updatePolyline(); updateStats();
|
||
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
|
||
} 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 => {
|
||
let p = { lat: e.latlng.lat, lon: e.latlng.lng };
|
||
points.push(p); markers.push(makeMarker(p));
|
||
updatePolyline(); updateStats();
|
||
});
|
||
|
||
document.querySelector("#re-undo").onclick = () => {
|
||
if (!points.length) return;
|
||
points.pop();
|
||
let m = markers.pop(); if (m) map.removeLayer(m);
|
||
updatePolyline(); updateStats();
|
||
};
|
||
|
||
document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container);
|
||
|
||
document.querySelector("#re-save").onclick = async () => {
|
||
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
|
||
let btn = document.querySelector("#re-save");
|
||
btn.disabled = true; btn.textContent = "Сохранение...";
|
||
if (editing) {
|
||
await DB.updateRoutePoints(routeId, points);
|
||
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", points);
|
||
showToast("Маршрут создан");
|
||
showRouteDetail(container, id);
|
||
}
|
||
};
|
||
}, 200);
|
||
}
|
||
|
||
function showDrawRouteScreen(container) {
|
||
let points = []; // {lat, lon}
|
||
let markers = [];
|
||
let polyline = null;
|
||
|
||
container.innerHTML = `
|
||
<div class="draw-screen">
|
||
<div id="draw-map" class="draw-map"></div>
|
||
<div class="rec-stats">
|
||
<div class="rec-stat"><span id="draw-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
|
||
<div class="rec-stat"><span id="draw-pts" class="rec-val">0</span><span class="rec-lbl">Точек</span></div>
|
||
</div>
|
||
<div class="rec-bottom">
|
||
<select id="draw-type" style="padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||
<option value="bike">Вело</option>
|
||
<option value="walk">Ходьба</option>
|
||
<option value="run">Бег</option>
|
||
</select>
|
||
<button id="draw-undo" class="btn-secondary" style="padding:8px 16px">Отменить</button>
|
||
<button id="draw-save" class="btn-primary" style="padding:8px 16px">Сохранить</button>
|
||
<button id="draw-cancel" class="btn-secondary" style="padding:8px 12px;color:#cf6679">Отмена</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
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
|
||
? `<path d="M28 5 L37 23 L19 23 Z" fill="${fill}" stroke="#fff" stroke-width="2.5" stroke-linejoin="round" paint-order="stroke"/>
|
||
<path d="M28 5 L37 23 L19 23 Z" fill="none" stroke="#000" stroke-width="0.6" stroke-linejoin="round"/>`
|
||
: "";
|
||
return L.divIcon({
|
||
className: "rec-puck",
|
||
html: `<svg width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
|
||
${tri}
|
||
<circle cx="28" cy="28" r="11" fill="#000"/>
|
||
<circle cx="28" cy="28" r="10" fill="#fff"/>
|
||
<circle cx="28" cy="28" r="7.5" fill="${fill}"/>
|
||
</svg>`,
|
||
iconSize: [56, 56],
|
||
iconAnchor: [28, 28], // circle centre = geo position; rotation pivots here
|
||
});
|
||
}
|
||
|
||
async function renderRecordingInline(container) {
|
||
if (!GPS.recording.active) { renderScreen("gps"); return; }
|
||
|
||
// Keep screen on while recording is visible
|
||
if (navigator.wakeLock) {
|
||
navigator.wakeLock.request("screen").then(lock => {
|
||
window._screenLock = lock;
|
||
}).catch(() => {});
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="rec-screen">
|
||
<div class="rec-map-wrap">
|
||
<div id="rec-map" class="rec-map"></div>
|
||
${GPS.recording.state === "waiting_gps" ? '<div id="gps-placeholder" class="gps-placeholder"><div class="gps-placeholder-text">Поиск GPS...</div></div>' : ''}
|
||
</div>
|
||
<div class="rec-panel">
|
||
<div class="rec-hr-row" id="rec-hr-row" style="display:none">
|
||
<span id="rec-hr-val" class="rec-hr-val">--</span>
|
||
<span class="rec-hr-unit">bpm</span>
|
||
<span id="rec-spo2" class="rec-hr-extra"></span>
|
||
<span id="rec-bp" class="rec-hr-extra"></span>
|
||
</div>
|
||
<div class="rec-stats">
|
||
<div class="rec-stat"><span id="rec-dist" class="rec-val">0 м</span><span class="rec-lbl">Дистанция</span></div>
|
||
<div class="rec-stat"><span id="rec-speed" class="rec-val">0</span><span class="rec-lbl">км/ч</span></div>
|
||
<div class="rec-stat"><span id="rec-time" class="rec-val">0:00</span><span class="rec-lbl">Время</span></div>
|
||
<div class="rec-stat"><span id="rec-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
|
||
<div class="rec-stat"><span id="rec-clock" class="rec-val">${new Date().toTimeString().slice(0,5)}</span><span class="rec-lbl">Часы</span></div>
|
||
<div class="rec-stat"><span id="rec-temp" class="rec-val">—</span><span class="rec-lbl">t°C</span></div>
|
||
</div>
|
||
<div class="rec-bottom">
|
||
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
|
||
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
|
||
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
|
||
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;
|
||
let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], 15);
|
||
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();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
// 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 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 = '<div class="rec-compass-arrow"></div>';
|
||
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);
|
||
|
||
// Roads toggle button
|
||
let roadsBtn = document.createElement("button");
|
||
roadsBtn.className = "rec-map-btn rec-roads-toggle";
|
||
roadsBtn.textContent = "R";
|
||
let roadLayers = [];
|
||
roadsBtn.onclick = async () => {
|
||
if (roadLayers.length > 0) {
|
||
roadLayers.forEach(l => map.removeLayer(l));
|
||
roadLayers = [];
|
||
roadsBtn.classList.remove("active");
|
||
localStorage.setItem("baldav_show_roads", "0");
|
||
return;
|
||
}
|
||
roadsBtn.classList.add("active");
|
||
localStorage.setItem("baldav_show_roads", "1");
|
||
// Roads live in their own pane BELOW route/track: tiles(200)<roads(240)<route(250)<track(400).
|
||
if (!map.getPane("roadsPane")) { map.createPane("roadsPane", map._rotatePane || undefined); map.getPane("roadsPane").style.zIndex = 240; }
|
||
let pts = await DB.getRoadPoints();
|
||
let seg = [];
|
||
for (let i = 0; i < pts.length; i++) {
|
||
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) {
|
||
if (seg.length >= 2) {
|
||
let color = roadColor(seg[0].atype);
|
||
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
|
||
}
|
||
seg = [];
|
||
}
|
||
}
|
||
seg.push(pts[i]);
|
||
}
|
||
if (seg.length >= 2) {
|
||
let color = roadColor(seg[0].atype);
|
||
roadLayers.push(L.polyline(seg.map(p => [p.lat, p.lon]), { color, weight: 3, opacity: 0.6, pane: "roadsPane" }).addTo(map));
|
||
}
|
||
};
|
||
btnWrap.appendChild(roadsBtn);
|
||
// Auto-load roads if was enabled
|
||
if (localStorage.getItem("baldav_show_roads") === "1") roadsBtn.click();
|
||
|
||
// Saved-route overlay TOGGLE (like the roads toggle). Shows/hides the active
|
||
// route (chosen in "Сохранённые маршруты"). Handy to hide it if you rode off-plan
|
||
// but don't want to stop the workout.
|
||
let routeBtn = document.createElement("button");
|
||
routeBtn.className = "rec-map-btn rec-route-toggle";
|
||
routeBtn.textContent = "М";
|
||
let routeLayer = null;
|
||
async function setRouteShown(shown) {
|
||
localStorage.setItem("baldav_route_shown", shown ? "1" : "0");
|
||
if (shown) {
|
||
routeLayer = await drawActiveRouteOverlay(map, routeLayer);
|
||
try { borderLine.bringToFront(); trailLine.bringToFront(); } catch {}
|
||
} else if (routeLayer) {
|
||
map.removeLayer(routeLayer); routeLayer = null;
|
||
}
|
||
routeBtn.classList.toggle("active", !!routeLayer);
|
||
}
|
||
routeBtn.onclick = () => setRouteShown(!routeLayer);
|
||
btnWrap.appendChild(routeBtn);
|
||
// 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";
|
||
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 = document.querySelector("#rec-status");
|
||
|
||
// 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);
|
||
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";
|
||
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 row = document.querySelector("#rec-hr-row");
|
||
if (!row) return;
|
||
row.style.display = "flex";
|
||
let hrEl = document.querySelector("#rec-hr-val");
|
||
if (hrEl) {
|
||
hrEl.textContent = reading.hr;
|
||
hrEl.className = "rec-hr-val " + hrZoneClass(reading.hr);
|
||
}
|
||
let spo2El = document.querySelector("#rec-spo2");
|
||
if (spo2El) spo2El.textContent = reading.spo2 ? `SpO2: ${reading.spo2}%` : "";
|
||
let bpEl = document.querySelector("#rec-bp");
|
||
if (bpEl) bpEl.textContent = (reading.bp_sys && reading.bp_dia) ? `${reading.bp_sys}/${reading.bp_dia}` : "";
|
||
};
|
||
|
||
// 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);
|
||
|
||
// 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];
|
||
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
|
||
? `<div class="supp-pick-divider">${r.name.replace(/---? ?/g, "")}</div>`
|
||
: `<div class="supp-pick-item" data-idx="${i}">${r.name}</div>`
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>Скачать карту</h3>
|
||
<div class="form-group">
|
||
<label>Регион</label>
|
||
<div id="m-region-list" class="supp-pick-list">${regionOpts}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Мин. зум</label>
|
||
<input id="m-zmin" type="number" value="12" min="1" max="18" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Макс. зум</label>
|
||
<input id="m-zmax" type="number" value="15" min="1" max="18" />
|
||
</div>
|
||
<div id="m-tile-count" style="color:var(--muted);font-size:12px;margin-top:4px"></div>`,
|
||
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;
|
||
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; }
|
||
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, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||
// Compute duration from points regardless
|
||
if (points.length >= 2) {
|
||
stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000);
|
||
}
|
||
// Always compute segments from points (needed for the Segments section)
|
||
if (!stats.segments && points.length >= 3) {
|
||
let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80);
|
||
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: "Бег", 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) : "—";
|
||
|
||
showModal(`
|
||
<h3>${actName}</h3>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Время</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.date}</span><span class="sd-lbl">Дата</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${duration}</span><span class="sd-lbl">Длительность</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.start_time ?? "—"}</span><span class="sd-lbl">Начало</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${session.end_time ?? "—"}</span><span class="sd-lbl">Конец</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${GPS.formatDistance(stats.distance)}</span><span class="sd-lbl">Расстояние</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${tempo}</span><span class="sd-lbl">Темп</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Маршрут</div>
|
||
<div id="sd-map" class="sd-map"></div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Скорость</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
|
||
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
|
||
</div>
|
||
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||
</div>
|
||
|
||
<div class="sd-section">
|
||
<div class="sd-title">Калории</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${session.total_kcal ?? stats.kcal} ккал</span><span class="sd-lbl">Всего</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${kcalMin} ккал/мин</span><span class="sd-lbl">Расход</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
${elevations.length > 0 ? `
|
||
<div class="sd-section">
|
||
<div class="sd-title">Высота</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${minElev} м</span><span class="sd-lbl">Мин.</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${maxElev} м</span><span class="sd-lbl">Макс.</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${stats.elevGain} м</span><span class="sd-lbl">Подъём</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${stats.elevLoss} м</span><span class="sd-lbl">Спуск</span></div>
|
||
</div>
|
||
<div class="chart-wrap" id="sd-elev-wrap"><canvas id="sd-elev-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||
<div class="chart-wrap" id="sd-hr-wrap" style="display:none"><canvas id="sd-hr-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||
</div>` : ""}
|
||
|
||
${(stats.segments && stats.segments.length > 1) ? `
|
||
<div class="sd-section">
|
||
<div class="sd-title">Сегменты</div>
|
||
<div class="climb-segments">
|
||
${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 `<div class="segment-item ${seg.type}">
|
||
<span class="seg-icon">${icon}</span>
|
||
<span class="seg-info">${distStr}, ${elevStr}</span>
|
||
<span class="seg-stats">${durStr} | ${seg.avgSpeed} км/ч</span>
|
||
</div>`;
|
||
}).join("")}
|
||
</div>
|
||
</div>` : ""}
|
||
|
||
<div class="sd-section" id="sd-goals-section" style="display:none">
|
||
<div class="sd-title">Цели</div>
|
||
<div id="sd-goals-list"></div>
|
||
</div>
|
||
|
||
<div class="sd-section" id="sd-food-cost-section" style="display:none">
|
||
<div class="sd-title">Стоимость тренировки</div>
|
||
<div id="sd-food-cost" class="food-cost"></div>
|
||
</div>
|
||
|
||
${session.bike_id ? `<div class="sd-section"><div class="sd-title">Велосипед</div><div class="sd-row"><div class="sd-stat"><span class="sd-val" id="sd-bike-name">...</span><span class="sd-lbl">Название</span></div></div></div>` : ''}
|
||
${session.activity_type === 'bike' ? `<div class="sd-section"><div class="sd-row"><div class="form-group" style="width:100%;margin:0"><label>Велосипед</label><select id="sd-bike-select" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)"><option value="">-- Нет --</option></select></div></div></div>` : ''}
|
||
<button id="sd-compare" class="btn-secondary full-width" style="margin-top:16px">Сравнить с...</button>
|
||
<button id="sd-recalc" class="btn-secondary full-width" style="margin-top:8px">Пересчитать</button>
|
||
<button id="sd-refine" class="btn-secondary full-width" style="margin-top:8px">Уточнить высоты (онлайн)</button>
|
||
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
|
||
<button id="sd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить тренировку</button>
|
||
`, () => {});
|
||
|
||
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/";
|
||
let map = L.map(mapEl, { attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
|
||
mapEl._leafletMap = map; // save ref for winStack collapse
|
||
// 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();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
// 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(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,
|
||
});
|
||
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 = `<b>Сглаживание (SG)</b>
|
||
<label>Окно ±: <span id="sm-wv">${smParams.W}</span></label><input id="sm-w" type="range" min="1" max="12" value="${smParams.W}">
|
||
<label>Итераций: <span id="sm-iv">${smParams.it}</span></label><input id="sm-i" type="range" min="1" max="8" value="${smParams.it}">
|
||
<div id="sm-stat" class="sm-stat"></div>
|
||
<button id="sm-save" class="btn-primary" style="width:100%;margin-top:6px">Сохранить сглаженный</button>
|
||
${session.smoothed ? '<button id="sm-revert" class="btn-secondary" style="width:100%;margin-top:6px">Вернуть оригинал</button>' : ''}`;
|
||
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; }
|
||
L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
|
||
L.polyline(seg, { color: GPS.config.trackColor || "#bb86fc", weight: 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);
|
||
}
|
||
|
||
// 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 : ""}`;
|
||
L.circleMarker([p.lat, p.lon], {
|
||
radius: 8, weight: 0, fillOpacity: 0, interactive: true,
|
||
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
|
||
}
|
||
|
||
// Ensure Leaflet has correct container size, then fit
|
||
map.invalidateSize();
|
||
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);
|
||
|
||
// 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);
|
||
|
||
// Recalculate button
|
||
let recalcBtn = document.querySelector("#sd-recalc");
|
||
if (recalcBtn) recalcBtn.onclick = async () => {
|
||
recalcBtn.textContent = "Пересчёт...";
|
||
recalcBtn.disabled = true;
|
||
let newStats = await GPS.recalculateSession(sessionId);
|
||
recalcBtn.textContent = "Пересчитать";
|
||
recalcBtn.disabled = false;
|
||
if (newStats) {
|
||
// Close modal and reopen with fresh data
|
||
if (winStack.length > 0) winStack.pop().close();
|
||
_skipPops = 1;
|
||
history.back();
|
||
showSessionDetail(sessionId);
|
||
}
|
||
};
|
||
|
||
let refineBtn = document.querySelector("#sd-refine");
|
||
if (refineBtn) refineBtn.onclick = async () => {
|
||
let reset = (t) => { refineBtn.textContent = t; refineBtn.disabled = false; };
|
||
refineBtn.disabled = true;
|
||
refineBtn.textContent = "Загрузка высот...";
|
||
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);
|
||
let terr = await fetchElevations(sampleIdx.map(i => pts[i]));
|
||
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 = "Сохранение...";
|
||
await DB.updatePointsSrtm(rows);
|
||
await GPS.recalculateSession(sessionId);
|
||
if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам");
|
||
if (winStack.length > 0) winStack.pop().close();
|
||
_skipPops = 1;
|
||
history.back();
|
||
showSessionDetail(sessionId);
|
||
} 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 = `<span class="goal-progress goal-better">На ${diff >= 1000 ? (diff / 1000).toFixed(1) + " км" : diff + " м"} ближе (было ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"}, ${r.bestPrevDate})</span>`;
|
||
} else if (diff < 0) {
|
||
progressHtml = `<span class="goal-progress goal-worse">Рекорд: ${r.bestPrevDist >= 1000 ? (r.bestPrevDist / 1000).toFixed(1) + " км" : r.bestPrevDist + " м"} (${r.bestPrevDate})</span>`;
|
||
} else {
|
||
progressHtml = `<span class="goal-progress">Повторение рекорда</span>`;
|
||
}
|
||
}
|
||
// 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 `<div class="goal-marker-item">
|
||
<div class="goal-marker-name">${esc(r.name)}</div>
|
||
<div class="goal-marker-dist">Ближайшая точка: ${distStr} (точка #${r.closestIdx})</div>
|
||
<div class="goal-bar-wrap"><div class="goal-bar-fill" style="width:${pct}%"></div></div>
|
||
${progressHtml}
|
||
</div>`;
|
||
}).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 = `<div class="food-cost-total">${Math.round(sessionKcal)} ккал — это как:</div>
|
||
<div class="food-cost-items">${lines.map(l => `<div class="food-cost-line">${l}</div>`).join('<div class="food-cost-or">или</div>')}</div>`;
|
||
}
|
||
} 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(`<h3>Сравнение</h3><p style="color:var(--muted)">Нет похожих тренировок (та же активность, расстояние +-30%).</p>`, () => {});
|
||
return;
|
||
}
|
||
|
||
let actNames = { bike: "Вело", walk: "Ходьба", run: "Бег", other: "Другое" };
|
||
showModal(`
|
||
<h3>Сравнить с...</h3>
|
||
<div class="compare-list">
|
||
${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 `<div class="compare-item" data-sid="${s.id_str ?? s.id}">
|
||
<div style="font-weight:600">${s.date} ${s.start_time ?? ""}</div>
|
||
<div style="font-size:12px;color:var(--muted)">${d} | ${spd}</div>
|
||
</div>`;
|
||
}).join("")}
|
||
</div>`, () => {});
|
||
|
||
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(`
|
||
<h3>Сравнение треков</h3>
|
||
<div class="sd-section">
|
||
<div class="sd-title">Результат</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val" style="color:${durDiff <= 0 ? "#4caf50" : "#f44336"}">${durLabel}</span><span class="sd-lbl">Время</span></div>
|
||
</div>
|
||
<div class="sd-row">
|
||
<div class="sd-stat"><span class="sd-val">${newAvg} км/ч</span><span class="sd-lbl">Новый (${newerSession.date})</span></div>
|
||
<div class="sd-stat"><span class="sd-val">${oldAvg} км/ч</span><span class="sd-lbl">Старый (${olderSession.date})</span></div>
|
||
<div class="sd-stat"><span class="sd-val" style="color:${spdDiff >= 0 ? "#4caf50" : "#f44336"}">${spdDiff > 0 ? "+" : ""}${spdDiff}</span><span class="sd-lbl">Разница</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="sd-section">
|
||
<div class="sd-title">Карта</div>
|
||
<div style="font-size:11px;color:var(--muted);margin-bottom:4px">Зелёные участки — прогресс (новый быстрее). Красные — регресс (новый медленнее).</div>
|
||
<div id="compare-map" class="sd-map" style="height:300px"></div>
|
||
</div>
|
||
`, () => {});
|
||
|
||
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, { attributionControl: false, zoomControl: false, touchZoom: true, dragging: true });
|
||
mapEl._leafletMap = map;
|
||
addZoomDisplay(map);
|
||
let CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
// 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(`
|
||
<h3>Добавки</h3>
|
||
<p style="color:var(--muted)">Нет добавок. Создайте на вкладке "Добавки".</p>`,
|
||
() => {});
|
||
return;
|
||
}
|
||
|
||
showModal(`
|
||
<h3>Принял добавку</h3>
|
||
<div class="form-group">
|
||
<label>Добавка</label>
|
||
<div id="m-supp-list" class="supp-pick-list">
|
||
${supps.map(s => `<div class="supp-pick-item" data-sid="${s.id}">${esc(s.name)}${s.daily_dose_g ? ` <small>(${s.daily_dose_g}г/день)</small>` : ""}</div>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Количество (шт)</label>
|
||
<input id="m-qty" type="number" inputmode="decimal" value="1" step="0.5" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Время</label>
|
||
<input id="m-time" type="time" value="${nowTime()}" />
|
||
</div>`,
|
||
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 = `
|
||
<div class="products-screen">
|
||
<div class="section-header">
|
||
<h2>Добавки</h2>
|
||
<span style="display:flex;gap:6px">
|
||
<button class="add-btn" id="import-supps-btn" style="font-size:12px">Импорт</button>
|
||
<button class="add-btn" id="export-supps-btn" style="font-size:12px">Экспорт</button>
|
||
<button class="add-btn" id="add-new-supp">+</button>
|
||
</span>
|
||
</div>
|
||
<div id="supp-list" class="prod-list">
|
||
${supps.length ? supps.map(s => `
|
||
<div class="prod-card" data-sid="${s.id}">
|
||
<div class="prod-card-header">
|
||
<div class="prod-card-name">${esc(s.name)}
|
||
${s.daily_dose_g ? `<span class="prod-weight">${s.daily_dose_g} шт/день</span>` : ""}
|
||
</div>
|
||
</div>
|
||
<div class="supp-summary">${suppSummary(s)}</div>
|
||
</div>`).join("")
|
||
: `<div class="prod-hint">Нет добавок. Нажмите + чтобы добавить.</div>`}
|
||
</div>
|
||
</div>`;
|
||
|
||
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 `<div class="form-group micro-field">
|
||
<label>${f.label}</label>
|
||
<input id="m-${f.key}" type="number" inputmode="decimal" step="any" value="${displayVal}" placeholder="${(() => { let r = BMR.RDI[f.key]; let t = BMR.RDI_TYPES[f.key]; return r ? r + (t === 'aup' ? '*' : '') : '—'; })()}" />
|
||
</div>`;
|
||
}
|
||
).join("");
|
||
|
||
showModal(`
|
||
<h3>${isNew ? "Новая добавка" : "Редактирование"}</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="m-name" type="text" value="${esc(s.name ?? "")}" placeholder="Витамин D3" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Таблеток в день</label>
|
||
<input id="m-dose" type="number" inputmode="decimal" step="0.5" value="${s.daily_dose_g ?? ""}" placeholder="1" />
|
||
</div>
|
||
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Содержание в 1 таблетке:</p>
|
||
<div class="micro-grid">${microHtml}</div>
|
||
${!isNew ? `<button id="m-del-supp" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить добавку</button>` : ""}`,
|
||
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 = `
|
||
<div class="settings-screen">
|
||
<h2>Настройки</h2>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-item" id="s-open-profile">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Профиль</div>
|
||
<div class="settings-item-desc">${profileSummary || "Не заполнен"}</div>
|
||
</div>
|
||
<span class="settings-arrow">›</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Нормы нутриентов</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Стандарт РСП</div>
|
||
<div class="settings-item-desc">Пересчитает % нутриентов</div>
|
||
</div>
|
||
<select id="s-rdi-standard" style="width:80px;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||
<option value="RU">РФ</option>
|
||
<option value="EU">ЕС</option>
|
||
<option value="US">США</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Честный знак</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Сессия</div>
|
||
<div class="settings-item-desc" id="s-chzn-status">${(() => {
|
||
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")}`;
|
||
})()}</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-chzn-import" style="padding:6px 12px;font-size:13px;">Импорт</button>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Обновить сессию</div>
|
||
<div class="settings-item-desc">Продлить sid через refresh (проверка связи)</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-chzn-refresh" style="padding:6px 12px;font-size:13px;">Обновить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Карты</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Оффлайн режим</div>
|
||
<div class="settings-item-desc">Тайлы только из кэша</div>
|
||
</div>
|
||
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Импорт тайлов</div>
|
||
<div class="settings-item-desc">Загрузить .db файл с тайлами региона</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Очистить продукты без магазина</div>
|
||
<div class="settings-item-desc">Удалить неиспользованные продукты без метки источника (health-diet, фастфуд). Съеденные и в рецептах остаются.</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-clean-products" style="padding:6px 12px;font-size:13px;">Очистить</button>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Скачать с роутера</div>
|
||
<div class="settings-item-desc">LAN: 192.168.1.1</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-download-tiles" style="padding:6px 12px;font-size:13px;">Регионы</button>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Кэш тайлов</div>
|
||
<div class="settings-item-desc" id="s-tile-count">...</div>
|
||
</div>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Просмотр дорог</div>
|
||
<div class="settings-item-desc">Все проезженные маршруты на карте</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-roads-map" style="padding:6px 12px;font-size:13px;">Открыть</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">GPS (запись треков)</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Ниже = пауза</div>
|
||
</div>
|
||
<input id="s-min-speed" type="number" step="0.1" value="${(GPS.config.minSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. погрешность GPS (м)</div>
|
||
<div class="settings-item-desc">Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно. Применяется и при пересчёте трека — снизьте и нажмите «Пересчитать», чтобы отбросить неточные точки (обратимо: поднимите обратно).</div>
|
||
</div>
|
||
<input id="s-min-acc" type="number" step="1" value="${GPS.config.minAccuracyM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. расстояние (м)</div>
|
||
<div class="settings-item-desc">Точки ближе = пропуск</div>
|
||
</div>
|
||
<input id="s-min-dist" type="number" step="0.5" value="${GPS.config.minDistanceM}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Выше = глитч GPS, игнор</div>
|
||
</div>
|
||
<input id="s-max-speed" type="number" step="1" value="${GPS.config.maxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. пауза GPS (сек)</div>
|
||
<div class="settings-item-desc">Пробел длиннее = новый сегмент</div>
|
||
</div>
|
||
<input id="s-gap-threshold" type="number" step="5" value="${GPS.config.gapThresholdSec}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Аэросопротивление</div>
|
||
<div class="settings-item-desc">Город 0.6, спорт 0.4</div>
|
||
</div>
|
||
<input id="s-air-drag" type="number" step="0.05" value="${GPS.config.airDrag}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Плотность воздуха (кг/м3)</div>
|
||
<div class="settings-item-desc">Море 1.225, горы 1.0</div>
|
||
</div>
|
||
<input id="s-air-density" type="number" step="0.01" value="${GPS.config.airDensity}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">КПД педалирования (%)</div>
|
||
<div class="settings-item-desc">Человек ~25%</div>
|
||
</div>
|
||
<input id="s-pedal-eff" type="number" step="1" value="${GPS.config.pedalEfficiency}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Сглаживание высоты</div>
|
||
<div class="settings-item-desc">Полуокно (5=11 точек, 7=15)</div>
|
||
</div>
|
||
<input id="s-alt-smooth" type="number" step="1" value="${GPS.config.altSmoothHalf}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Ходьба / Бег</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Макс. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Выше = глитч GPS</div>
|
||
</div>
|
||
<input id="s-walk-max-speed" type="number" step="1" value="${GPS.config.walkMaxSpeedKmh}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Мин. скорость (км/ч)</div>
|
||
<div class="settings-item-desc">Ниже = стоит</div>
|
||
</div>
|
||
<input id="s-walk-min-speed" type="number" step="0.1" value="${(GPS.config.walkMinSpeedMs * 3.6).toFixed(1)}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px;">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left"><div class="settings-item-title">Цвет текущего трека</div><div class="settings-item-desc">Линия записи/трека (чтобы не путать с дорогами)</div></div>
|
||
<input id="s-track-color" type="color" value="${GPS.config.trackColor || '#bb86fc'}" style="width:40px;height:28px;border:none;background:none;padding:0">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: вело</div></div>
|
||
<input id="s-road-color-bike" type="color" value="${GPS.config.roadColorBike || '#2266aa'}" style="width:40px;height:28px;border:none;background:none;padding:0">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: ходьба</div></div>
|
||
<input id="s-road-color-walk" type="color" value="${GPS.config.roadColorWalk || '#8B5E3C'}" style="width:40px;height:28px;border:none;background:none;padding:0">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left"><div class="settings-item-title">Цвет дорог: бег</div></div>
|
||
<input id="s-road-color-run" type="color" value="${GPS.config.roadColorRun || '#e67e22'}" style="width:40px;height:28px;border:none;background:none;padding:0">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Fused-локация (Google Play)</div>
|
||
<div class="settings-item-desc">Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.</div>
|
||
</div>
|
||
<input id="s-use-fused" type="checkbox" ${GPS.config.useFusedLocation ? "checked" : ""} style="width:20px;height:20px;">
|
||
</div>
|
||
<div class="settings-item" style="flex-direction:column;align-items:stretch;gap:6px">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">GPXZ API ключ (высоты маршрутов)</div>
|
||
<div class="settings-item-desc">Точные высоты Copernicus GLO-30 (лучше SRTM для уклонов). Пусто = open-elevation. Ключ хранится только на устройстве, не в git.</div>
|
||
</div>
|
||
<input id="s-gpxz-key" type="text" autocomplete="off" placeholder="ak_..." value="${esc(GPS.config.gpxzKey || '')}" style="width:100%;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:6px 8px;">
|
||
</div>
|
||
<div class="settings-item" id="s-gps-save" style="cursor:pointer;justify-content:center">
|
||
<div class="settings-item-title" style="color:var(--primary)">Сохранить настройки GPS</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Велосипеды</div>
|
||
<div id="s-bikes-list"></div>
|
||
<div class="settings-item" id="s-add-bike" style="cursor:pointer">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title" style="color:var(--primary)">+ Добавить велосипед</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Разрешения</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Геолокация (GPS)</div>
|
||
<div class="settings-item-desc" id="s-gps-status">Проверка...</div>
|
||
</div>
|
||
<button class="settings-toggle" id="s-gps-toggle"></button>
|
||
</div>
|
||
<div class="settings-item" id="s-bgloc-item" style="cursor:pointer">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Фоновая геолокация</div>
|
||
<div class="settings-item-desc">Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».</div>
|
||
</div>
|
||
<button class="btn-secondary" id="s-bgloc-btn">Разрешить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Нормы ЧСС и давления</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">ЧСС покой (мин / макс)</div>
|
||
</div>
|
||
<div style="display:flex;gap:4px">
|
||
<input id="s-hr-rest-min" type="number" value="${state.profile.hr_rest_min || 60}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
|
||
<input id="s-hr-rest-max" type="number" value="${state.profile.hr_rest_max || 100}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
|
||
</div>
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">ЧСС макс</div>
|
||
<div class="settings-item-desc">По умолчанию 220 - возраст</div>
|
||
</div>
|
||
<input id="s-hr-max" type="number" value="${state.profile.hr_max || (220 - BMR.getAge(parseInt(state.profile.birth_year) || 1988))}" style="width:60px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px 6px">
|
||
</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">Давление норма (сис / диа)</div>
|
||
</div>
|
||
<div style="display:flex;gap:4px">
|
||
<input id="s-bp-sys" type="number" value="${state.profile.bp_sys_norm || 120}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
|
||
<input id="s-bp-dia" type="number" value="${state.profile.bp_dia_norm || 80}" style="width:50px;text-align:right;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg);padding:4px">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Пульсометр (BLE)</div>
|
||
<div class="settings-item" id="s-ble-device">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title" id="s-ble-name">${state.profile.ble_device_name || "Не привязан"}</div>
|
||
<div class="settings-item-desc" id="s-ble-status">${state.profile.ble_device_addr ? state.profile.ble_device_addr : "Нажмите для поиска"}</div>
|
||
</div>
|
||
<div style="display:flex;gap:6px">
|
||
${state.profile.ble_device_addr ? `<button id="s-ble-unbind" style="padding:6px 12px;font-size:13px;background:#cf6679;color:#fff;border:none;border-radius:4px">Отвязать</button>` : ""}
|
||
<button class="btn-primary" id="s-ble-scan" style="padding:6px 12px;font-size:13px">Поиск</button>
|
||
</div>
|
||
</div>
|
||
<div id="s-ble-results" style="padding:0 14px"></div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">Синхронизация</div>
|
||
<div class="settings-item">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title" id="s-sync-status">${Sync.userId ? "Подключено" : "Не настроено"}</div>
|
||
<div class="settings-item-desc" id="s-sync-desc">${Sync.userId ? (state.profile.sync_last_time || "Не синхронизировано") : "Привяжите устройство"}</div>
|
||
</div>
|
||
<button class="btn-primary" id="s-sync-now" style="padding:6px 12px;font-size:13px">${Sync.userId ? "Синхр." : "Настроить"}</button>
|
||
</div>
|
||
<div id="s-sync-setup" style="padding:0 14px;display:${Sync.userId ? "none" : ""}">
|
||
<div class="form-group" style="margin-top:8px">
|
||
<label>Код привязки (с другого устройства)</label>
|
||
<div style="display:flex;gap:6px">
|
||
<input id="s-sync-code" type="text" maxlength="6" placeholder="ABC123" style="flex:1;text-transform:uppercase" />
|
||
<button id="s-sync-pair" class="btn-primary" style="padding:6px 12px;font-size:13px">Привязать</button>
|
||
</div>
|
||
</div>
|
||
<button id="s-sync-register" class="btn-secondary full-width" style="margin-top:8px;font-size:13px">Создать новый аккаунт</button>
|
||
</div>
|
||
<div id="s-sync-info" style="padding:0 14px;display:${Sync.userId ? "" : "none"}">
|
||
<button id="s-sync-show-code" class="btn-secondary full-width" style="margin-top:8px;font-size:13px">Показать код для привязки</button>
|
||
<div id="s-sync-code-display" style="display:none;margin-top:8px;text-align:center;font-size:28px;font-weight:bold;letter-spacing:4px;color:var(--primary)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-title">О приложении</div>
|
||
<div class="settings-item" id="s-about">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">BalDav</div>
|
||
<div class="settings-item-desc" id="s-version">...</div>
|
||
</div>
|
||
<div id="s-update-area"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// 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));
|
||
}
|
||
};
|
||
|
||
// 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
|
||
? `<div class="supp-pick-item" data-idx="${i}">${esc(r.label)} <span style="color:var(--muted);font-size:12px">${r.f.sizeMB ? r.f.sizeMB + " MB" : ""}</span></div>`
|
||
: `<div class="supp-pick-item" style="opacity:.4;pointer-events:none">${esc(r.label)} <span style="font-size:12px">нет на роутере</span></div>`
|
||
).join("");
|
||
|
||
// Tap a map to start downloading it; the OK button is unused here.
|
||
showModal(`
|
||
<h3>Карты на роутере</h3>
|
||
<div class="prod-hint">Нажмите на карту, чтобы скачать и импортировать</div>
|
||
<div id="t-file-list" class="supp-pick-list">${items}</div>`,
|
||
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 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 => `
|
||
<div class="settings-item" data-bike-id="${b.id}" style="cursor:pointer">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">${esc(b.name)}${b.is_active ? ' <span style="color:var(--primary);font-size:12px">[активный]</span>' : ''}</div>
|
||
<div class="settings-item-desc">${b.weight_kg ? b.weight_kg + ' кг' : ''}${b.surface_type ? ' / ' + ({asphalt:'Асфальт',mixed:'Смешанный',gravel:'Грунт'}[b.surface_type] || b.surface_type) : ''}</div>
|
||
</div>
|
||
<span class="settings-arrow">›</span>
|
||
</div>`).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();
|
||
};
|
||
}
|
||
|
||
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 = `<div style="padding:8px 0;color:var(--muted);font-size:13px">Сканирование 5 сек...</div>`;
|
||
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 = `<div style="padding:8px 0;color:var(--muted);font-size:13px">Устройства не найдены</div>`;
|
||
} else {
|
||
bleResults.innerHTML = devices.map(d =>
|
||
`<div class="settings-item" style="cursor:pointer" data-ble-addr="${d.address}" data-ble-type="${d.device_type}" data-ble-name="${esc(d.name)}">
|
||
<div class="settings-item-left">
|
||
<div class="settings-item-title">${esc(d.name)}</div>
|
||
<div class="settings-item-desc">${d.address} (${d.device_type}) ${d.rssi}dBm</div>
|
||
</div>
|
||
<span class="settings-arrow">›</span>
|
||
</div>`
|
||
).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 = `<div style="padding:8px 0;color:#cf6679;font-size:13px">${e}</div>`;
|
||
}
|
||
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 = `<small style="color:var(--muted)">проверка...</small>`;
|
||
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 = `<button id="s-update-btn" class="btn-primary" style="padding:6px 14px;font-size:13px">
|
||
Обновить (${state.updateAvailable.remoteBuild})
|
||
</button>`;
|
||
container.querySelector("#s-update-btn").onclick = () => startUpdate();
|
||
}
|
||
|
||
function showProfileModal() {
|
||
let p = state.profile;
|
||
let selectedSex = p.sex ?? "m";
|
||
|
||
showModal(`
|
||
<h3>Профиль</h3>
|
||
<div class="form-group">
|
||
<label>Вес (кг)</label>
|
||
<input id="m-weight" type="number" inputmode="decimal" step="0.1" value="${p.weight_kg ?? ""}" placeholder="80" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Рост (см)</label>
|
||
<input id="m-height" type="number" inputmode="numeric" value="${p.height_cm ?? ""}" placeholder="178" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Год рождения</label>
|
||
<input id="m-year" type="number" inputmode="numeric" value="${p.birth_year ?? ""}" placeholder="1988" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Пол</label>
|
||
<div class="type-chips">
|
||
<button class="type-chip ${(p.sex ?? "m") === "m" ? "selected" : ""}" data-sex="m">М</button>
|
||
<button class="type-chip ${p.sex === "f" ? "selected" : ""}" data-sex="f">Ж</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>BMR (Mifflin-St Jeor)</label>
|
||
<div style="padding:6px 0;color:var(--muted);font-size:14px" id="m-bmr-display">...</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Образ жизни (без учёта тренировок)</label>
|
||
<select id="m-activity-level" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||
<option value="1.2">Сидячий (офис, минимум движения)</option>
|
||
<option value="1.375">Лёгкая активность (ходьба, быт)</option>
|
||
<option value="1.55">Умеренная (стоячая работа, прогулки)</option>
|
||
<option value="1.725">Высокая (физический труд)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Норма воды (мл/день)</label>
|
||
<input id="m-water-norm" type="number" inputmode="numeric" value="${p.daily_water_ml ?? "2000"}" placeholder="2000" />
|
||
</div>`,
|
||
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 waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
|
||
await DB.setProfileValue("daily_water_ml", waterNorm);
|
||
|
||
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;
|
||
}
|
||
}, 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(`
|
||
<h3>${isNew ? "Новый велосипед" : "Редактирование"}</h3>
|
||
<div class="form-group">
|
||
<label>Название</label>
|
||
<input id="m-bike-name" type="text" value="${esc(bike.name ?? "")}" placeholder="Мой велосипед" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Вес велосипеда (кг)</label>
|
||
<input id="m-bike-weight" type="number" inputmode="decimal" step="0.1" value="${bike.weight_kg ?? ""}" placeholder="12" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Диаметр колеса (дюймы)</label>
|
||
<input id="m-bike-wheel" type="number" inputmode="decimal" step="0.5" value="${bike.wheel_diameter_inch ?? 26}" placeholder="26" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Передняя звезда (зубцы)</label>
|
||
<input id="m-bike-chainring" type="number" inputmode="numeric" value="${bike.chainring_teeth ?? ""}" placeholder="42" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Задняя звезда (зубцы)</label>
|
||
<input id="m-bike-cog" type="number" inputmode="numeric" value="${bike.cog_teeth ?? ""}" placeholder="18" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Покрытие</label>
|
||
<select id="m-bike-surface" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--on-bg)">
|
||
<option value="asphalt"${surfaceVal === "asphalt" ? " selected" : ""}>Асфальт</option>
|
||
<option value="mixed"${surfaceVal === "mixed" ? " selected" : ""}>Смешанный</option>
|
||
<option value="gravel"${surfaceVal === "gravel" ? " selected" : ""}>Грунт</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Давление в шинах (бар)</label>
|
||
<input id="m-bike-pressure" type="number" inputmode="decimal" step="0.1" value="${bike.tire_pressure_bar ?? 3.0}" placeholder="3.0" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Активный велосипед</label>
|
||
<div class="type-chips">
|
||
<button class="type-chip ${bike.is_active ? "selected" : ""}" id="m-bike-active-yes" data-active="1">Да</button>
|
||
<button class="type-chip ${!bike.is_active ? "selected" : ""}" id="m-bike-active-no" data-active="0">Нет</button>
|
||
</div>
|
||
</div>
|
||
${!isNew ? `
|
||
<div class="sd-section" style="margin-top:20px">
|
||
<div class="sd-title">Обслуживание</div>
|
||
<div id="m-bike-mileage" style="font-size:13px;color:var(--muted);margin-bottom:8px"></div>
|
||
<div id="m-bike-maint-list"></div>
|
||
<button id="m-bike-maint-add" class="btn-secondary full-width" style="margin-top:8px">+ Добавить</button>
|
||
<div id="m-bike-maint-form" style="display:none;margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:var(--radius)">
|
||
<div class="form-group"><label>Дата</label><input id="m-maint-date" type="date" value="${new Date().toISOString().slice(0,10)}" /></div>
|
||
<div class="form-group"><label>Описание</label><input id="m-maint-desc" type="text" placeholder="Смазка цепи, подкачка шин..." /></div>
|
||
<div class="form-group"><label>След. обслуживание через (км)</label><input id="m-maint-next" type="number" inputmode="decimal" step="1" placeholder="100" /></div>
|
||
<div class="form-group"><label>Заметки</label><input id="m-maint-notes" type="text" placeholder="" /></div>
|
||
<button id="m-maint-save" class="btn-primary full-width">Сохранить запись</button>
|
||
</div>
|
||
</div>
|
||
` : ""}
|
||
${!isNew ? `<button id="m-del-bike" class="btn-secondary full-width" style="margin-top:12px;color:#cf6679">Удалить велосипед</button>` : ""}`,
|
||
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 = '<div style="font-size:13px;color:var(--muted);padding:8px 0">Нет записей</div>';
|
||
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 `<div class="maint-item${overdue ? " maint-overdue" : ""}" data-mid="${e.id}">
|
||
<div class="maint-info">
|
||
<span class="maint-date">${dateShort}</span>
|
||
<span class="maint-desc">${esc(e.description)}</span>
|
||
${e.mileage_km != null ? `<span class="maint-km">(${e.mileage_km} км${nextInfo})</span>` : ""}
|
||
${overdue ? '<span class="maint-warn">[!]</span>' : ""}
|
||
</div>
|
||
<button class="maint-del" data-mid="${e.id}">×</button>
|
||
</div>`;
|
||
}).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(`<span class="micro-chip ${cls}">${NUTRIENT_LABELS[key] || key} ${pct}%</span>`);
|
||
}
|
||
if (items.length === 0) return "";
|
||
return `<div class="micro-strip">${items.join("")}</div>`;
|
||
}
|
||
|
||
// 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 `<div class="food-nutrients">${items.join(", ")}</div>`;
|
||
}
|
||
|
||
// 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 ? `<div class="nut-item" style="font-weight:bold;opacity:0.6;min-width:auto">${title}</div>` : "";
|
||
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 `<div class="nut-item ${cls}">${n.label}${star} ${n.pct}%</div>`;
|
||
}).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 = `
|
||
<input type="search" class="search-input" placeholder="${esc(opts.placeholder || "Поиск продукта...")}" />
|
||
<div class="search-dropdown" style="display:none"></div>`;
|
||
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 =>
|
||
`<div class="sd-item" data-pid="${p.id}">
|
||
<span>${esc(p.name)} ${sourceBadge(p.source)}</span>
|
||
<span class="sd-item-kcal">${p.kcal_per_100g != null ? Math.round(p.kcal_per_100g) + " кк" : "?"}</span>
|
||
</div>`
|
||
).join("") || `<div class="prod-hint">Не найдено</div>`;
|
||
|
||
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 => `<span class="sleep-line">${l}</span>`).join("") ||
|
||
`<span class="log-empty">нет данных</span>`;
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
async function showRoadsMap() {
|
||
let container = document.querySelector(".screen-content");
|
||
container.innerHTML = `
|
||
<div style="display:flex;flex-direction:column;height:100%;">
|
||
<div style="display:flex;align-items:center;padding:8px 12px;padding-top:calc(8px + var(--safe-area-top));background:var(--surface);border-bottom:1px solid var(--border);gap:8px;">
|
||
<button id="roads-back" class="btn-secondary" style="padding:6px 12px;">Назад</button>
|
||
<span style="flex:1;font-weight:600;">Дороги</span>
|
||
<span id="roads-info" style="font-size:12px;color:var(--muted);"></span>
|
||
<button id="roads-rebuild" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Перестроить</button>
|
||
</div>
|
||
<div id="roads-map" style="flex:1;"></div>
|
||
</div>`;
|
||
|
||
document.querySelector("#roads-back").onclick = () => renderScreen("settings");
|
||
|
||
let mapEl = document.querySelector("#roads-map");
|
||
L.Icon.Default.imagePath = "lib/";
|
||
let map = L.map(mapEl, { attributionControl: false }).setView([47.42, 40.08], 13);
|
||
addZoomDisplay(map);
|
||
let CachedLayer = TileCache.createLayer();
|
||
new CachedLayer({ maxZoom: 19 }).addTo(map);
|
||
|
||
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 = "...";
|
||
infoEl.textContent = "Построение (может занять минуту)...";
|
||
try {
|
||
let count = await DB.rebuildRoads();
|
||
infoEl.textContent = `${count} точек`;
|
||
rebuildBtn.textContent = "Перестроить";
|
||
rebuildBtn.disabled = false;
|
||
showRoadsMap();
|
||
} catch (e) {
|
||
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 points into segments by gap (>100m) and activity type
|
||
let segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
|
||
let seg = [];
|
||
let segType = pts[0]?.atype || "b";
|
||
for (let i = 0; i < pts.length; i++) {
|
||
let newType = pts[i].atype || "b";
|
||
if (seg.length > 0) {
|
||
let prev = seg[seg.length - 1];
|
||
let dlat = (pts[i].lat - prev[0]) * 111000;
|
||
let dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
|
||
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
|
||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||
seg = [];
|
||
segType = newType;
|
||
}
|
||
}
|
||
seg.push([pts[i].lat, pts[i].lon]);
|
||
}
|
||
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
|
||
|
||
// Draw: bike = blue, walk = brown
|
||
let allCoords = [];
|
||
for (let s of segments) {
|
||
let color = roadColor(s.atype);
|
||
L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map);
|
||
allCoords.push(...s.coords);
|
||
}
|
||
|
||
if (allCoords.length > 0) {
|
||
map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
|
||
}
|
||
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 `<span class="src-badge ${cls}">${label}</span>`;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function esc(str) {
|
||
return String(str ?? "")
|
||
.replace(/&/g, "&").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 = `<div class="loading" style="color:#cf6679">Ошибка: ${esc(String(err))}</div>`;
|
||
});
|