baldav/frontend/js/main.js
4aiman 1101016ef4 hydration: spoken 'hydrate now' alert, per-activity sweat, drink reconciliation
- signal is now a loud TTS 'hydrate now' on the ALARM stream (TTS warmed at
  session start), so it is unmistakably BalDav and audible when the phone is not
  in view (walking/VR); the old ringtone was indistinguishable from a system alarm.
- sweat need scales by activity instead of a flat 600 ml/h: bike 600, walk 200,
  run 450, workout/vr 250 ml/h. Walking no longer nags every 15 min.
- deficit split into baseDeficit (seeded from the food log) + accrued sweat;
  logging a drink mid-session recomputes the base and re-seeds via seedSeq, so it
  silences the reminder. seedSeq=0 (profile save / init) never zeroes it mid-ride.
- persistent 'БалДав: попей воды (HH:MM)' notification left as a record.
2026-10-27 21:47:10 +03:00

9539 lines
454 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// BalDav main app — pure JS, no framework, no bundler.
// Depends on window.DB (db.js) and window.BMR (bmr.js) loaded before this script.
// let DB = window.DB;
// let GPS = window.GPS;
// let BMR = window.BMR;
// let TileCache = window.TileCache;
// Disable leaflet-rotate's compass control on all maps by default;
// only the recording map enables rotate: true explicitly.
if (L && L.Map) L.Map.mergeOptions({ rotateControl: false });
let app = document.getElementById("app");
let UPDATE_BASE_URL = "https://4aiman.top/baldav";
let ACTIVITY_MET = { squats: 6.0, jumprope: 12.3, stretch: 2.3, swim: 8.0, manual: 3.0 };
let ACTIVITY_NAMES = { squats: "Приседания", jumprope: "Скакалка", stretch: "Растяжки", swim: "Бассейн", manual: "Другое" };
function activityKcal(type, minutes, weightKg) {
let met = ACTIVITY_MET[type] || 3.0;
return Math.round(met * 3.5 * weightKg * minutes / 200);
}
let state = {
screen: "today",
date: todayIso(),
profile: {},
latestWeight: null,
updateAvailable: null, // { remoteBuild: number } or null
};
// Parse amount string: supports "4*30" (=120), "2*150" (=300), "300+50" (=350), plain "250"
function parseAmount(str) {
if (!str) return NaN;
let s = str.toString().trim().replace(/,/g, ".").replace(/х/gi, "*").replace(/x/gi, "*");
if (s.includes("*")) {
let parts = s.split("*").map(Number);
if (parts.length === 2 && parts.every(n => !isNaN(n))) return parts[0] * parts[1];
return NaN;
}
if (s.includes("+")) {
let parts = s.split("+").map(Number);
if (parts.every(n => !isNaN(n))) return parts.reduce((a, b) => a + b, 0);
return NaN;
}
return parseFloat(s);
}
// Check for app updates by comparing BUILD_NUMBER with remote
async function checkForUpdate() {
try {
// Kotlin checks BUILD_NUMBER via native HTTP (works through VPN/proxy)
// and writes result to update_info.json, Rust reads it
let raw = await window.__TAURI__.core.invoke("get_update_info");
let info = JSON.parse(raw);
if (info.remote && info.local && info.remote > info.local) {
state.updateAvailable = { remoteBuild: info.remote, localBuild: info.local, url: info.url || null };
console.log(`[Update] available: ${info.local} -> ${info.remote} url=${info.url}`);
} else {
state.updateAvailable = null;
}
} catch (e) {
console.log("[Update] check failed:", e.message || e);
}
}
async function startUpdate() {
if (!state.updateAvailable) return;
let url = state.updateAvailable.url || (UPDATE_BASE_URL + "/baldav.apk");
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "open_url:" + url });
} catch (e) {
console.error("[Update] open browser failed:", e);
}
}
// Navigation history stack — survives in-memory across screen changes.
// When app is restored from background, we replay the stack into browser history.
let NAV_MAX = 20;
// Restore nav stack from localStorage so back gesture works after app restart
let navStack = (() => {
try {
let saved = JSON.parse(localStorage.getItem("baldav_nav_stack"));
if (Array.isArray(saved) && saved.length > 0) return saved;
} catch {}
return ["today"];
})();
function persistNavStack() {
try { localStorage.setItem("baldav_nav_stack", JSON.stringify(navStack)); } catch {}
}
// ---------------------------------------------------------------------------
// Window stack — single global stack for ALL overlays (modals, fullscreen).
// Every open = push. Every back = pop top. One popstate handler.
// ---------------------------------------------------------------------------
let winStack = []; // { type, close() }
let _skipPops = 0; // counter: how many popstate events to skip (from closeModal's history.go)
function winPush(type, closeFn) {
winStack.push({ type, close: closeFn });
history.pushState({ win: type }, "");
}
function winPopTop() {
if (winStack.length === 0) return false;
winStack.pop().close();
return true;
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
async function init() {
applyDarkMap(); // restore dark-map tile filter preference before any map renders
// Hydration reminder is ON by default — write the config so the native service has it (the
// native side also defaults ON when the file is missing; this keeps file + UI consistent).
try {
let on = localStorage.getItem("baldav_hydr_on") !== "0";
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
window.__TAURI__?.core?.invoke("set_hydration_config",
{ enabled: on, alertMl, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 });
} catch (e) {}
// Live location sharing is OFF by default — keep the native config file in sync with the UI
// toggle so a session always sees the current choice (off unless the user turned it on).
try {
let liveOn = localStorage.getItem("baldav_live_on") === "1";
let liveUrl = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push";
// migrate the old /api/live/push endpoint to /api/trackme/push (renamed server-side)
if (liveUrl.endsWith("/api/live/push")) {
liveUrl = liveUrl.replace("/api/live/push", "/api/trackme/push");
localStorage.setItem("baldav_live_url", liveUrl);
}
let liveToken = localStorage.getItem("baldav_live_token") || "";
window.__TAURI__?.core?.invoke("set_live_config",
{ enabled: liveOn, url: liveUrl, token: liveToken });
} catch (e) {}
if (typeof DB === "undefined" || typeof DB.getFullProfile !== "function") {
app.innerHTML = `<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
// ---------------------------------------------------------------------------
// Keep the vertical scroll position across a re-render of the SAME screen (day nav, adding
// food, deletes) instead of jumping to the top. Anchor on the topmost visible top-level
// section + its offset (robust to height changes, e.g. one more food row or async charts).
function captureScrollAnchor(el) {
let top = el.scrollTop, kids = el.children, idx = 0, delta = top;
for (let i = 0; i < kids.length; i++) {
if (kids[i].offsetTop <= top + 4) { idx = i; delta = top - kids[i].offsetTop; } else break;
}
return { idx, delta };
}
function applyScrollAnchor(el, a) {
let kid = el.children[a.idx];
if (kid) el.scrollTop = Math.max(0, kid.offsetTop + a.delta);
}
// The screen fills asynchronously (charts, nutrient strips), so re-apply for a short window as
// content settles; stop early if the user scrolls themselves.
function scheduleScrollRestore(el, a) {
let stop = false, off = () => { stop = true; };
el.addEventListener("wheel", off, { passive: true, once: true });
el.addEventListener("touchmove", off, { passive: true, once: true });
let apply = () => { if (!stop) applyScrollAnchor(el, a); };
apply();
requestAnimationFrame(apply);
[60, 180, 400].forEach(ms => setTimeout(apply, ms));
}
function renderScreen(name, fromPopstate) {
// Release screen wake lock and compass listener when leaving recording screen
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
try { TileCache.stopHealer(); } catch {} // leaving a map screen stops online tile-healing; map screens restart it
let prevScreen = state.screen;
state.screen = name;
// Push to nav stack and browser history (unless triggered by popstate)
if (!fromPopstate && prevScreen && prevScreen !== name) {
navStack.push(name);
if (navStack.length > NAV_MAX) navStack.shift();
persistNavStack();
history.pushState({ screen: name }, "");
}
let screens = {
today: renderToday,
products: renderProducts,
recipes: renderRecipes,
gps: renderGpsScreen,
recording: renderRecordingInline,
supplements: renderSupplements,
settings: renderSettings,
};
// Re-render of the same screen: remember where we were scrolled so we can stay put.
let scrollAnchor = null;
if (prevScreen === name) {
let oldContent = app.querySelector(".screen-content");
if (oldContent && oldContent.scrollTop > 0) scrollAnchor = captureScrollAnchor(oldContent);
}
app.innerHTML = "";
// Floating recording bar (visible on all screens except recording)
if (GPS.recording.active && name !== "recording") {
let bar = document.createElement("div");
bar.className = "rec-floating-bar";
bar.onclick = () => renderScreen("recording");
bar.innerHTML = `<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);
if (scrollAnchor) scheduleScrollRestore(content, scrollAnchor);
}
function buildNav(active) {
let nav = document.createElement("nav");
nav.className = "bottom-nav";
[
{ id: "today", label: "Сегодня" },
{ id: "products", label: "Продукты" },
{ id: "gps", label: "Трек" },
{ id: "supplements", label: "Добавки" },
{ id: "settings", label: "Настройки" },
].forEach(item => {
let btn = document.createElement("button");
btn.className = "nav-btn" + (item.id === active ? " active" : "");
btn.textContent = item.label;
btn.onclick = () => renderScreen(item.id);
nav.appendChild(btn);
});
return nav;
}
// ---------------------------------------------------------------------------
// TODAY screen
// ---------------------------------------------------------------------------
async function renderToday(container) {
container.innerHTML = `<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, state.date);
let bmr = calcBmr(recentWeightsForBmr);
let actLevel = parseFloat(state.profile.activity_level) || 1.2;
let intake = Math.round(foodTotals.kcal ?? 0);
let actKcal = Math.round(actTotals.kcal ?? 0);
// TEF (термический эффект пищи): доля прихода на переваривание. Вводим с сохранением
// поддержки — базу урезаем на tefFrac, а TEF добавляем от реального прихода, поэтому на
// поддержке итог тот же, но в дефицит (ел меньше — переварил меньше) расход чуть ниже.
let tefFrac = Math.max(0, Math.min(0.3, GPS.config.tefFrac ?? 0));
let dailyBase = bmr ? Math.round(bmr * actLevel * (1 - tefFrac)) : null;
let tefKcal = Math.round(tefFrac * intake);
let expend = dailyBase != null ? dailyBase + tefKcal + actKcal : (actKcal > 0 ? actKcal + tefKcal : null);
let balance = expend != null ? intake - expend : null;
container.innerHTML = `
<div class="day-header">
<button class="day-nav" id="prev-day">&#8592;</button>
<h2>${formatDate(state.date)}</h2>
<button class="day-nav" id="next-day">&#8594;</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>
${expend != null ? `<div class="expend-breakdown" style="text-align:center;font-size:11px;color:#9aa4ae;margin-top:-4px">
расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""}
</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}">&#x2715;</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}">&#x2715;</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}">&#x2715;</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}">&#x2715;</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}">&#x2715;</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">&#x270E;</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, state.date),
DB.getDailyActivityTotals(7, state.date),
]);
// The range is now exactly 7 days ending at the selected day (no off-by-one slice needed).
if (food7.length === 0) return;
let foodMap = {};
food7.forEach(d => foodMap[d.date] = Math.round(d.kcal ?? 0));
let actMap = {};
act7.forEach(d => actMap[d.date] = Math.round(d.kcal ?? 0));
// Build per-day balance for all dates that have food data
let balances = [];
for (let d of food7) {
let dayIntake = foodMap[d.date] ?? 0;
let dayAct = actMap[d.date] ?? 0;
let dayBalance = dayIntake - ((dailyBase ?? 0) + Math.round(tefFrac * dayIntake) + dayAct);
balances.push(dayBalance);
}
if (balances.length === 0) return;
let sum = balances.reduce((a, b) => a + b, 0);
let avg = Math.round(sum / balances.length);
// Накопительный "банк": сумма дневных балансов за окно. Отвечает на "сколько доесть/недоесть"
// (цель асимптотически к весовой). ~7700 ккал = 1 кг жира.
let bank = Math.round(sum);
let bankKg = (bank / 7700).toFixed(2);
let cls = avg <= 0 ? "trend-deficit" : "trend-surplus";
let bankCls = bank <= 0 ? "trend-deficit" : "trend-surplus";
trendEl.innerHTML = `
<div><span class="balance-trend-label">Банк за ${balances.length} дн.:</span>
<span class="balance-trend-val ${bankCls}">${bank >= 0 ? "+" : ""}${bank} ккал (${bankKg} кг)</span></div>
<div><span class="balance-trend-label">Среднее:</span>
<span class="balance-trend-val ${cls}">${avg >= 0 ? "+" : ""}${avg} ккал/день</span></div>`;
} 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) {
let chartRefDate = state.date; // window ends at the selected day
renderStatsCharts(statsContainer, 30, foodTotals, dailyBase, chartRefDate);
statsPeriod.querySelectorAll(".type-chip").forEach(chip => {
chip.onclick = () => {
statsPeriod.querySelectorAll(".type-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
renderStatsCharts(statsContainer, parseInt(chip.dataset.days), foodTotals, dailyBase, chartRefDate);
};
});
}
}
// ---------------------------------------------------------------------------
// Stats charts
// ---------------------------------------------------------------------------
let statsChartInstances = [];
async function renderStatsCharts(container, days, foodTotals, bmr, refDate) {
// Destroy old charts
statsChartInstances.forEach(c => c.destroy());
statsChartInstances.length = 0;
container.innerHTML = `<div class="loading">Загрузка...</div>`;
let weights, dailyFood, dailyActivity;
try {
[weights, dailyFood, dailyActivity] = await Promise.all([
DB.getRecentWeights(days, refDate),
DB.getDailyFoodTotals(days, refDate),
DB.getDailyActivityTotals(days, refDate),
]);
} catch (e) {
container.innerHTML = `<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);
}
// Шкала баланса (y1): симметрична вокруг 0 и ФЛОТИРУЕТ под реальные данные - подгоняется по
// максимуму |баланса|, округляя вверх до 500, с полом ±500 (чтобы мелкие колебания не были
// плоской линией). Без фиксированного потолка: нет больших значений - нет и большой шкалы.
// Симметрия держит читаемость даже при жёстком дне (дефицит у нижнего края, норма у центра).
let bAbs = Math.max(500, ...balanceData.filter(v => v != null).map(v => Math.abs(v)), 0);
let bMax = Math.ceil(bAbs / 500) * 500;
let corrLabels = allDates.map(d => {
let parts = d.split("-");
return parts[2] + "." + parts[1];
});
let ctx = block.querySelector("#stats-corr-chart");
let chart = new Chart(ctx, {
type: "line",
data: {
labels: corrLabels,
datasets: [
{
label: "Вес",
data: weightData,
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
yAxisID: "y",
spanGaps: true,
},
{
label: "Баланс 7д",
data: balanceData,
borderColor: "#81c784",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
yAxisID: "y1",
spanGaps: true,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
labels: { color: "#888", font: { size: 12 } },
},
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: false },
},
},
scales: {
x: { ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: {
position: "left",
title: { display: true, text: "кг", color: "#bb86fc" },
ticks: { color: "#bb86fc" },
grid: { color: "#333" },
},
y1: {
position: "right",
min: -bMax, max: bMax,
title: { display: true, text: "ккал", color: "#81c784" },
ticks: { color: "#81c784" },
grid: { drawOnChartArea: false },
},
},
},
});
statsChartInstances.push(chart);
}
// If no data at all
if (container.innerHTML === "") {
container.innerHTML = `<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 = "&#x2715;";
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">&#8592;</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).
// Fill a button's background to `pct` (0-100) as a live progress bar; pct=null clears it.
// The % rides on a CSS custom property (.btn-progress reads --pct) so no inline style rule.
function setBtnProgress(el, pct) {
if (!el) return;
if (pct == null) { el.classList.remove("btn-progress"); el.style.removeProperty("--pct"); return; }
el.classList.add("btn-progress");
el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%");
}
// Show the HR strap charge as a background fill on the HR toggle (0-100%, coloured by level).
function setHrBatteryFill(el, bat) {
if (!el) return;
if (bat == null || bat < 0 || bat > 100) { el.style.removeProperty("--bat"); el.style.removeProperty("--bat-color"); return; }
el.style.setProperty("--bat", bat + "%");
el.style.setProperty("--bat-color", bat > 40 ? "rgba(76,175,80,0.5)" : bat > 15 ? "rgba(255,183,77,0.55)" : "rgba(239,83,80,0.6)");
}
// Before a recording, nudge the user to turn on what it needs: Bluetooth (for the HR strap) and
// location (for the track). Can't silently toggle them on modern Android, so prompt + act: BT via
// the system enable dialog, location via the location-settings screen. Declining is fine, and the
// two "Не спрашивать" settings suppress each prompt. Never prompts when the state is unknown.
async function ensureSystemReady(needBt, needGps) {
let inv = window.__TAURI__.core.invoke;
if (needBt && localStorage.getItem("baldav_dontask_bt") !== "1") {
let bt = "on"; try { bt = await inv("ble_check_bluetooth"); } catch {}
if (bt === "off" && await appConfirm("Для пульсометра нужен Bluetooth. Включить?", "Включить")) {
try { await inv("ble_request_enable"); } catch {}
}
}
if (needGps && localStorage.getItem("baldav_dontask_gps") !== "1") {
let locOn = await GPS.isLocationEnabled();
if (locOn === false && await appConfirm("Для записи трека нужна геолокация. Открыть настройки, чтобы включить?", "Открыть")) {
try { await inv("gps_service_command", { cmd: "open_location_settings" }); } catch {}
}
}
}
async function reopenDetail(sessionId) {
let count = 0;
while (winStack.length > 0) {
let top = winStack.pop();
count++;
try { top.close(); } catch {}
if (top.type === "modal") break;
}
// AWAIT the history.go(-count) popstate(s) before re-rendering. Rendering synchronously
// after history.go races it: showSessionDetail pushes its own history entry, then the
// pending popstates unwind THAT entry and desync the stack, and the async map/chart build
// comes up blank until the next event repaints it (the "graphs+map gone until Cancel" bug).
// Same fix as closeThen() — resolve on the popstates, never a timeout.
if (count > 0) {
_skipPops = count;
await new Promise(res => {
let remaining = count;
let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } };
window.addEventListener("popstate", h);
history.go(-count);
});
}
showSessionDetail(sessionId);
}
function showWeightModal() {
showModal(`
<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); // also syncs profile weight_kg to the latest weigh-in
// Refresh in-memory state so kcal/BMR and the settings weight field are current at once.
try {
state.latestWeight = await DB.getLatestWeight();
if (state.profile && state.latestWeight?.weight_kg != null) state.profile.weight_kg = state.latestWeight.weight_kg;
} catch {}
});
}
function showFoodModal() {
let selectedProduct = null;
let selectedUnit = null; // { id, name, name_short, grams_per_unit }
let availableUnits = [];
showModal(`
<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);
hydrationOnDrink(); // if it was liquid, quiet the reminder once you've drunk
} 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="Экспорт своих продуктов и рецептов">&#x21E9;</button>
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">&#x21E7;</button>
<button class="add-btn" id="scan-chzn" style="font-size:14px" title="Скан «Честный знак»">&#x25A6;</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);
hydrationOnDrink(); // quiet the drink reminder if this was liquid
});
}
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">&#8592;</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">&#128161;</button>
</div>
<div id="cz-zoom-wrap" style="display:none;align-items:center;gap:10px;margin-top:10px">
<span style="font-size:16px">&#128269;</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}">&#x2715;</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">Нет рецептов. Создайте в Продукты &rarr; Рецепты.</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;
// At session start, seed the native hydration deficit from the FOOD LOG: how far behind on water
// you are for the time of day (need-so-far minus logged intake). During the session the native
// service adds sweat on top. Intake comes from what you log, not a button.
// Sweat (extra water loss) per activity — cycling sweats far more than a walk, so a walk must
// NOT accrue the ride rate (that made it falsely hit the threshold after ~1 h of walking).
let HYDR_SWEAT = { bike: 600, walk: 200, run: 450, workout: 250, vr: 250 };
function hydrSweatFor(type) { return HYDR_SWEAT[type] || 600; }
// Compute the current sedentary shortfall (need-so-far for the time of day minus what you've
// logged drinking today). Shared by the session seed and the on-drink re-seed.
async function hydrBaseDeficit() {
let target = parseInt(state.profile?.daily_water_ml) || 2000;
let now = new Date();
let nowMin = now.getHours() * 60 + now.getMinutes();
let wakeStart = 7 * 60, wakeEnd = 23 * 60; // assume waking 07:00–23:00
let frac = Math.max(0, Math.min(1, (nowMin - wakeStart) / (wakeEnd - wakeStart)));
let intake = await DB.getDailyWaterMl(now.toISOString().slice(0, 10));
return Math.max(0, Math.min(5000, Math.round(target * frac - intake)));
}
async function seedHydrationDeficit(activityType) {
if (localStorage.getItem("baldav_hydr_on") === "0") return; // ON by default unless turned off
try {
let startDeficit = await hydrBaseDeficit();
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
await window.__TAURI__.core.invoke("set_hydration_config",
{ enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(activityType), reAlertMin: 15, startDeficitMl: startDeficit, seedSeq: Date.now() });
} catch (e) { console.error("seedHydration", e); }
}
// After you log a drink DURING a session: recompute the base deficit (now lower) and push it with
// a fresh seedSeq so the native side drops baseDeficit — the reminder goes quiet once you've drunk.
async function hydrationOnDrink() {
try {
if (!(GPS.recording && GPS.recording.active)) return; // only matters during a session
if (localStorage.getItem("baldav_hydr_on") === "0") return;
let base = await hydrBaseDeficit();
let alertMl = parseInt(localStorage.getItem("baldav_hydr_alert")) || 600;
await window.__TAURI__.core.invoke("set_hydration_config",
{ enabled: true, alertMl, sweatMlPerHour: hydrSweatFor(GPS.recording.activityType), reAlertMin: 15, startDeficitMl: base, seedSeq: Date.now() });
} catch (e) {}
}
async function renderGpsScreen(container) {
// Check Bluetooth state if BLE device is paired
let bleAddr = await DB.getProfileValue("ble_device_addr");
if (bleAddr) {
try {
let btState = await window.__TAURI__.core.invoke("ble_check_bluetooth");
if (btState === "off") {
showToast("Bluetooth выключен");
} else if (btState === "no_adapter") {
showToast("Bluetooth недоступен");
}
} catch {}
}
let PAGE_SIZE = 20;
let sessionCard = (s) => `
<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) : "—"}
&middot; ${s.total_kcal != null ? Math.round(s.total_kcal) + " кк" : ""}
&middot; ${s.avg_speed_kmh != null ? s.avg_speed_kmh + " км/ч" : ""}
${s.elevation_gain_m ? " &middot; +" + 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>
<option value="workout"${localStorage.getItem("baldav_gps_type")==="workout"?" selected":""}>Зарядка (пульс)</option>
<option value="vr"${localStorage.getItem("baldav_gps_type")==="vr"?" selected":""}>Руки/VR (пульс)</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">&#x22EE;</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 id="gps-sel-bar" class="gps-sel-bar" hidden>
<span id="gps-sel-count">Выбрано 0</span>
<button id="gps-sel-merge" class="btn-primary" disabled>Объединить</button>
<button id="gps-sel-cancel" class="btn-secondary">Отмена</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;
GPS.recording.gearCount = bike.gear_count;
}
}
// Nudge BT (for the strap) / location (for the track) on if off. Non-blocking to the user's
// choice: they can decline, and the two "Не спрашивать" settings suppress each prompt.
await ensureSystemReady(!!state.profile?.ble_device_addr, !GPS.isHrOnly(type));
// Workout/VR (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
let hrProfile = GPS.isHrOnly(type) ? {
weightKg: wkg,
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
sex: state.profile?.sex || "m",
heightCm: parseFloat(state.profile?.height_cm) || 178,
} : null;
await GPS.startRecording(type, wkg, hrProfile);
seedHydrationDeficit(type);
renderScreen("recording");
}
};
// Session click → detail view; LONG-PRESS → multi-select for merging tracks. Keep sid a
// STRING (numeric id from the SQL plugin is corrupted off-by-1 for large i64 values).
let selMode = false, selected = new Set();
let selBar = container.querySelector("#gps-sel-bar");
let selCount = container.querySelector("#gps-sel-count");
let selMergeBtn = container.querySelector("#gps-sel-merge");
let refreshSel = () => {
container.querySelectorAll(".gps-session-item").forEach(it => it.classList.toggle("selected", selected.has(it.dataset.sid)));
if (selBar) selBar.hidden = !selMode;
if (selCount) selCount.textContent = "Выбрано " + selected.size;
if (selMergeBtn) selMergeBtn.disabled = selected.size < 2;
};
let exitSel = () => { selMode = false; selected.clear(); refreshSel(); };
let toggleSel = (sid) => {
if (selected.has(sid)) selected.delete(sid); else selected.add(sid);
if (selected.size === 0) { exitSel(); return; }
refreshSel();
};
let enterSel = (sid) => { selMode = true; selected.add(sid); refreshSel(); };
// Long-press: hold ~500 ms without moving. Works for touch and (desktop) mouse; the
// click that follows is suppressed so a long-press doesn't also open the detail.
let attachLongPress = (item) => {
let timer = null, longFired = false;
let cancel = () => { if (timer) { clearTimeout(timer); timer = null; } };
let start = () => { longFired = false; cancel(); timer = setTimeout(() => { timer = null; longFired = true; if (!selMode) enterSel(item.dataset.sid); else toggleSel(item.dataset.sid); }, 500); };
item.addEventListener("touchstart", start, { passive: true });
item.addEventListener("touchmove", cancel, { passive: true });
item.addEventListener("touchend", cancel);
item.addEventListener("mousedown", start);
item.addEventListener("mouseup", cancel);
item.addEventListener("mouseleave", cancel);
item.onclick = () => { if (longFired) { longFired = false; return; } if (selMode) toggleSel(item.dataset.sid); else showSessionDetail(item.dataset.sid); };
};
let bindSessionClick = attachLongPress;
container.querySelectorAll(".gps-session-item").forEach(bindSessionClick);
let sessTs = (s, which) => new Date((s.date || "1970-01-01") + "T" + ((which === "end" ? s.end_time : s.start_time) || "00:00:00"));
if (container.querySelector("#gps-sel-cancel")) container.querySelector("#gps-sel-cancel").onclick = () => exitSel();
if (selMergeBtn) selMergeBtn.onclick = async () => {
if (selected.size < 2) return;
let ids = [...selected];
let sess = [];
for (let id of ids) { let s = await DB.getGpsSession(id); if (s) sess.push(s); }
if (sess.length < 2) return;
sess.sort((a, b) => sessTs(a) - sessTs(b));
// Different bikes must not be merged: gear/weight differ, so power and cadence would be
// computed wrong for the mixed track. Refuse and tell the rider how to proceed.
let bikeSet = new Set(sess.map(s => s.bike_id ?? "none"));
if (bikeSet.size > 1) {
await appAlert("Велосипеды у выбранных треков разные - склеить нельзя: передача и вес отличаются, мощность и каденс посчитались бы неверно.\n\nЕсли это на самом деле один велосипед, задайте его всем трекам одинаково (в детали трека: «Велосипед», затем «Пересчитать») и склеивайте снова.");
return;
}
let gapWarn = false;
for (let i = 1; i < sess.length; i++) {
let gapMin = (sessTs(sess[i]) - sessTs(sess[i - 1], "end")) / 60000;
if (gapMin > 5) gapWarn = true;
}
let msg = "Объединить " + sess.length + " тренировки в одну?" +
(gapWarn ? "\n\nВнимание: между треками разрыв больше 5 минут - точки лягут с пропусками." : "") +
"\n\nОбратимо: исходные тренировки помечаются в точках (можно будет разделить позже).";
if (!(await appConfirm(msg, "Объединить"))) return;
try {
selMergeBtn.disabled = true; selMergeBtn.textContent = "Объединяю...";
let targetId = await DB.mergeGpsSessions(ids);
if (targetId) await GPS.recalculateSession(targetId, { noDelete: true });
showToast("Объединено в одну тренировку");
exitSel();
renderScreen("gps");
} catch (e) {
console.error("[merge]", e);
showToast("Ошибка объединения: " + (e.message || e));
selMergeBtn.disabled = false; selMergeBtn.textContent = "Объединить";
}
};
// Pagination: "Показать ещё" loads the next page from the DB and appends it.
let sessOffset = sessions.length;
let moreBtn = container.querySelector("#gps-more");
if (moreBtn) moreBtn.onclick = async () => {
moreBtn.disabled = true;
let label = moreBtn.textContent;
moreBtn.textContent = "Загрузка...";
let next = await DB.getGpsSessions(PAGE_SIZE, sessOffset);
let listEl = container.querySelector("#gps-session-list");
next.forEach(s => {
let tmp = document.createElement("div");
tmp.innerHTML = sessionCard(s);
let item = tmp.firstElementChild;
bindSessionClick(item);
listEl.appendChild(item);
});
sessOffset += next.length;
moreBtn.disabled = false;
moreBtn.textContent = label;
if (next.length < PAGE_SIZE) moreBtn.style.display = "none";
};
// Three-dot menu
let menuBtn = container.querySelector("#gps-menu-btn");
let menu = container.querySelector("#gps-menu");
menuBtn.onclick = () => menu.classList.toggle("hidden");
document.addEventListener("click", e => {
if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden");
}, { once: false });
// Download map tiles — region picker
container.querySelector("#gps-draw-route").onclick = () => showDrawRouteScreen(container);
container.querySelector("#gps-routes").onclick = () => {
menu.classList.add("hidden");
showRoutesScreen(container);
};
container.querySelector("#gps-download-map").onclick = () => {
menu.classList.add("hidden");
showMapDownloadModal();
};
// Clear tile cache
container.querySelector("#gps-clear-cache").onclick = async () => {
menu.classList.add("hidden");
let count = await TileCache.count();
await TileCache.clear();
console.log("Cache cleared");
};
// Recover points from native files — rebuilds sessions whose DB rows fell behind
// the service's JSONL (e.g. screen-off import glitch). Works fully on-device.
container.querySelector("#gps-recover").onclick = async () => {
menu.classList.add("hidden");
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
// Keep the screen awake — recovery runs in the WebView and stalls if it sleeps.
let wl = null;
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
// Visible progress bar (fixed at the bottom) so it's clear it's working.
let prog = document.createElement("div");
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
prog.textContent = "Восстановление: подготовка...";
document.body.appendChild(prog);
try {
let res = await GPS.recoverFromFiles((i, n) => { prog.textContent = `Восстановление: файл ${i}/${n}`; });
if (res.error === "recording") { prog.remove(); showToast("Нельзя во время записи"); return; }
let list = res.results || [];
let totalPts = list.reduce((a, r) => a + (r.imported || 0), 0);
let sessions = list.filter(r => r.imported > 0).length;
let unmatched = list.filter(r => r.unmatched).length;
let removed = res.removed || 0;
// Show the result IN the bar — a toast gets overwritten by the Bluetooth
// check when the screen re-renders. Tap the bar to close and refresh.
prog.textContent = `Готово: файлов ${list.length}, вставлено ${totalPts} в ${sessions} сессий, удалено ${removed}, сирот ${unmatched}. Нажмите, чтобы закрыть`;
prog.style.cursor = "pointer";
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
} catch (e) {
console.error("[recover] failed", e);
prog.textContent = "Ошибка восстановления: " + (e.message || e) + " — нажмите, чтобы закрыть";
prog.onclick = () => prog.remove();
} finally {
if (wl) { try { await wl.release(); } catch {} }
}
};
// Recompute stored elevation/kcal for every recorded session (formula fix).
// Stats-only: never touches points. Same progress-bar UX as recovery.
container.querySelector("#gps-recalc-stats").onclick = async () => {
menu.classList.add("hidden");
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
let wl = null;
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
let prog = document.createElement("div");
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
prog.textContent = "Пересчёт: подготовка...";
document.body.appendChild(prog);
try {
let res = await GPS.recomputeAllStats((i, n) => { prog.textContent = `Пересчёт: ${i}/${n}`; });
if (res.error) { prog.textContent = "Ошибка: " + res.error + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); return; }
prog.textContent = `Готово: пересчитано ${res.updated}, пропущено ${res.skipped}. Нажмите, чтобы закрыть`;
prog.style.cursor = "pointer";
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
} catch (e) {
console.error("[recalc-all] failed", e);
prog.textContent = "Ошибка пересчёта: " + (e.message || e) + " — нажмите, чтобы закрыть";
prog.onclick = () => prog.remove();
} finally {
if (wl) { try { await wl.release(); } catch {} }
}
};
// Unified import — detect by extension
container.querySelector("#gps-import").onclick = () => {
menu.classList.add("hidden");
pickFile("", async (data, file) => {
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
let ext = file.name.split(".").pop().toLowerCase();
console.log("[IMPORT] ext:", ext);
let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
try {
if (ext === "gpx") {
let text = typeof data === "string" ? data : new TextDecoder().decode(data);
await GPS.importGPX(text, wkg);
} else if (ext === "ftb" || ext === "zip") {
let buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
console.log("[IMPORT] FTB buf size:", buf.byteLength);
let count = await GPS.importFTB(buf, wkg);
console.log("[IMPORT] done, count:", count);
} else {
console.error("[IMPORT] Unsupported file type: " + ext);
}
renderScreen("gps");
} catch (e) {
console.error("[IMPORT] error:", e, String(e));
}
}, true);
};
// Goals button — manage goal markers
container.querySelector("#gps-goals-btn").onclick = () => showGoalMarkersModal();
}
// ---------------------------------------------------------------------------
// Goal markers management modal (IDEA 2)
// ---------------------------------------------------------------------------
async function showGoalMarkersModal() {
let markers = await DB.getGoalMarkers();
let html = `
<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}">&#x2715;</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, onProgress) {
// Terrain elevation, tried in order until one answers a batch:
// 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m).
// 2. open-elevation.com (SRTM, reachable in RU).
// 3. open-meteo (often blocked by RU carriers, kept in case).
// 4. OpenTopoData public (free, no key, srtm30m) — last-resort keyless analog.
// Batches of 100 (also OpenTopoData's max locations/request). The GPXZ key lives
// only in GPS.config (localStorage), never in git.
let elevations = new Array(points.length).fill(null);
let gpxzKey = GPS.config.gpxzKey && GPS.config.gpxzKey.trim();
for (let i = 0; i < points.length; i += 100) {
let batch = points.slice(i, i + 100);
let got = null;
if (gpxzKey) {
try {
let latlons = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");
let resp = await fetch("https://api.gpxz.io/v1/elevation/points?latlons=" + encodeURIComponent(latlons),
{ headers: { "x-api-key": gpxzKey } });
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
} else { console.log("[gpxz] http", resp.status); }
} catch (e) { console.log("[gpxz] batch fail:", e.message); }
}
if (got) { for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; continue; }
try {
let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }),
});
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
}
} catch (e) { console.log("[open-elevation] batch fail:", e.message); }
if (!got) {
try {
let lats = batch.map(p => p.lat.toFixed(5)).join(",");
let lons = batch.map(p => p.lon.toFixed(5)).join(",");
let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; }
} catch (e) { console.log("[open-meteo] batch fail:", e.message); }
}
if (!got) {
try {
let locs = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");
let resp = await fetch("https://api.opentopodata.org/v1/srtm30m?locations=" + encodeURIComponent(locs));
if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); }
} catch (e) { console.log("[opentopodata] batch fail:", e.message); }
}
if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j];
if (onProgress) try { onProgress(Math.min(i + 100, points.length), points.length); } catch {}
}
return elevations.some(x => x != null) ? elevations : null;
}
// Colour for the roads overlay by activity type (road_points.atype holds the full
// activity_type: "walk" / "bike" / "run"). Configurable in GPS settings.
function roadColor(atype) {
// get_road_points (Rust) returns atype as a single letter: "w" walk, "r" run, "b" bike/other.
let c = GPS.config;
if (atype === "w") return c.roadColorWalk || "#8B5E3C";
if (atype === "r") return c.roadColorRun || "#e67e22";
return c.roadColorBike || "#2266aa";
}
// Line thickness by how often a stretch was ridden (road_points.passes from rebuild_roads):
// base width x (1..3), +0.2 per extra pass, capped at 3x. Optional — disabled by the flag.
function roadsThickOn() { return localStorage.getItem("baldav_roads_thick") !== "0"; }
function roadWeight(passes, base) {
base = base || 3;
if (!roadsThickOn()) return base;
return base * Math.min(3, 1 + 0.2 * ((passes || 1) - 1));
}
// Draw one road segment (array of {lat,lon,passes}) as ONE polyline, its width from the
// segment's MEDIAN pass count. Earlier this split each segment into a run per weight change,
// which exploded a 1.4k-segment network into ~84k polylines and janked the map; one width per
// segment keeps it at one polyline per segment while still reading thicker on often-ridden
// routes. `extra`: { pane, renderer, base, opacity, smoothFactor }.
function drawRoadSegment(map, seg, color, out, extra) {
if (!seg || seg.length < 2) return;
extra = extra || {};
let base = extra.base || 3;
let opts = { color, opacity: extra.opacity != null ? extra.opacity : 0.6 };
if (extra.pane) opts.pane = extra.pane;
if (extra.renderer) opts.renderer = extra.renderer;
if (extra.smoothFactor != null) opts.smoothFactor = extra.smoothFactor;
let weight = base;
if (roadsThickOn()) {
let ps = seg.map(p => p.passes || 1).sort((a, b) => a - b);
weight = roadWeight(ps[ps.length >> 1], base); // median pass → one width per segment
}
out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight }).addTo(map));
}
function debounce(fn, ms) { let t; return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); }; }
// Split flat road points into segments (break on >100 m gap or activity-type change) and
// precompute each segment's bbox [minLat,minLon,maxLat,maxLon] for viewport culling.
function buildRoadSegments(pts) {
let segs = [], seg = [];
const push = () => {
if (seg.length >= 2) {
let minLa = Infinity, maxLa = -Infinity, minLo = Infinity, maxLo = -Infinity;
for (let p of seg) {
if (p.lat < minLa) minLa = p.lat; if (p.lat > maxLa) maxLa = p.lat;
if (p.lon < minLo) minLo = p.lon; if (p.lon > maxLo) maxLo = p.lon;
}
segs.push({ pts: seg, atype: seg[0].atype, b: [minLa, minLo, maxLa, maxLo] });
}
seg = [];
};
for (let i = 0; i < pts.length; i++) {
if (seg.length > 0) {
let prev = seg[seg.length - 1];
let dlat = (pts[i].lat - prev.lat) * 111000;
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) push();
}
seg.push(pts[i]);
}
push();
return segs;
}
// Viewport culling: clear `out` layers, then draw ONLY segments whose bbox meets the padded
// view. Keeps the drawn set to a screenful instead of the whole (possibly huge) road network.
function cullRoadsInto(map, segments, out, pad, extra) {
out.forEach(l => map.removeLayer(l)); out.length = 0;
let v = map.getBounds().pad(pad);
let S = v.getSouth(), N = v.getNorth(), W = v.getWest(), E = v.getEast();
for (let s of segments) {
let bb = s.b; // [minLat,minLon,maxLat,maxLon]
if (bb[2] < S || bb[0] > N || bb[1] > E || bb[3] < W) continue; // bbox fully outside view
drawRoadSegment(map, s.pts, roadColor(s.atype), out, extra);
}
}
// Resolve once the Leaflet map has finished its initial layout, so layers added
// after this actually render (adding a polyline before the map is loaded/sized
// leaves it invisible until the next redraw). Awaits the real 'load' event — the
// single event→await bridge — then forces a size recalc.
async function whenMapReady(map) {
if (!map._loaded) await new Promise(res => map.once("load", res));
map.invalidateSize();
}
// Draw the active saved route (localStorage baldav_active_route) as a colored line
// on a Leaflet map. Removes `prev` first. Returns the new layer, or null if none.
// Hypsometric tint (like old topo maps): low = green, mid = yellow, high = brown.
// t in [0,1] (normalized elevation).
function routeElevColor(t) {
t = Math.max(0, Math.min(1, t));
let lerp = (a, b, f) => Math.round(a + (b - a) * f);
let c1, c2, f;
if (t < 0.5) { c1 = [46, 125, 50]; c2 = [253, 216, 53]; f = t / 0.5; } // green -> yellow
else { c1 = [253, 216, 53]; c2 = [109, 76, 65]; f = (t - 0.5) / 0.5; } // yellow -> brown
return `rgb(${lerp(c1[0], c2[0], f)},${lerp(c1[1], c2[1], f)},${lerp(c1[2], c2[2], f)})`;
}
async function drawActiveRouteOverlay(map, prev) {
if (prev) { try { map.removeLayer(prev); } catch {} }
let id = localStorage.getItem("baldav_active_route");
if (!id) return null;
let route = await DB.getRoute(id);
if (!route || !route.points || route.points.length < 2) return null;
return drawRoutePolyline(map, route.points, route.color);
}
// Draw the impassable "nogo" circles (DB.nogo_areas) as red translucent circles. Tapping a
// circle offers to remove it; onChange() (if given) fires after a removal so the caller can
// re-fetch nogos and re-route. Returns the layer group (removed via the `prev` arg on redraw).
async function drawNogoAreas(map, prev, onChange) {
if (prev) { try { map.removeLayer(prev); } catch {} }
let areas = await DB.getNogoAreas();
let group = L.layerGroup();
for (let a of areas) {
let c = L.circle([a.lat, a.lon], { radius: a.radius_m || 25, color: "#e53935", weight: 2, fillColor: "#e53935", fillOpacity: 0.22 });
((area) => c.on("click", async (e) => {
L.DomEvent.stop(e);
if (await appConfirm("Убрать пометку «нет проезда»?", "Убрать")) {
await DB.deleteNogoArea(area.id);
if (onChange) onChange();
}
}))(a);
c.addTo(group);
}
group.addTo(map);
return group;
}
// nogos for the router: [{lat,lon,radius}] from the nogo_areas table.
async function getNogosForRouter() {
return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 }));
}
// ---------------------------------------------------------------------------
// Ridden-roads router: route on the user's own tracks first, BRouter for the rest
// ---------------------------------------------------------------------------
let _rideGraph = null; // { nodes:[[lat,lon]], adj:Map, grid:Map, cellDeg }
async function loadRideGraph(force) {
if (_rideGraph && !force) return _rideGraph;
let raw = await window.__TAURI__.core.invoke("get_ride_graph", { minSpeedMs: 0.8 });
let g = JSON.parse(raw);
let nodes = g.nodes || [];
let adj = new Map();
for (let e of (g.edges || [])) {
let a = e[0], b = e[1], d = e[2];
if (!adj.has(a)) adj.set(a, []); adj.get(a).push([b, d]);
if (!adj.has(b)) adj.set(b, []); adj.get(b).push([a, d]);
}
let cellDeg = 0.00045; // ~50 m nearest-node buckets
let grid = new Map();
for (let i = 0; i < nodes.length; i++) {
let key = Math.round(nodes[i][0] / cellDeg) + "," + Math.round(nodes[i][1] / cellDeg);
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(i);
}
_rideGraph = { nodes, adj, grid, cellDeg };
return _rideGraph;
}
function _nearestRideNode(g, lat, lon, maxM) {
let cx = Math.round(lat / g.cellDeg), cy = Math.round(lon / g.cellDeg), best = -1, bestD = Infinity;
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
let arr = g.grid.get((cx + dx) + "," + (cy + dy)); if (!arr) continue;
for (let i of arr) { let d = GPS.distance(lat, lon, g.nodes[i][0], g.nodes[i][1]); if (d < bestD) { bestD = d; best = i; } }
}
return (best >= 0 && bestD <= maxM) ? { node: best, dist: bestD } : null;
}
function _rideDijkstra(g, src, dst, maxDist) {
let distMap = new Map([[src, 0]]), prev = new Map(), heap = [[0, src]], seen = new Set();
while (heap.length) {
let mi = 0; for (let i = 1; i < heap.length; i++) if (heap[i][0] < heap[mi][0]) mi = i;
let popped = heap.splice(mi, 1)[0], d = popped[0], u = popped[1];
if (seen.has(u)) continue; seen.add(u);
if (u === dst) break;
if (d > maxDist) return null;
let nb = g.adj.get(u); if (!nb) continue;
for (let vw of nb) {
let v = vw[0], w = vw[1]; if (seen.has(v)) continue;
let nd = d + w;
if (nd < (distMap.get(v) ?? Infinity)) { distMap.set(v, nd); prev.set(v, u); heap.push([nd, v]); }
}
}
if (!seen.has(dst)) return null;
let path = [], u = dst;
while (u != null) { path.push([g.nodes[u][0], g.nodes[u][1]]); u = prev.get(u); }
path.reverse();
return { path, dist: distMap.get(dst) };
}
// Route a→b on ridden roads. Returns [{lat,lon}] (with the exact waypoints at the ends) or null
// when either end isn't near ridden roads or the ridden path is too indirect (fall back to BRouter).
async function rideRoute(aLat, aLon, bLat, bLon, opts) {
opts = opts || {};
let g = await loadRideGraph();
if (!g || !g.nodes.length) return null;
let sn = _nearestRideNode(g, aLat, aLon, opts.snapM ?? 60);
let dn = _nearestRideNode(g, bLat, bLon, opts.snapM ?? 60);
if (!sn || !dn) return null;
if (sn.node === dn.node) return null; // same node → let BRouter handle a tiny leg
let straight = GPS.distance(aLat, aLon, bLat, bLon);
let cap = (opts.maxRatio ?? 2.5) * Math.max(300, straight);
let res = _rideDijkstra(g, sn.node, dn.node, cap + 2000);
if (!res || res.dist > cap) return null;
return [{ lat: aLat, lon: aLon }].concat(res.path.map(p => ({ lat: p[0], lon: p[1] })), [{ lat: bLat, lon: bLon }]);
}
// Move a point `d` metres along bearing `brDeg` (for a "start forward, don't U-turn" helper).
function _offsetLatLon(lat, lon, brDeg, d) {
let R = 6378137, br = brDeg * Math.PI / 180, la1 = lat * Math.PI / 180, lo1 = lon * Math.PI / 180;
let la2 = Math.asin(Math.sin(la1) * Math.cos(d / R) + Math.cos(la1) * Math.sin(d / R) * Math.cos(br));
let lo2 = lo1 + Math.atan2(Math.sin(br) * Math.sin(d / R) * Math.cos(la1), Math.cos(d / R) - Math.sin(la1) * Math.sin(la2));
return { lat: la2 * 180 / Math.PI, lon: lo2 * 180 / Math.PI };
}
// On-the-fly detour: from the current position, build a connector back ONTO the saved route
// PAST the blockage, avoiding nogos, preferring to start in the travel direction (no 180 if a
// bearing is given). Does NOT modify the saved route. Blockage reference = nearest nogo that
// sits on the route (<=150 m), else the current position's nearest route point. Target = a
// route point ~1.5 km further along; on failure step further (up to the route end).
// Returns { points:[{lat,lon}], target:{lat,lon} } or null.
// Reroute back onto the active route PAST a blockage ahead. Only acts when a nogo
// actually sits on the route ahead of the current progress point (in the travel
// direction) — otherwise returns {noBlockage:true} and builds nothing (pressing it
// with no blockage used to route to a bogus far point). Returns {points,target} on
// success, null if a detour couldn't be built, {noBlockage:true} if nothing to do.
async function rejoinReroute(cur, bearingDeg, routePts, nogos) {
if (!cur || !routePts || routePts.length < 2) return null;
let cum = [0];
for (let i = 1; i < routePts.length; i++) cum[i] = cum[i-1] + GPS.distance(routePts[i-1].lat, routePts[i-1].lon, routePts[i].lat, routePts[i].lon);
let inNogo = (p) => nogos && nogos.some(n => GPS.distance(p.lat, p.lon, n.lat, n.lon) <= (n.radius || 25) + 15);
// current progress = nearest route point to the current position
let curIdx = 0, curD = Infinity;
for (let i = 0; i < routePts.length; i++) { let d = GPS.distance(cur.lat, cur.lon, routePts[i].lat, routePts[i].lon); if (d < curD) { curD = d; curIdx = i; } }
// travel direction along the route: increasing index unless the compass bearing
// clearly points the other way (handles routes ridden in reverse / loops).
let step = 1;
if (bearingDeg != null && !isNaN(bearingDeg)) {
let ja = Math.min(routePts.length - 1, curIdx + 1), jb = Math.max(0, curIdx - 1);
if (ja !== jb) {
let fb = GPS.bearing(routePts[jb].lat, routePts[jb].lon, routePts[ja].lat, routePts[ja].lon);
let diff = Math.abs(((bearingDeg - fb + 540) % 360) - 180); // 0 = aligned w/ increasing idx, 180 = opposite
if (diff > 90) step = -1; // travelling against increasing index → forward is decreasing idx
}
}
// blockage: first route point within a nogo AHEAD, within a lookahead window
const LOOKAHEAD = 2500;
let blocked = null;
for (let i = curIdx; i >= 0 && i < routePts.length; i += step) {
if (Math.abs(cum[i] - cum[curIdx]) > LOOKAHEAD) break;
if (inNogo(routePts[i])) { blocked = i; break; }
}
if (blocked == null) return { noBlockage: true };
// rejoin target: first route point clear of every nogo AND >= margin past the blockage
const CLEAR_MARGIN = 300;
let ti = blocked;
while (ti >= 0 && ti < routePts.length) {
if (!inNogo(routePts[ti]) && Math.abs(cum[ti] - cum[blocked]) >= CLEAR_MARGIN) break;
ti += step;
}
if (ti < 0 || ti >= routePts.length) ti = step > 0 ? routePts.length - 1 : 0;
let helper = (bearingDeg != null && !isNaN(bearingDeg)) ? _offsetLatLon(cur.lat, cur.lon, bearingDeg, 40) : null;
let jump = Math.max(1, Math.round(routePts.length / 50));
for (let attempt = 0; attempt < 6; attempt++) {
let target = routePts[ti];
let straight = GPS.distance(cur.lat, cur.lon, target.lat, target.lon);
let wps = helper ? [[cur.lon, cur.lat], [helper.lon, helper.lat], [target.lon, target.lat]] : [[cur.lon, cur.lat], [target.lon, target.lat]];
try {
let gpx = await BRouter.route(wps, "trekking", nogos);
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
if (rp.length >= 2) {
let rlen = 0; for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
if (rlen <= Math.max(500, straight * 3)) return { points: rp.map(p => ({ lat: p.lat, lon: p.lon })), target: { lat: target.lat, lon: target.lon } };
}
} catch (e) { /* try a further rejoin point */ }
let next = ti + step * jump;
if (next < 0 || next >= routePts.length) break;
ti = next;
}
return null;
}
// ---------------------------------------------------------------------------
// Share image ("Похвастаться"): track shape + stats as a story-format PNG
// ---------------------------------------------------------------------------
// Story-format duration: "1h 29min" / "29 min" (GPS.formatDuration is h:mm:ss).
function _shareDuration(sec) {
let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
if (m === 60) { h += 1; m = 0; }
return h > 0 ? (m > 0 ? h + "h " + m + "min" : h + "h") : m + " min";
}
// Project track points into a WxH box (equirectangular at track scale, padded, Y down).
function _projectTrack(points, w, h, pad) {
let lats = points.map(p => p.lat), lons = points.map(p => p.lon);
let minLa = Math.min(...lats), maxLa = Math.max(...lats), minLo = Math.min(...lons), maxLo = Math.max(...lons);
let kx = Math.cos((minLa + maxLa) / 2 * Math.PI / 180); // lon → same metric scale as lat
let spanX = ((maxLo - minLo) * kx) || 1e-6, spanY = (maxLa - minLa) || 1e-6;
let scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY);
let ox = (w - spanX * scale) / 2, oy = (h - spanY * scale) / 2;
return points.map(p => ({ x: ox + (p.lon - minLo) * kx * scale, y: oy + (maxLa - p.lat) * scale }));
}
// Which fields the share image can show, and their defaults. `f` is the per-image
// selection (opts.fields), falling back to defaults so an unset key stays on.
let SHARE_FIELDS = ["track", "distance", "time", "speed", "maxspeed", "elev", "kcal", "date"];
let SHARE_FIELD_LABELS = { track: "Форма трека", distance: "Дистанция", time: "Время", speed: "Ср. скорость", maxspeed: "Макс. скорость", elev: "Набор высоты", kcal: "Калории", date: "Дата" };
let SHARE_FIELD_DEFAULTS = { track: true, distance: true, time: true, speed: true, maxspeed: false, elev: true, kcal: true, date: true };
function shareFieldOn(fields, key) { return fields && key in fields ? !!fields[key] : SHARE_FIELD_DEFAULTS[key]; }
// Build the share PNG dataURL. opts.bg: "transparent" | "photo"; opts.fields: per-field on/off.
async function buildShareImage(session, stats, points, opts) {
opts = opts || {};
let f = opts.fields;
let W = 1080, H = 1920;
let cv = document.createElement("canvas"); cv.width = W; cv.height = H;
let ctx = cv.getContext("2d");
if (opts.bg === "photo") {
let bg = localStorage.getItem("baldav_share_bg");
if (bg) {
await new Promise((res) => {
let img = new Image();
img.onload = () => { let s = Math.max(W / img.width, H / img.height); let dw = img.width * s, dh = img.height * s; ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); res(); };
img.onerror = () => res();
img.src = bg;
});
}
let g = ctx.createLinearGradient(0, H * 0.4, 0, H);
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.55)");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
let hasTrack = shareFieldOn(f, "track") && points && points.length >= 2;
// Track shape (top ~42%).
if (hasTrack) {
let boxTop = 90, boxH = Math.round(H * 0.42);
let pts = _projectTrack(points.map(p => ({ lat: p.lat, lon: p.lon })), W, boxH, 130);
let col = (window.GPS && GPS.config && GPS.config.trackColor) || "#2f6bff";
ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.shadowColor = col; ctx.shadowBlur = 30; ctx.strokeStyle = col; ctx.lineWidth = 12;
ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y + boxTop) : ctx.moveTo(p.x, p.y + boxTop));
ctx.stroke(); ctx.shadowBlur = 0;
}
// Stats.
let cx = W / 2;
function bigStat(y, value, label) {
ctx.textAlign = "center";
ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3;
ctx.fillStyle = "#fff"; ctx.font = "700 100px system-ui, -apple-system, sans-serif"; ctx.fillText(value, cx, y);
ctx.shadowBlur = 5; ctx.fillStyle = "rgba(255,255,255,0.85)"; ctx.font = "400 42px system-ui, -apple-system, sans-serif"; ctx.fillText(label, cx, y + 56);
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
}
// Collect enabled stats; lay them out adaptively so turning some off leaves no gaps.
let big = [];
if (shareFieldOn(f, "distance")) big.push([(stats.distance / 1000).toFixed(1) + " km", "Distance"]);
if (shareFieldOn(f, "time")) big.push([_shareDuration(stats.duration || 0), "Time"]);
if (shareFieldOn(f, "speed")) big.push([(stats.avgSpeed || 0).toFixed(1) + " km/h", "Speed"]);
if (shareFieldOn(f, "maxspeed") && stats.maxSpeed != null) big.push([(stats.maxSpeed || 0).toFixed(1) + " km/h", "Max speed"]);
let footer = [];
if (shareFieldOn(f, "elev") && stats.elevGain != null) footer.push("↑ " + Math.round(stats.elevGain) + " m");
if (shareFieldOn(f, "kcal") && stats.kcal != null) footer.push(Math.round(stats.kcal) + " kcal");
if (shareFieldOn(f, "date") && session && session.date) footer.push(session.date);
// Position the stats+footer block: just under the track when it's shown, else centred.
let blockH = big.length * 250 + (footer.length ? 90 : 0);
let y0 = hasTrack ? Math.round(H * 0.55) : Math.round((H - blockH) / 2 + 90);
big.forEach((s, i) => bigStat(y0 + i * 250, s[0], s[1]));
if (footer.length) {
ctx.textAlign = "center"; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 6;
ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "400 36px system-ui, -apple-system, sans-serif";
ctx.fillText(footer.join(" · "), cx, y0 + big.length * 250 + 40); ctx.shadowBlur = 0;
}
return cv.toDataURL("image/png");
}
// Send the generated PNG to the native layer: action "share" (system chooser) or "save" (SAF).
async function exportShareImage(dataUrl, filename, action) {
if (!dataUrl) return;
let base64 = dataUrl.split(",")[1];
try {
await window.__TAURI__.core.invoke("export_image", { filename, base64, action });
showToast(action === "share" ? "Открываю «Поделиться»…" : "Сохранение…");
} catch (e) { showToast("Экспорт картинки: " + (e.message || e)); }
}
// Preview modal with a transparent/photo toggle + Share/Save.
function showShareModal(session, stats, points) {
// Per-field on/off, remembered across shares. Missing keys fall back to defaults.
let fields = {};
try { fields = JSON.parse(localStorage.getItem("baldav_share_fields") || "{}") || {}; } catch { fields = {}; }
let fieldChecks = SHARE_FIELDS.map(k =>
`<label class="share-field"><input type="checkbox" data-field="${k}" ${shareFieldOn(fields, k) ? "checked" : ""}>${SHARE_FIELD_LABELS[k]}</label>`
).join("");
let ov = document.createElement("div");
ov.className = "share-modal";
ov.innerHTML = `
<div class="share-box">
<img id="share-img" class="share-preview" alt="">
<div class="share-fields">${fieldChecks}</div>
<div class="share-toggle">
<button id="share-bg-t" class="btn-secondary active">Прозрачный</button>
<button id="share-bg-p" class="btn-secondary">С фото</button>
</div>
<div class="share-actions">
<button id="share-do" class="btn-primary">Поделиться</button>
<button id="share-save" class="btn-secondary">Сохранить</button>
<button id="share-close" class="btn-secondary" style="color:#cf6679">Закрыть</button>
</div>
</div>`;
document.body.appendChild(ov);
// Register in the global window stack so the hardware/gesture back closes THIS
// modal instead of paging the screens behind it (winPush adds a history entry).
let base = winStack.length, closed = false;
winPush("share", () => ov.remove());
let closeShare = () => {
if (closed) return;
closed = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
};
let bg = "transparent", dataUrl = null;
let img = ov.querySelector("#share-img");
let bt = ov.querySelector("#share-bg-t"), bp = ov.querySelector("#share-bg-p");
async function render() { dataUrl = await buildShareImage(session, stats, points, { bg, fields }); img.src = dataUrl; }
ov.querySelectorAll(".share-field input").forEach(cb => cb.onchange = () => {
fields[cb.dataset.field] = cb.checked;
try { localStorage.setItem("baldav_share_fields", JSON.stringify(fields)); } catch {}
render();
});
bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); };
bp.onclick = () => {
if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана - задайте в Настройках"); return; }
bg = "photo"; bp.classList.add("active"); bt.classList.remove("active"); render();
};
let filename = () => "baldav-" + ((session && session.date) || "track") + ".png";
ov.querySelector("#share-do").onclick = () => exportShareImage(dataUrl, filename(), "share");
ov.querySelector("#share-save").onclick = () => exportShareImage(dataUrl, filename(), "save");
ov.querySelector("#share-close").onclick = () => closeShare();
render();
}
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
// Slope is coloured by DIRECTION of travel (point order = travel direction):
// uphill climbs through a WARM fan (green -> yellow -> red -> brown -> black),
// downhill drops through a COOL fan (green -> cyan -> blue -> navy). Flat (<1%)
// is green either way. 1% bands: <1%=0 .. 9–10%=9, >=10% = last.
let ROUTE_SLOPE_UP = [
"#2e7d32", "#7cb342", "#aeea00", "#ffeb3b", "#ffc107",
"#ff9800", "#ff5722", "#e53935", "#b71c1c", "#6d4c41", "#000000",
];
let ROUTE_SLOPE_DOWN = [
"#2e7d32", "#2e9e5b", "#26a69a", "#00bcd4", "#29b6f6",
"#1e88e5", "#1976d2", "#1565c0", "#0d47a1", "#0a357a", "#001f5c",
];
function routeSlopeColor(gradePct) {
let g = gradePct || 0;
let idx = Math.min(10, Math.floor(Math.abs(g)));
return g < 0 ? ROUTE_SLOPE_DOWN[idx] : ROUTE_SLOPE_UP[idx];
}
// Compact colour strips for the slope legend (every 1% band, 0 → 10%+), both fans.
function slopeLegendHtml() {
let strip = arr => arr
.map(c => `<span style="display:inline-block;width:9px;height:10px;background:${c};vertical-align:middle"></span>`)
.join("");
return `подъём ${strip(ROUTE_SLOPE_UP)} крутой&nbsp;&nbsp;спуск ${strip(ROUTE_SLOPE_DOWN)} крутой`;
}
// Which BRouter profile to route with: the adventure variant (fords/grade5/bad trails
// allowed) when the rider opted in, else the default trekking (avoids them).
function trekProfile() {
return localStorage.getItem("baldav_allow_adventure") === "1" ? "trekking-adventure" : "trekking";
}
// Draw a route as a feature group: a wide colored casing (per segment — by slope
// or by elevation, per baldav_route_color_mode) UNDER a thin identity-color line,
// when the route has elevations; otherwise just the plain colored line. The casing
// lives in a dedicated pane below the overlay pane, so a recorded track always
// draws ON TOP of the route. Removable via map.removeLayer.
function drawRoutePolyline(map, pts, color, mode) {
mode = mode || localStorage.getItem("baldav_route_color_mode") || "slope";
// On a rotating map (leaflet-rotate) tile/overlay panes live INSIDE _rotatePane;
// a pane created at the default mapPane level wouldn't rotate/zoom with the map
// (route flashes then vanishes). Parent our pane to _rotatePane when it exists.
if (!map.getPane("routePane")) {
map.createPane("routePane", map._rotatePane || undefined);
map.getPane("routePane").style.zIndex = 250; // between tilePane(200) and overlayPane(400)
}
let popts = { pane: "routePane", interactive: false };
let latlngs = pts.map(p => [p.lat, p.lon]);
let group = L.featureGroup();
let st = GPS.routeElevStats(pts);
if (st.hasEle) {
let valid = st.smoothed.filter(e => e != null);
let lo = Math.min(...valid), hi = Math.max(...valid), span = (hi - lo) || 1;
for (let i = 0; i < pts.length - 1; i++) {
// No elevation on this segment (e.g. a straight leg between planner waypoints) → leave
// it as the thin identity line only, so road (casing) and straight (thin) read apart.
if (st.smoothed[i] == null && st.smoothed[i+1] == null) continue;
let col;
if (mode === "elev") {
let e = st.smoothed[i] ?? st.smoothed[i+1];
col = routeElevColor((e - lo) / span);
} else {
col = routeSlopeColor(st.grades[i]);
}
L.polyline([latlngs[i], latlngs[i+1]], { ...popts, color: col, weight: 14, opacity: 0.95 }).addTo(group);
}
}
L.polyline(latlngs, { ...popts, color: color || "#e53935", weight: 3, opacity: 0.95 }).addTo(group);
group.addTo(map);
return group;
}
// Saved routes management page (import GPX, rename, color, delete, pick active).
// Replaces the GPS container like showDrawRouteScreen; back returns to the GPS screen.
async function showRoutesScreen(container) {
let active = localStorage.getItem("baldav_active_route") || "";
async function render() {
let routes = await DB.getRoutes();
let rows = routes.map(r => `
<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.wpts != null ? r.wpts + " тчк" : 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">Нет маршрутов. Нажмите «Новый маршрут» — тапайте по карте опорные точки, линия проложится по дорогам (BRouter) прямо в телефоне. Или импортируйте готовый 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.
// Elevation correction grid from ridden tracks (Rust get_elev_correction): per ~20m
// cell, the median (measured − SRTM) offset over >= N passes. Cached per session.
let _elevCorr = null;
async function loadElevCorrection(force) {
if (_elevCorr && !force) return _elevCorr;
let minPasses = parseInt(localStorage.getItem("baldav_elev_corr_passes")) || 3;
let raw = await window.__TAURI__.core.invoke("get_elev_correction", { minPasses });
let arr = JSON.parse(raw) || [];
let dLat = 0.00018, dLon = 0.00027, cells = new Map();
for (let [lat, lon, delta] of arr) cells.set(Math.round(lat / dLat) + "," + Math.round(lon / dLon), delta);
_elevCorr = { cells, dLat, dLon };
return _elevCorr;
}
// SRTM stays the base; nudge each point up/down by the SMOOTHED ridden offset where
// you've ridden enough. No coverage -> delta 0 -> unchanged. Non-destructive (returns
// a copy), so the stored route keeps its original elevations.
async function applyRiddenElevCorrection(pts) {
let corr;
try { corr = await loadElevCorrection(); } catch { return pts; }
if (!corr || !corr.cells.size) return pts;
let deltas = pts.map(p => {
if (p.ele == null) return 0;
let ci = Math.round(p.lat / corr.dLat), cj = Math.round(p.lon / corr.dLon);
let v0 = corr.cells.get(ci + "," + cj);
if (v0 != null) return v0;
for (let di = -1; di <= 1; di++) for (let dj = -1; dj <= 1; dj++) {
let v = corr.cells.get((ci + di) + "," + (cj + dj));
if (v != null) return v;
}
return 0; // no ridden coverage here
});
let half = 3;
return pts.map((p, i) => {
if (p.ele == null) return p;
let sum = 0, cnt = 0;
for (let j = Math.max(0, i - half); j <= Math.min(pts.length - 1, i + half); j++) { sum += deltas[j]; cnt++; }
let d = cnt ? sum / cnt : 0;
return d ? { ...p, ele: p.ele + d } : p;
});
}
async function showRouteDetail(container, routeId) {
let route = await DB.getRoute(routeId);
if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); showRoutesScreen(container); return; }
let pts = await applyRiddenElevCorrection(route.points);
let st = GPS.routeElevStats(pts);
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
let legendHtml = mode === "slope"
? slopeLegendHtml()
: `<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, { preferCanvas: true, attributionControl: false, zoomControl: false }); // canvas: slope-coloured route vectors vs SVG compositor tile-memory limit
map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing)
new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map);
let routeLayer = drawRoutePolyline(map, pts, route.color, mode);
// Tappable vertices → altitude + grade to next point
for (let i = 0; i < pts.length; i++) {
let e = st.smoothed[i], g = st.grades[i];
let info = `#${i}${e != null ? " | " + Math.round(e) + " м" : ""}${i < pts.length - 1 ? " | " + (g >= 0 ? "+" : "") + g.toFixed(1) + "%" : ""}`;
L.circleMarker([pts[i].lat, pts[i].lon], { radius: 4, color: "#fff", weight: 1, fillColor: "#000", fillOpacity: 0.5, interactive: true })
.bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
let bounds = L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.1);
// On-map color-mode toggle (slope <-> elevation) — redraws the route in place.
if (st.hasEle) {
let colorCtl = L.control({ position: "topleft" });
colorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = mode === "slope" ? "∡" : "⛰";
d.title = "Раскраска: уклон / высота";
d.onclick = (e) => {
e.stopPropagation();
mode = mode === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", mode);
map.removeLayer(routeLayer);
routeLayer = drawRoutePolyline(map, pts, route.color, mode);
d.innerHTML = mode === "slope" ? "∡" : "⛰";
let leg = container.querySelector("#rd-legend");
if (leg) leg.innerHTML = mode === "slope"
? slopeLegendHtml()
: `<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 = "&#x2715;";
map.invalidateSize(); map.fitBounds(bounds);
winPush("fsRouteMap", () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
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 = "&#x26F6;";
fsDiv.title = "На весь экран";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleFs(); };
return fsDiv;
};
fsCtl.addTo(map);
map.invalidateSize();
map.fitBounds(bounds);
}
if (st.hasEle) {
let ctx = container.querySelector("#rd-elev-chart");
if (ctx) new Chart(ctx, {
type: "line",
data: {
labels: st.cumKm.map(k => k.toFixed(1)),
datasets: [{
data: st.smoothed,
borderColor: "#ffb74d", borderWidth: 2, pointRadius: 0, fill: true,
backgroundColor: "rgba(255,183,77,0.12)", tension: 0.3,
// Steep (>8%) segments turn red — the sections that hurt on a singlespeed.
segment: { borderColor: c => Math.abs(st.grades[c.p0DataIndex] || 0) > 8 ? "#e53935" : "#ffb74d" },
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: {
title: items => items[0].label + " км",
label: it => Math.round(it.raw) + " м, уклон " + ((st.grades[it.dataIndex] || 0) >= 0 ? "+" : "") + (st.grades[it.dataIndex] || 0).toFixed(1) + "%",
} },
},
scales: {
x: { title: { display: true, text: "км", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "м", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}
}
// Draw a NEW saved route, or edit an existing one's geometry, right in the app —
// tap to add points, undo the last, save. Elevations are fetched afterwards from
// the preview screen (geometry-only here). New -> prompts a name; edit -> replaces
// points_json (drops stale elevations — geometry changed) keeping name/color.
async function showRouteEditor(container, routeId) {
let editing = routeId != null;
let route = editing ? await DB.getRoute(routeId) : null;
// Editable waypoints. Planner-built routes store them separately (5-6 pts) + per-leg mode;
// imported / legacy routes only have dense geometry → load that as waypoints (legs straight).
let wpSaved = (editing && route && route.waypoints && route.waypoints.waypoints) ? route.waypoints : null;
let points = wpSaved
? wpSaved.waypoints.map(p => ({ lat: p.lat, lon: p.lon }))
: ((editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : []);
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
let markers = [];
// Per-leg road routing: each leg between consecutive waypoints is independently "road"
// (BRouter geometry, with elevation, cached in legGeom[i]) or "straight". Default = road.
let hitLines = []; // transparent fat polyline per leg — the tap/long-press target
let displayLayer = null; // the visible route (casing+slope+thin line) via drawRoutePolyline
let legMode = []; // "road" | "straight", length = points.length - 1
let legGeom = []; // routed [{lat,lon,ele}] per road leg, or null
let legDangers = []; // [{lat,lon,type}] per road leg (fords/rough from BRouter tags)
let dangerLayer = null; // danger markers overlay
let defaultMode = "road";
let routeTimer = null;
let dirty = new Set();
let nogos = []; // [{lat,lon,radius}] impassable circles the route must avoid
let nogoLayer = null; // red circles overlay
let nogoMode = false; // when on, a map tap drops a nogo instead of a waypoint
container.innerHTML = `
<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="re-bottom">
<div class="re-hint">тап карты — точка · тап линии — вставить точку · долгий тап — дорога/прямая · тап точки — удалить</div>
<div id="re-danger" class="re-danger"></div>
<div class="re-btns">
<button id="re-brouter" class="btn-secondary active" title="Все отрезки: по дорогам / прямые">🚲 По дорогам</button>
<button id="re-adventure" class="btn-secondary" title="Броды и сложные тропы: избегать / разрешить">Броды: избегать</button>
<button id="re-undo" class="btn-secondary">Отменить</button>
<button id="re-save" class="btn-primary">Сохранить</button>
</div>
<button id="re-cancel" class="btn-secondary re-cancel">Отмена</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:14px;height:14px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 2px #000"></div>`,
iconSize: [18, 18], iconAnchor: [9, 9],
});
function normalizeLegs() {
let need = Math.max(0, points.length - 1);
while (legMode.length < need) { legMode.push(defaultMode); legGeom.push(null); legDangers.push(null); }
legMode.length = need; legGeom.length = need; legDangers.length = need;
}
// Drawn/saved geometry for a leg: routed pts for a routed road leg (carry ele), else the
// straight chord (also the fallback when a road leg couldn't be routed).
function legPts(i) {
if (legMode[i] === "road" && legGeom[i] && legGeom[i].length >= 2) return legGeom[i];
return [points[i], points[i + 1]];
}
// Full dense geometry = concat of every leg (shared junction not duplicated); keeps ele.
function fullGeom() {
if (points.length < 2) return points.map(p => ({ lat: p.lat, lon: p.lon }));
let out = [{ lat: points[0].lat, lon: points[0].lon }];
for (let i = 0; i < points.length - 1; i++) {
let lp = legPts(i);
for (let k = 1; k < lp.length; k++) out.push({ lat: lp[k].lat, lon: lp[k].lon, ele: lp[k].ele });
}
return out;
}
function updateStats() {
let g = fullGeom(), dist = 0;
for (let i = 1; i < g.length; i++) dist += GPS.distance(g[i-1].lat, g[i-1].lon, g[i].lat, g[i].lon);
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
// Visible route: casing (slope/elev colour) + thin identity line, like the detail view.
function drawDisplay() {
if (displayLayer) { map.removeLayer(displayLayer); displayLayer = null; }
if (points.length >= 2) {
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
displayLayer = drawRoutePolyline(map, fullGeom(), color, mode);
}
drawDangers();
}
// Mark fords (water) and rough/abandoned tracks the route passes, so the rider sees the
// "ад" BEFORE riding it. Counter in the stats row. Fed by BRouter WayTags per leg.
function drawDangers() {
if (dangerLayer) { map.removeLayer(dangerLayer); dangerLayer = null; }
let all = [];
for (let d of legDangers) if (Array.isArray(d)) all = all.concat(d);
let fords = all.filter(x => x.type === "ford").length;
let wet = all.filter(x => x.type === "wetcross").length;
let rough = all.filter(x => x.type === "rough").length;
let dEl = document.querySelector("#re-danger");
if (dEl) {
let bits = [];
if (fords) bits.push(`брод: ${fords}`);
if (wet) bits.push(`возм.брод: ${wet}`);
if (rough) bits.push(`грунт: ${rough}`);
dEl.textContent = bits.length ? "⚠ " + bits.join(" · ") : "";
}
if (!all.length) return;
// ford = confirmed OSM ford (solid blue); wetcross = likely untagged ford from a high
// BRouter river-class on a track (light cyan — a guess); rough = orange; ferry = purple.
let COL = { ford: "#0288d1", wetcross: "#4dd0e1", rough: "#ff9800", ferry: "#9c27b0" };
let LBL = { ford: "брод", wetcross: "возм. брод", rough: "грунт/тропа", ferry: "паром" };
let g = L.layerGroup();
for (let d of all) {
L.circleMarker([d.lat, d.lon], { radius: 7, color: "#fff", weight: 2, fillColor: COL[d.type] || "#9c27b0", fillOpacity: 1 })
.bindTooltip(LBL[d.type] || d.type, { direction: "top" }).addTo(g);
}
g.addTo(map); dangerLayer = g;
}
// Fat transparent hit target per leg (tap = insert, long-press = toggle road/straight).
// pointer-events:stroke (CSS .re-hit) makes the invisible fat stroke clickable.
function drawHitLines() {
hitLines.forEach(l => map.removeLayer(l)); hitLines = [];
for (let i = 0; i < points.length - 1; i++) {
let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]),
{ color: "#000", weight: 26, opacity: 0, interactive: true, className: "re-hit" }).addTo(map);
(function (idx) {
line.on("click", (e) => { L.DomEvent.stop(e); if (nogoMode) placeNogo(e.latlng); else insertInLeg(idx, e.latlng); }); // tap → insert (or nogo)
line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight
})(i);
hitLines.push(line);
}
}
function redraw() { drawDisplay(); drawHitLines(); }
function markDirty(i) { if (i >= 0 && i < points.length - 1) dirty.add(i); }
function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(routeDirty, 450); }
async function routeDirty() {
let idxs = [...dirty]; dirty.clear();
for (let i of idxs) {
if (i >= points.length - 1 || legMode[i] !== "road") continue;
let a = points[i], b = points[i + 1];
// Prefer already-ridden roads; use BRouter (public OSM) only where there is no ridden path.
let ridden = null;
try { ridden = await rideRoute(a.lat, a.lon, b.lat, b.lon); } catch (e) { /* fall back */ }
if (ridden && ridden.length >= 2) { legGeom[i] = ridden.map(p => ({ lat: p.lat, lon: p.lon })); continue; }
try {
// GeoJSON carries per-segment WayTags → detect fords / rough tracks on this leg.
let gj = await BRouter.routeGeoJson([[a.lon, a.lat], [b.lon, b.lat]], trekProfile(), nogos);
let parsed = GPS.parseRouteGeoJson(gj) || {};
let rp = parsed.points || [];
legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope
legDangers[i] = (rp.length >= 2) ? (parsed.dangers || []) : null;
} catch (e) { legGeom[i] = null; legDangers[i] = null; } // silent fallback to straight
}
redraw(); updateStats();
}
function toggleLeg(i) {
if (i < 0 || i >= points.length - 1) return;
legMode[i] = (legMode[i] === "road") ? "straight" : "road";
if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); scheduleRoute(); }
redraw(); updateStats();
}
function afterEdit() { normalizeLegs(); redraw(); updateStats(); scheduleRoute(); }
function makeMarker(p) {
let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon });
m.on("drag", () => {
let idx = markers.indexOf(m); if (idx < 0) return;
points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng };
if (idx - 1 >= 0) legGeom[idx - 1] = null;
if (idx < points.length - 1) legGeom[idx] = null;
redraw(); updateStats();
});
m.on("dragend", () => {
let idx = markers.indexOf(m); if (idx < 0) return;
markDirty(idx - 1); markDirty(idx); scheduleRoute();
});
m.on("click", (e) => { L.DomEvent.stop(e); deletePoint(m); });
m.addTo(map);
return m;
}
function addPoint(latlng) {
points.push({ lat: latlng.lat, lon: latlng.lng });
markers.push(makeMarker(points[points.length - 1]));
if (points.length >= 2) { let li = points.length - 2; legMode[li] = defaultMode; legGeom[li] = null; markDirty(li); }
afterEdit();
}
function insertInLeg(legIdx, latlng) {
if (legIdx < 0 || legIdx >= points.length - 1) return;
let mode = legMode[legIdx] ?? defaultMode;
points.splice(legIdx + 1, 0, { lat: latlng.lat, lon: latlng.lng });
markers.splice(legIdx + 1, 0, makeMarker(points[legIdx + 1]));
legMode.splice(legIdx, 1, mode, mode); // one leg → two, same mode
legGeom.splice(legIdx, 1, null, null);
markDirty(legIdx); markDirty(legIdx + 1);
afterEdit();
}
function deletePoint(m) {
let idx = markers.indexOf(m); if (idx < 0) return;
points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m);
if (legMode.length) {
if (idx === 0) { legMode.splice(0, 1); legGeom.splice(0, 1); }
else if (idx > points.length - 1) { legMode.splice(idx - 1, 1); legGeom.splice(idx - 1, 1); }
else { let mode = legMode[idx - 1] ?? defaultMode; legMode.splice(idx - 1, 2, mode); legGeom.splice(idx - 1, 2, null); markDirty(idx - 1); }
}
afterEdit();
}
// --- Map controls, reused from the track/detail views ---
addDarkMapControl(map); // day/night tiles
// Slope <-> elevation colour toggle (shared preference); redraws the route.
let colorCtl = L.control({ position: "topleft" });
colorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "∡" : "⛰";
d.title = "Раскраска маршрута: уклон / высота";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", nm);
d.innerHTML = nm === "slope" ? "∡" : "⛰";
drawDisplay();
};
return d;
};
colorCtl.addTo(map);
// Locate me
let locCtl = L.control({ position: "topleft" });
locCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = "&#x25CE;";
d.title = "Моё местоположение";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
err => console.error("GPS error:", err.message), { enableHighAccuracy: true, timeout: 10000 });
};
return d;
};
locCtl.addTo(map);
// Nogo (impassable) circles: toggle the ⛔ button, then a map/line tap drops a red circle
// the routing avoids. Persist in DB; every road leg re-routes to go around them.
async function refreshNogos(reroute) {
nogos = await getNogosForRouter();
nogoLayer = await drawNogoAreas(map, nogoLayer, () => refreshNogos(true));
if (reroute) {
for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); }
redraw(); updateStats(); scheduleRoute();
}
}
async function placeNogo(latlng) {
await DB.addNogoArea(latlng.lat, latlng.lng, 25, null);
await refreshNogos(true);
}
let nogoCtl = L.control({ position: "topleft" });
nogoCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = "⛔";
d.title = "Пометить непроезжее: тап по карте";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
nogoMode = !nogoMode;
d.classList.toggle("active", nogoMode);
showToast(nogoMode ? "Тап по карте — пометить непроезжее" : "Режим отметки выключен");
};
return d;
};
nogoCtl.addTo(map);
refreshNogos(false);
// Existing route: load waypoints. Planner-built → its saved leg modes (re-route road legs
// to get fresh geometry + elevations). Imported/legacy dense → all legs straight.
if (points.length) {
points.forEach(p => markers.push(makeMarker(p)));
for (let i = 0; i < points.length - 1; i++) {
legMode[i] = (wpSaved && wpSaved.legMode && wpSaved.legMode[i]) ? wpSaved.legMode[i] : (wpSaved ? "road" : "straight");
legGeom[i] = null;
if (legMode[i] === "road") markDirty(i);
}
normalizeLegs(); redraw(); updateStats();
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
if (dirty.size) scheduleRoute();
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
map.on("click", e => { if (nogoMode) placeNogo(e.latlng); else addPoint(e.latlng); });
document.querySelector("#re-undo").onclick = () => {
if (!points.length) return;
let m = markers.pop(); points.pop(); if (m) map.removeLayer(m);
if (legMode.length) { legMode.pop(); legGeom.pop(); }
afterEdit();
};
document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container);
// Global toggle: set ALL legs (and the default for new ones) to road or straight.
let brBtn = document.querySelector("#re-brouter");
brBtn.onclick = () => {
defaultMode = (defaultMode === "road") ? "straight" : "road";
brBtn.classList.toggle("active", defaultMode === "road");
for (let i = 0; i < legMode.length; i++) { legMode[i] = defaultMode; legGeom[i] = null; if (defaultMode === "road") markDirty(i); }
redraw(); updateStats(); if (defaultMode === "road") scheduleRoute();
};
// Broды/сложные тропы: избегать (default) vs разрешить (adventure). Re-routes all road
// legs with the chosen profile; danger markers show what the route then passes.
let advBtn = document.querySelector("#re-adventure");
let advOn = localStorage.getItem("baldav_allow_adventure") === "1";
let syncAdv = () => { advBtn.textContent = advOn ? "Броды: разрешить" : "Броды: избегать"; advBtn.classList.toggle("active", advOn); };
syncAdv();
advBtn.onclick = () => {
advOn = !advOn;
localStorage.setItem("baldav_allow_adventure", advOn ? "1" : "0");
syncAdv();
for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; legDangers[i] = null; markDirty(i); }
scheduleRoute();
};
document.querySelector("#re-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let btn = document.querySelector("#re-save");
btn.disabled = true; btn.textContent = "Сохранение...";
let dense = fullGeom(); // routed geometry with elevations (for display + slope)
if (dense.length < 2) { showToast("Маршрут пуст"); btn.disabled = false; btn.textContent = "Сохранить"; return; }
// Store the editable waypoints + per-leg mode alongside the dense geometry, so re-editing
// shows the 5-6 points again instead of the thousands of routed points.
let wpOut = { waypoints: points.map(p => ({ lat: p.lat, lon: p.lon })), legMode: legMode.slice() };
if (editing) {
await DB.updateRoutePoints(routeId, dense, wpOut);
showToast("Маршрут обновлён");
showRouteDetail(container, routeId);
} else {
let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить");
if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; }
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", dense, wpOut);
showToast("Маршрут создан");
showRouteDetail(container, id);
}
};
}, 200);
}
function showDrawRouteScreen(container) {
let points = []; // {lat, lon}
let markers = [];
let polyline = null;
container.innerHTML = `
<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
});
}
// HR-only "workout" session detail: no map — HR chart + load (kcal) + avg/max.
function renderWorkoutDetail(session) {
let samples = [];
try { samples = JSON.parse(session.hr_samples || "[]"); } catch {}
let hrs = samples.map(s => s[1]);
let durSec = samples.length ? samples[samples.length - 1][0] : 0;
let avg = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
let max = hrs.length ? Math.max(...hrs) : null;
let kcal = Math.round(session.total_kcal || 0);
let html = `
<div class="sd-section"><div class="sd-title">${session.activity_type === "vr" ? "Руки/VR" : "Зарядка"} · ${session.date} ${session.start_time ?? ""}</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
<div class="sd-stat"><span class="sd-val">${kcal} ккал</span><span class="sd-lbl">Нагрузка</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${avg ?? "—"}</span><span class="sd-lbl">Средний пульс</span></div>
<div class="sd-stat"><span class="sd-val">${max ?? "—"}</span><span class="sd-lbl">Макс пульс</span></div>
</div>
</div>
<div class="sd-section"><div class="sd-title">Пульс</div>
<div class="chart-wrap"><canvas id="wk-hr-chart" height="150"></canvas></div>
</div>
<button id="wk-del" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить зарядку</button>`;
showModal(html, () => {}, (overlay) => {
let ctx = overlay.querySelector("#wk-hr-chart");
if (ctx && samples.length) {
new Chart(ctx, {
type: "line",
data: { labels: samples.map(s => Math.round(s[0] / 60) + "м"),
datasets: [{ data: hrs, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3,
segment: { borderColor: c => hrZoneColor(c.p1.parsed.y) } }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false } } },
});
}
let del = overlay.querySelector("#wk-del");
if (del) del.onclick = async () => {
if (!(await appConfirm("Удалить эту зарядку?", "Удалить"))) return;
await DB.deleteGpsSession(session.id);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1; history.back();
renderScreen("gps");
};
});
}
// HR-only "workout" recording screen (зарядка): no map/GPS, live heart rate + timer + kcal.
function renderWorkoutInline(container) {
container.innerHTML = `
<div class="workout-screen">
<div class="wk-hr-big" id="wk-hr">--</div>
<div class="wk-hr-lbl">уд/мин</div>
<div class="wk-stats">
<div class="wk-stat"><span id="wk-time" class="wk-val">0:00</span><span class="wk-lbl">Время</span></div>
<div class="wk-stat"><span id="wk-kcal" class="wk-val">0</span><span class="wk-lbl">ккал</span></div>
<div class="wk-stat"><span id="wk-avg" class="wk-val">--</span><span class="wk-lbl">средний</span></div>
<div class="wk-stat"><span id="wk-max" class="wk-val">--</span><span class="wk-lbl">макс</span></div>
</div>
<div class="wk-status" id="wk-status">Подключите пульсометр</div>
<button id="wk-connect" class="btn-secondary full-width">Подключить пульсометр</button>
<button id="wk-stop" class="btn-primary gps-rec recording full-width">Стоп</button>
</div>`;
let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time");
let kcalEl = container.querySelector("#wk-kcal"), avgEl = container.querySelector("#wk-avg");
let maxEl = container.querySelector("#wk-max"), statusEl = container.querySelector("#wk-status");
GPS.recording.onHrUpdate = (reading) => {
if (hrEl) hrEl.textContent = reading.hr;
if (statusEl) statusEl.textContent = "Пульсометр на связи";
};
let tick = () => {
if (!GPS.recording.active) { clearInterval(window._wkTimer); return; }
let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0;
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
let samples = GPS.recording.hrSamples || [], hrs = samples.map(s => s[1]);
if (kcalEl) kcalEl.textContent = GPS.hrKcalNet(samples, GPS.recording.hrProfile);
if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--";
if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--";
if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr;
if (statusEl && GPS.recording.currentHr) {
let b = GPS.recording.currentBattery;
statusEl.textContent = "Пульсометр на связи" + (b != null ? ` · заряд ${b}%` : "");
}
};
clearInterval(window._wkTimer);
window._wkTimer = setInterval(tick, 1000);
tick();
container.querySelector("#wk-connect").onclick = async () => {
let addr = state.profile?.ble_device_addr;
if (!addr) { showToast("Сначала привяжите датчик: Настройки -> Пульсометр -> Поиск"); return; }
let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {}
if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth"); return; }
if (statusEl) statusEl.textContent = "Подключение...";
try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: state.profile?.ble_device_type || "hr_standard" }); } catch (e) { showToast("Ошибка: " + (e.message || e)); }
};
container.querySelector("#wk-stop").onclick = async () => {
clearInterval(window._wkTimer);
let res = await GPS.stopRecording();
if (res && res.sessionId) {
showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`);
showSessionDetail(res.sessionId);
} else { renderScreen("gps"); }
};
}
// Send an SMS with the current position and a Yandex Maps pin link. Direct send if the system
// granted SEND_SMS (the in-app confirm is the only tap), otherwise the native side opens the SMS
// app prefilled. Works over 2G/EDGE with no internet — it is a plain text message.
async function sendLocationSms() {
let number = (localStorage.getItem("baldav_sms_number") || "").trim();
if (!number) { appAlert("Сначала укажи номер в профиле"); return; }
let pts = GPS.recording.points || [];
let last = GPS.recording.lastMovingPoint || pts[pts.length - 1];
if (!last || last.lat == null) { appAlert("Нет координат — подожди фикс GPS"); return; }
let lat = (+last.lat).toFixed(5), lon = (+last.lon).toFixed(5);
// Yandex Maps: pt=lon,lat drops a pin at the position. Link ONLY (ASCII, ~54 chars)
// so the whole message is GSM-7 and fits in a single SMS — any Cyrillic would force
// UCS-2 (70-char limit) and risk splitting into two.
let text = `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map`;
let esc = s => String(s).replace(/</g, "&lt;");
let ok = await appConfirm(
`Отправить СМС с местоположением?<br><br><b>Кому:</b> ${esc(number)}<br><br><span style="font-size:12px;color:#9aa4ae;word-break:break-all">${esc(text)}</span>`,
"Отправить");
if (!ok) return;
try {
await window.__TAURI__.core.invoke("send_sms", { number, text });
} catch (e) { appAlert("Ошибка отправки: " + e); }
}
// "Отдать смотрящему": hand the viewer the active route + ridden roads + track-so-far. Sent to
// the server's RAM relay (NOT stored on its disk); the viewer pulls it once into their own
// localStorage. One-shot; press again to resend (e.g. after the track grew).
async function shareRideContext() {
let url = (localStorage.getItem("baldav_live_url") || "").trim();
let token = (localStorage.getItem("baldav_live_token") || "").trim();
if (!url || !token) { appAlert("Сначала задай адрес и токен трансляции в профиле"); return; }
let shareUrl = url.endsWith("/push") ? url.slice(0, -5) + "/share" : url.replace(/\/[^/]*$/, "/share");
// Round coords to 5 decimals (~1 m) — enough to draw, and cuts the payload a lot
// (full doubles are ~2x longer). Roads can be big; this keeps the POST reasonable.
let r5 = v => Math.round((+v) * 1e5) / 1e5;
let track = (GPS.recording.points || []).map(p => [r5(p.lat), r5(p.lon)]);
let route = [];
try {
let rid = localStorage.getItem("baldav_active_route");
if (rid) { let r = await DB.getRoute(rid); if (r && r.points) route = r.points.map(p => [r5(p.lat), r5(p.lon)]); }
} catch (e) {}
let roads = [];
try {
let segs = buildRoadSegments(await DB.getRoadPoints());
roads = segs.map(s => ({ p: s.pts.map(q => [r5(q.lat), r5(q.lon)]), t: s.atype || "b" }));
} catch (e) {}
// POI near the ride only (bbox of route+track + margin) — the full set is too big to send.
let pois = [];
try {
let all = await POI.load();
let bpts = track.concat(route);
if (all && all.length && bpts.length) {
let minLa = 1e9, maxLa = -1e9, minLo = 1e9, maxLo = -1e9;
for (let p of bpts) { if (p[0] < minLa) minLa = p[0]; if (p[0] > maxLa) maxLa = p[0]; if (p[1] < minLo) minLo = p[1]; if (p[1] > maxLo) maxLo = p[1]; }
minLa -= 0.05; maxLa += 0.05; minLo -= 0.07; maxLo += 0.07; // ~5 km margin
pois = all.filter(p => p.la >= minLa && p.la <= maxLa && p.lo >= minLo && p.lo <= maxLo)
.map(p => ({ la: r5(p.la), lo: r5(p.lo), c: p.c, n: p.n }));
}
} catch (e) {}
showToast("Отдаю смотрящему...");
try {
let raw = await window.__TAURI__.core.invoke("http_request", {
url: shareUrl, method: "POST", body: JSON.stringify({ token, route, roads, track, pois })
});
let res = JSON.parse(raw || "{}");
if (res.ok) showToast(`Отдано: маршрут ${route.length}, дороги ${roads.length}, трек ${track.length}, POI ${pois.length}`);
else appAlert("Не отдалось: " + (res.error || "ошибка"));
} catch (e) { appAlert("Не отдалось: " + (e.message || e)); }
}
async function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
// Keep screen on while recording is visible
if (navigator.wakeLock) {
navigator.wakeLock.request("screen").then(lock => {
window._screenLock = lock;
}).catch(() => {});
}
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; }
container.innerHTML = `
<div class="rec-screen">
<div class="rec-topbar">
<span class="rec-top" id="rec-time">0:00</span>
<span class="rec-top rec-top-mid" id="rec-temp">—</span>
<span class="rec-top" id="rec-clock">${new Date().toTimeString().slice(0,5)}</span>
</div>
<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-stats">
<div class="rec-stat"><span id="rec-hr-val" class="rec-val">--</span><span class="rec-lbl">Пульс</span></div>
<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-elev" class="rec-val">—</span><span class="rec-lbl">Высота</span></div>
</div>
<div class="rec-bottom">
<button id="rec-hr-toggle" class="btn-secondary rec-hr-toggle${GPS.recording.hrOn ? " active" : ""}" title="Пульсометр вкл/выкл" aria-label="ЧСС"><svg viewBox="0 0 24 22" width="22" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20S3 14.5 3 8.3C3 5.6 5 4 7.2 4 9 4 10.6 5.1 12 7 13.4 5.1 15 4 16.8 4 19 4 21 5.6 21 8.3c0 1.3-.4 2.5-1 3.7"/><path d="M2 13h5l2-3 2.4 6 2-3H22"/></svg><span id="rec-hr-num" class="rec-hr-num"></span></button>
<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;
// Remember the recording map's zoom across screen switches (persist on zoom, restore here).
let recZoom = parseInt(localStorage.getItem("baldav_rec_zoom"), 10);
if (!(recZoom >= 1 && recZoom <= 19)) recZoom = 15;
let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], recZoom);
map.on("zoomend", () => { try { localStorage.setItem("baldav_rec_zoom", String(map.getZoom())); } catch {} });
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along the ridden route whenever OSM is reachable.
TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon]));
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
let trailLine = L.polyline([], { color: GPS.config.trackColor || "#bb86fc", weight: 5 }).addTo(map);
// Map orientation: a mode button picks the source — magnetic compass or GPS course.
// Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show
// BOTH directions (orange = compass, green = GPS course) for visual comparison.
let rotOffset = 0; // 0/90/180/270 — whole-screen rotation
let compassFrozen = false; // X button: freeze current heading
let followMode = true; // ⊕ button: keep the map centred on the current position
let frozenHeading = 0; // heading captured at the moment of freezing
let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW)
let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW)
let prevCoursePt = null; // for the computed-bearing fallback
let lastPos = null; // last position, for drawing the direction arrows
let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course"
// Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing
// north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha).
let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0;
let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap
let recScreen = container.querySelector(".rec-screen");
let btnWrap = mapEl.parentElement;
// Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north.
let compassEl = document.createElement("div");
compassEl.className = "rec-compass";
compassEl.innerHTML = '<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 = "&#8593;"; // ↑
rotBtn.title = "Повернуть карту на 90°";
rotBtn.onclick = () => {
// 3 readable states: portrait + both landscapes. 180° is skipped —
// it would flip the panel text upside down ("низ" would end up on top).
rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0);
applyScreenRotation();
applyBearing();
};
btnWrap.appendChild(rotBtn);
// Freeze button — X: lock/unlock compass following (does NOT rotate)
let freezeBtn = document.createElement("button");
freezeBtn.className = "rec-map-btn rec-freeze-btn";
freezeBtn.textContent = "X";
freezeBtn.title = "Заморозить поворот карты";
freezeBtn.onclick = () => {
compassFrozen = !compassFrozen;
if (compassFrozen) frozenHeading = baseHeading();
freezeBtn.classList.toggle("active", compassFrozen);
applyBearing();
};
btnWrap.appendChild(freezeBtn);
// Follow button — ⊕: toggle whether the map keeps re-centring on the current position.
// Off = free pan (look around when lost / off-route); tapping it back on recentres now.
let followBtn = document.createElement("button");
followBtn.className = "rec-map-btn rec-follow-btn active";
followBtn.textContent = "⊕"; // ⊕
followBtn.title = "Следить за позицией";
followBtn.onclick = () => {
followMode = !followMode;
followBtn.classList.toggle("active", followMode);
if (followMode) {
// Re-enabling: jump straight back to where we are now.
let pts = GPS.recording.points;
if (pts && pts.length) {
let last = pts.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts))[pts.length - 1];
if (last) map.panTo([last.lat, last.lon]);
}
}
};
btnWrap.appendChild(followBtn);
// Left-side group (bottom-left, opposite the right controls): live-location toggle + SMS.
// Live sharing can be switched on/off right here even if it was never set in the profile.
let liveBtn = document.createElement("button");
liveBtn.className = "rec-map-btn rec-live-btn";
liveBtn.textContent = "LIVE";
liveBtn.title = "Трансляция местоположения вкл/выкл";
let liveOn = localStorage.getItem("baldav_live_on") === "1";
liveBtn.classList.toggle("active", liveOn);
liveBtn.onclick = async () => {
liveOn = !liveOn;
localStorage.setItem("baldav_live_on", liveOn ? "1" : "0");
liveBtn.classList.toggle("active", liveOn);
let url = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push";
let token = localStorage.getItem("baldav_live_token") || "";
try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url, token }); }
catch (e) { console.error("live toggle", e); }
if (liveOn && !token) appAlert("Трансляция включена, но токен не задан в профиле - точки не уйдут, пока не впишешь токен.");
};
btnWrap.appendChild(liveBtn);
// SMS "я тут" button
let smsBtn = document.createElement("button");
smsBtn.className = "rec-map-btn rec-sms-btn";
smsBtn.textContent = "СМС";
smsBtn.title = "Отправить СМС с местоположением";
smsBtn.onclick = () => sendLocationSms();
btnWrap.appendChild(smsBtn);
// Hand the viewer the route + ridden roads + track (via the server's RAM relay, not its disk).
let shareBtn = document.createElement("button");
shareBtn.className = "rec-map-btn rec-share-btn";
shareBtn.textContent = "Отд";
shareBtn.title = "Отдать смотрящему: маршрут, проезженные дороги и пройденный трек";
shareBtn.onclick = () => shareRideContext();
btnWrap.appendChild(shareBtn);
// Roads toggle button. Roads are viewport-culled onto a canvas pane (only the segments in
// view are drawn, redrawn on pan/zoom) so a huge road network doesn't jank the WebView.
let roadsBtn = document.createElement("button");
roadsBtn.className = "rec-map-btn rec-roads-toggle";
roadsBtn.textContent = "R";
let roadLayers = [];
let roadSegs = null, roadsOn = false, roadsRenderer = null;
let redrawRoads = () => { if (roadsOn && roadSegs) cullRoadsInto(map, roadSegs, roadLayers, 0.2, { pane: "roadsPane", renderer: roadsRenderer }); };
let roadsMove = debounce(redrawRoads, 120);
roadsBtn.onclick = async () => {
if (roadsOn) {
roadsOn = false;
map.off("moveend zoomend", roadsMove);
roadLayers.forEach(l => map.removeLayer(l)); roadLayers.length = 0;
roadsBtn.classList.remove("active");
localStorage.setItem("baldav_show_roads", "0");
return;
}
roadsOn = true;
roadsBtn.classList.add("active");
localStorage.setItem("baldav_show_roads", "1");
// Roads live in their own pane BELOW route/track: tiles(200)<roads(240)<route(250)<track(400).
if (!map.getPane("roadsPane")) { map.createPane("roadsPane", map._rotatePane || undefined); map.getPane("roadsPane").style.zIndex = 240; }
if (!roadsRenderer) roadsRenderer = L.canvas({ pane: "roadsPane", padding: 0.2 });
if (!roadSegs) { let pts = await DB.getRoadPoints(); roadSegs = buildRoadSegments(pts); }
if (!roadsOn) return; // toggled off while loading
redrawRoads();
map.on("moveend zoomend", roadsMove);
};
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);
// Dark-map toggle (moon): invert the tile layer only, remembered globally.
let darkBtn = document.createElement("button");
darkBtn.className = "rec-map-btn rec-dark-toggle" + (isDarkMap() ? " active" : "");
darkBtn.textContent = "☾";
darkBtn.title = "Тёмная карта";
darkBtn.onclick = () => toggleDarkMap();
btnWrap.appendChild(darkBtn);
// Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute
// will avoid it). Shows existing nogo circles too.
let recNogoLayer = null;
async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); }
refreshRecNogos();
let recNogoBtn = document.createElement("button");
recNogoBtn.className = "rec-map-btn rec-nogo-btn";
recNogoBtn.textContent = "⛔";
recNogoBtn.title = "Здесь нет проезда (отметить текущее место)";
recNogoBtn.onclick = async () => {
let p = GPS.recording.lastMovingPoint;
if (!p) { showToast("Нет позиции"); return; }
await DB.addNogoArea(p.lat, p.lon, 25, null);
await refreshRecNogos();
showToast("Отмечено: нет проезда");
};
btnWrap.appendChild(recNogoBtn);
// Reroute back onto the active route past a blockage (nogo), from the current position,
// avoiding nogos and 180° turns. Transient navigation overlay — does NOT touch the saved
// route or the recording. Tap again to rebuild from the new position; long-press to clear.
let rerouteLayer = null;
let rerouteBtn = document.createElement("button");
rerouteBtn.className = "rec-map-btn rec-reroute-btn";
rerouteBtn.textContent = "↪";
rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом";
rerouteBtn.onclick = async () => {
let rid = localStorage.getItem("baldav_active_route");
if (!rid) { showToast("Нет активного маршрута"); return; }
let cur = GPS.recording.lastMovingPoint;
if (!cur) { showToast("Нет позиции"); return; }
rerouteBtn.disabled = true; rerouteBtn.classList.add("active");
try {
let route = await DB.getRoute(rid);
if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); return; }
let ng = await getNogosForRouter();
let bearing = (typeof smoothedHeading === "number") ? smoothedHeading : null;
let res = await rejoinReroute(cur, bearing, route.points, ng);
if (res && res.noBlockage) { showToast("Впереди затыка нет (нет nogo на маршруте)"); rerouteBtn.classList.remove("active"); return; }
if (!res) { showToast("Объезд не найден"); rerouteBtn.classList.remove("active"); return; }
if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; }
let ll = res.points.map(p => [p.lat, p.lon]);
let g = L.layerGroup();
L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(g);
L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(g);
L.polyline(ll, { color: "#ff00ff", weight: 3, opacity: 1 }).addTo(g);
L.circleMarker([res.target.lat, res.target.lon], { radius: 7, color: "#ff00ff", fillColor: "#fff", fillOpacity: 1, weight: 3 }).addTo(g);
g.addTo(map); rerouteLayer = g;
showToast("Объезд построен");
} catch (e) { showToast("Объезд: " + (e.message || e)); }
finally { rerouteBtn.disabled = false; }
};
rerouteBtn.oncontextmenu = (e) => { e.preventDefault(); if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; rerouteBtn.classList.remove("active"); showToast("Объезд убран"); } };
btnWrap.appendChild(rerouteBtn);
POI.attach(map); // POI overlay + category filter on the recording map
// The default-ON draw is deferred to the END of this function (after all the
// critical wiring), where it awaits whenMapReady — so it can't delay setup.
function handleOrientation(e) {
let raw;
if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading
else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion
else return;
lastRawHeading = raw; // for the calibration tap
let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset
// EMA smoothing to reduce needle jitter (factor 0.3).
if (smoothedHeading === null) smoothedHeading = heading;
else {
let diff = heading - smoothedHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360;
}
// DEBUG overlay: raw sensor values on screen (to verify calibration).
// Only in debug builds — window.__BALDAV_DEBUG is set in init() from
// get_app_info().debug (cfg!(debug_assertions)); silent in release.
if (window.__BALDAV_DEBUG) {
let dbg = document.getElementById("orient-dbg");
if (!dbg) {
dbg = document.createElement("div");
dbg.id = "orient-dbg";
dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;";
mapEl.parentElement.appendChild(dbg);
}
dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading);
}
refreshMe(); // update the direction triangle from the live compass heading
if (!compassFrozen) applyBearing();
}
let throttledOrientation = L.Util.throttle(handleOrientation, 100);
let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(orientEvent, throttledOrientation);
applyBearing(); // initial: north-up until first compass reading
GPS.recording._compassCleanup = () => {
window.removeEventListener(orientEvent, throttledOrientation);
if (recScreen) recScreen.classList.remove("rot-90", "rot-270");
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = "";
};
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
let ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
if (followMode) map.setView([p.lat, p.lon], 16);
} else if (state === "recording") {
statusEl.textContent = "";
statusEl.style.color = "";
} else if (state === "resumed") {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Live point updates
let distEl = document.querySelector("#rec-dist");
let spdEl = document.querySelector("#rec-speed");
let timeEl = document.querySelector("#rec-time");
let elevEl = document.querySelector("#rec-elev");
let statusEl = { style: {} }; // status line removed from the UI; keep a no-op sink so the existing statusEl.* writes don't crash
// Speed buffer for median
let speedBuf = [];
GPS.recording.onPoint = (p) => {
let ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
if (followMode) map.panTo(ll);
// Save position for next session start (avoid black screen)
DB.setProfileValue("last_lat", String(p.lat));
DB.setProfileValue("last_lon", String(p.lon));
updateMe(ll);
// Stats
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
// Show current state
let recState = GPS.recording.state;
if (recState === "waiting_gps") {
statusEl.textContent = "Поиск GPS...";
statusEl.style.color = "#ffb74d";
if (followMode) map.setView([p.lat, p.lon], 15);
return;
}
if (recState === "ready") {
updateMe([p.lat, p.lon]); // position only (no heading yet)
return;
}
// Orient map by direction of movement (GPS course) + update direction arrows
updateCourse(p);
// Speed
let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
speedBuf.push(curSpeed);
if (speedBuf.length > 5) speedBuf.shift();
let sorted = speedBuf.slice().sort((a, b) => a - b);
let median = sorted[Math.floor(sorted.length / 2)] || 0;
if (spdEl) spdEl.textContent = median.toFixed(1);
// Elevation
let alt = p.altitude;
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + " м";
// Pause indicator
if (GPS.recording.pauseStart) {
statusEl.textContent = "Пауза";
statusEl.style.color = "#ffb74d";
} else {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// HR update from BLE
GPS.recording.onHrUpdate = (reading) => {
let hrEl = document.querySelector("#rec-hr-val"); // HR stat in the bottom row
if (hrEl) { hrEl.textContent = reading.hr; hrEl.className = "rec-val " + hrZoneClass(reading.hr); }
let hrNum = document.querySelector("#rec-hr-num"); // live HR on the toggle button itself
if (hrNum) hrNum.textContent = reading.hr;
setHrBatteryFill(document.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // strap charge
};
setHrBatteryFill(container.querySelector("#rec-hr-toggle"), GPS.recording.currentBattery); // on render, if already known
// Timer — excludes pauses, starts only when recording
let timer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (!GPS.recording.startTime) return; // not started yet
let totalMs = Date.now() - GPS.recording.startTime.getTime();
let activeMs = totalMs - GPS.recording.totalPauseMs -
(GPS.recording.pauseStart ? Date.now() - GPS.recording.pauseStart.getTime() : 0);
let sec = Math.max(0, Math.round(activeMs / 1000));
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
// Update clock
let clockEl = document.querySelector("#rec-clock");
if (clockEl) clockEl.textContent = new Date().toTimeString().slice(0, 5);
}, 1000);
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
async function fetchTemperature() {
let pos = GPS.recording.lastMovingPoint || { lat: parseFloat(state.profile.last_lat) || 47.42, lon: parseFloat(state.profile.last_lon) || 40.08 };
let lat = pos.lat.toFixed(2), lon = pos.lon.toFixed(2);
let invoke = window.__TAURI__.core.invoke;
let apis = [
// Yandex Pogoda — works on Russian mobile internet, scrape HTML
{ url: `https://yandex.ru/pogoda/?lat=${lat}&lon=${lon}`,
parse: r => { let m = r.match(/FactTemperature_value[^>]*>(-?\d+)/); return m ? parseInt(m[1]) : null; } },
// open-meteo — free API, may be blocked on mobile
{ url: `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m`,
parse: r => { try { return JSON.parse(r).current?.temperature_2m; } catch { return null; } } },
// wttr.in — free, may be blocked on mobile
{ url: `https://wttr.in/${lat},${lon}?format=%t`,
parse: r => { let n = parseFloat(r); return isNaN(n) ? null : n; } },
];
for (let api of apis) {
try {
let resp = await invoke("http_request", { url: api.url, method: "GET", body: null });
let temp = api.parse(resp);
if (temp != null && !isNaN(temp)) {
let el = document.querySelector("#rec-temp");
if (el) el.textContent = Math.round(temp) + "°";
return;
}
} catch {}
}
}
fetchTemperature();
let weatherTimer = setInterval(fetchTemperature, 15 * 60 * 1000);
// Headwind label toggle: tap to open a "wind in the face" interval, tap to close.
// Marks are ground truth for validating the from-track wind estimate — not kcal.
// HR on/off toggle (replaces the old "Ветер" button). ON connects the paired strap + polls;
// OFF disconnects. Grey = off, coloured = on.
let hrToggle = container.querySelector("#rec-hr-toggle");
if (hrToggle) hrToggle.onclick = async () => {
hrToggle.disabled = true;
await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once)
hrToggle.classList.toggle("active", !!GPS.recording.hrOn);
if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill
hrToggle.disabled = false;
};
// Pause button
container.querySelector("#rec-pause").onclick = () => {
if (GPS.recording.manualPaused) {
GPS.resumeRecording();
container.querySelector("#rec-pause").textContent = "Пауза";
statusEl.textContent = "";
statusEl.style.color = "";
} else {
GPS.pauseRecording();
container.querySelector("#rec-pause").textContent = "Продолжить";
statusEl.textContent = "Пауза (ручная)";
statusEl.style.color = "#ffb74d";
}
};
// Stop button
container.querySelector("#rec-stop").onclick = async () => {
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
clearInterval(timer);
renderScreen("gps");
};
// Restore trail if already recording — sort by seq/ts to avoid jumps
if (GPS.recording.points.length > 0) {
let sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
let coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
map.fitBounds(trailLine.getBounds().pad(0.1));
}
// Batch update callback — called after poll reads multiple points at once.
// Rebuilds polyline from all sorted points instead of adding one-by-one.
GPS.recording.onBatchUpdate = () => {
let sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
let coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
if (coords.length) {
let last = coords[coords.length - 1];
if (followMode) map.panTo(last);
updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing)
updateMe(last);
}
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
};
// Lightweight per-point draw for progressive import (screen-wake catch-up):
// only extends the polyline so the track visibly grows. Heavy work (stats,
// marker, course) is done periodically + at the end by onBatchUpdate.
GPS.recording.onImportPoint = (p) => {
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
};
// Finally: if a route is selected (and not toggled off), draw it once the map is
// ready. Done LAST so awaiting map-load never delays the compass/handlers/buttons.
if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") {
await whenMapReady(map);
await setRouteShown(true);
}
}
function setupLiveUpdates(container) {
let distEl = container.querySelector("#gs-dist");
let spdEl = container.querySelector("#gs-speed");
let timeEl = container.querySelector("#gs-time");
let timer = null;
GPS.recording.onPoint = (p) => {
if (!gpsTrailLine || !gpsMap) return;
gpsTrailLine.addLatLng([p.lat, p.lon]);
gpsMap.panTo([p.lat, p.lon]);
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) {
let curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
spdEl.textContent = curSpd + " км/ч";
}
};
GPS.recording.onBatchUpdate = () => {
if (!gpsTrailLine || !gpsMap) return;
let sorted = GPS.recording.points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let coords = sorted.map(p => [p.lat, p.lon]);
gpsTrailLine.setLatLngs(coords);
if (coords.length) gpsMap.panTo(coords[coords.length - 1]);
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
};
// Lightweight per-point draw for progressive import (see recording screen).
GPS.recording.onImportPoint = (p) => {
if (!gpsTrailLine) return;
gpsTrailLine.addLatLng([p.lat, p.lon]);
};
// Timer for duration display + auto-finish watchdog for a forgotten session
timer = setInterval(async () => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (GPS.shouldAutoFinish()) {
clearInterval(timer);
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
showToast("Тренировка завершена автоматически — нет движения");
renderScreen("gps");
return;
}
let sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
}
// ---------------------------------------------------------------------------
// Map download modal — pick region + zoom range
// ---------------------------------------------------------------------------
let MAP_REGIONS = [
{ name: "Текущий вид карты", bounds: null },
{ name: "--- Города ---", bounds: null, disabled: true },
{ name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] },
{ name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] },
{ name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] },
{ name: "Мой регион", bounds: [[47.35, 39.95], [47.52, 40.22]] },
{ name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] },
{ name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] },
{ name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] },
{ name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] },
{ name: "--- Области ---", bounds: null, disabled: true },
{ name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] },
{ name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] },
{ name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] },
{ name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] },
];
function showMapDownloadModal() {
let regionOpts = MAP_REGIONS.map((r, i) =>
r.disabled
? `<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;
// Remember each track's last map view (centre+zoom) so a recompute/refine reopen — or coming
// back to a track you looked at — restores where you were instead of re-fitting the whole track.
let _sdMapView = {};
async function showSessionDetail(sessionId) {
let renderToken = ++_sessionDetailRender;
try {
console.log("[SD] loading session", sessionId);
let session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
// HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path.
if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
let points = GPS.filterNoise(rawPoints, session.activity_type);
// If this track has a saved smoothed (de-jittered) variant active, display it —
// rawPoints keeps the original lat/lon (for re-tuning / revert).
if (session.smoothed) points = points.map(p => (p.lat_smoothed != null ? { ...p, lat: p.lat_smoothed, lon: p.lon_smoothed } : p));
console.log("[SD] filtered to", points.length, "points");
// Use stored session stats if available (computed correctly at import time),
// fallback to recomputing from points
let storedStats = session.total_distance_m != null;
let stats = storedStats ? {
distance: Math.round(session.total_distance_m || 0),
avgSpeed: session.avg_speed_kmh || 0,
maxSpeed: session.max_speed_kmh || 0,
kcal: Math.round(session.total_kcal || 0),
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
// Manual-pause total (ms) to exclude from displayed duration
let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0);
// Compute duration from points regardless (minus manual pauses)
if (points.length >= 2) {
stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000));
}
// Always compute segments from points (needed for the Segments section)
if (!stats.segments && points.length >= 3) {
let fullStats = GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
stats.segments = fullStats.segments;
}
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
let rawSpeeds = [];
for (let i = 1; i < points.length; i++) {
let devSpd = points[i].speed_ms;
if (devSpd != null && devSpd >= 0) {
rawSpeeds.push(devSpd * 3.6);
} else {
let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
}
}
// 31-point moving average — kills GPS jitter from phone-in-pocket
let HALF_WIN = 15;
let smoothed = rawSpeeds.map((_, i) => {
let sum = 0, count = 0;
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
sum += rawSpeeds[j]; count++;
}
return Math.round(sum / count * 10) / 10;
});
// maxSpeed from smoothed graph (not raw)
let graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
// Elevation data
// Prefer refined terrain altitude (srtm, written by "Уточнить высоты") over raw
// GPS altitude — same priority computeStats uses, so the chart matches the stats.
let elevations = GPS.filterAndSmoothAlt(points.map(p => p.altitude_srtm_m ?? p.altitude_gps_m));
let minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
let maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
let duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
: "—";
let tempo = stats.duration > 0 && stats.distance > 0
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
: "—";
let kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
// HR-based kcal "what if" (Keytel) — comparison ONLY. The activity balance keeps the physics/MET
// number (don't disturb the established diet); here we just show what HR would give, if present.
let _t0hr = points.length ? new Date(points[0].ts).getTime() : 0;
let hrSamples = points.filter(p => p.heart_rate > 0).map(p => [(new Date(p.ts).getTime() - _t0hr) / 1000, p.heart_rate]);
let hasHr = hrSamples.length > 2;
let hrProfile = {
weightKg: session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 75,
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
sex: state.profile?.sex || "m",
heightCm: parseFloat(state.profile?.height_cm) || 178,
};
let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null;
let hrKcalCalib = hasHr ? Math.round(hrKcal * (GPS.config.hrCalibFactor ?? 0.65)) : null; // грубая справочная прикидка
let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null;
let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null;
showModal(`
<h3>${actName}</h3>
<div id="sd-track-id" style="font-size:11px;color:var(--muted);margin:-6px 0 8px;cursor:pointer;user-select:all">ID: ${session.id}</div>
<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>
<details class="sd-section sd-accordion" open>
<summary class="sd-title sd-acc-title">Скорость</summary>
<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 id="sd-walk-stat" class="prod-hint" style="text-align:center;margin-bottom:6px"></div>
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
</details>
<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">${hasHr ? "Физика (в баланс)" : "Всего"}</span></div>
<div class="sd-stat"><span class="sd-val">${kcalMin} ккал/мин</span><span class="sd-lbl">Расход</span></div>
</div>
${hasHr ? `<div class="sd-row"><div class="sd-stat"><span class="sd-val">${hrKcal} ккал</span><span class="sd-lbl">По ЧСС (Keytel)</span></div>
<div class="sd-stat"><span class="sd-val">${hrKcalCalib} ккал</span><span class="sd-lbl">ЧСС откалибр. (грубо)</span></div></div>
<div class="prod-hint" style="text-align:center;margin-top:4px">В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.</div>` : ""}
</div>
${elevations.length > 0 ? `
<details class="sd-section sd-accordion" open>
<summary class="sd-title sd-acc-title">Высота</summary>
<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>
</details>` : ""}
${hasHr ? `
<details class="sd-section sd-accordion" open>
<summary class="sd-title sd-acc-title">Пульс</summary>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${hrAvg}</span><span class="sd-lbl">Средний</span></div>
<div class="sd-stat"><span class="sd-val">${hrMax}</span><span class="sd-lbl">Макс.</span></div>
</div>
<div class="chart-wrap" id="sd-hr-wrap"><canvas id="sd-hr-chart" height="150" style="touch-action:pan-y"></canvas></div>
</details>` : ""}
${(stats.segments && stats.segments.length > 1) ? `
<details class="sd-section sd-accordion">
<summary class="sd-title sd-acc-title">Сегменты <span class="sd-acc-count">${stats.segments.length}</span></summary>
<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>
</details>` : ""}
${session.activity_type === 'bike' ? `
<details class="sd-section sd-accordion" id="sd-repeats">
<summary class="sd-title sd-acc-title">Разбор повторов</summary>
<div id="sd-repeats-body" class="repeats-body">Считаю...</div>
</details>` : ""}
<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-split" class="btn-secondary full-width" style="margin-top:8px">Разрезать трек</button>
<button id="sd-unmerge" class="btn-secondary full-width" style="margin-top:8px;display:none">Разделить склейку</button>
<button id="sd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт GPX</button>
<button id="sd-share" class="btn-secondary full-width" style="margin-top:8px">Похвастаться</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/";
// preferCanvas: big tracks (10k+ points, multi-layer slope colouring) as SVG blow the
// Chromium compositor's tile-memory limit on zoom/pan → map tiles stop drawing until a
// restart ("tile memory limits exceeded, some content may not draw"). Canvas renders the
// vectors into one viewport-bounded canvas instead of thousands of SVG paths.
let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false, scrollWheelZoom: false, dragging: false, tap: false, touchZoom: true });
mapEl._leafletMap = map; // save ref for winStack collapse
// Remember the view for this track so a reopen (after recompute / refine) keeps the zoom.
map.on("moveend zoomend", () => { _sdMapView[sessionId] = { c: map.getCenter(), z: map.getZoom() }; });
// Ctrl+wheel zoom on desktop
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along this track's route when OSM is reachable.
TileCache.startHealer(map, tileLayer, () => points.map(p => [p.lat, p.lon]));
addDarkMapControl(map); // dark-tiles toggle on the track view
POI.attach(map); // POI overlay + category filter
{ let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles
// Gap-fill: route GPS dropouts along roads (BRouter) and PREVIEW only the filled gaps
// (not the whole track); then save (reversible, in-place) or discard. If the track is
// already gap-filled, the button offers to remove the fill.
let gapLayer = null; // L.layerGroup of the preview patches
let gapPanel = null; // bottom preview panel (Save/Cancel)
let gapCache = null; // { points } from fillGaps, kept for save
let gapBtn = null;
function clearGapPreview() {
if (gapLayer) { map.removeLayer(gapLayer); gapLayer = null; }
if (gapPanel) { gapPanel.remove(); gapPanel = null; }
gapCache = null;
if (gapBtn) gapBtn.classList.remove("active");
}
function drawGapPatches(gaps) {
let group = L.layerGroup(), filledN = 0, skippedN = 0, addedM = 0, skippedLong = 0, skippedNoroute = 0;
for (let g of gaps) {
if (g.status === "filled") {
filledN++; addedM += g.addedM;
let ll = [[g.a.lat, g.a.lon]].concat(g.routed.map(p => [p.lat, p.lon]), [[g.b.lat, g.b.lon]]);
L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(group); // casing
L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(group);
L.polyline(ll, { color: "#00e5ff", weight: 3, opacity: 1 }).addTo(group); // core
} else if (g.status === "skipped-long" || g.status === "skipped-noroute") {
// a real gap we couldn't bridge — mark it red so the user knows it's unfilled.
// "skipped-nogain" (road ≈ straight) is fine as-is → not drawn, not counted.
skippedN++;
if (g.status === "skipped-long") skippedLong++; else skippedNoroute++;
L.polyline([[g.a.lat, g.a.lon], [g.b.lat, g.b.lon]], { color: "#e53935", weight: 3, opacity: 0.9, dashArray: "6,6" }).addTo(group);
}
}
group.addTo(map);
gapLayer = group;
return { filledN, skippedN, addedM, skippedLong, skippedNoroute };
}
function panel(html) {
let el = document.createElement("div");
el.className = "gap-panel";
el.innerHTML = html;
document.body.appendChild(el);
return el;
}
let GapCtl = L.Control.extend({
options: { position: "topright" },
onAdd: function () {
let b = L.DomUtil.create("button", "rec-map-btn");
gapBtn = b;
b.textContent = "🧩";
b.title = session.gap_filled ? "Убрать достройку пропусков" : "Достроить пропуски по дорогам";
b.style.position = "static";
if (session.gap_filled) b.classList.add("active");
L.DomEvent.disableClickPropagation(b);
b.onclick = async () => {
if (gapPanel) { clearGapPreview(); return; } // toggle panel off
// Already filled → offer revert.
if (session.gap_filled) {
b.classList.add("active");
gapPanel = panel(`<div class="gap-panel-txt">Трек достроен по дорогам (голубые участки).<br><span class="gap-panel-hint">Можно убрать и вернуть исходный трек.</span></div>
<div class="gap-panel-btns"><button class="btn-secondary" id="gap-revert" style="color:#cf6679">Убрать достройку</button><button class="btn-secondary" id="gap-close">Закрыть</button></div>`);
gapPanel.querySelector("#gap-close").onclick = () => clearGapPreview();
gapPanel.querySelector("#gap-revert").onclick = async () => {
let rb = gapPanel.querySelector("#gap-revert"); rb.disabled = true; rb.textContent = "Убираю...";
try {
await DB.revertGapFill(sessionId);
await GPS.recalculateSession(sessionId, { noDelete: true });
showToast("Достройка убрана");
clearGapPreview(); reopenDetail(sessionId);
} catch (e) { showToast("Откат: " + (e.message || e)); rb.disabled = false; rb.textContent = "Убрать достройку"; }
};
return;
}
// Compute preview.
b.disabled = true; b.classList.add("active");
try {
// Prefer bridging a dropout over roads YOU actually rode (ride-graph); fall back to
// the public BRouter only where you've never ridden. A ridden bridge is trusted (no
// "detour too long" rejection); public routing keeps that guard.
let res = await GPS.fillGaps(points, {
routeFn: async (a, b) => {
try { let r = await rideRoute(a.lat, a.lon, b.lat, b.lon); if (r && r.length >= 2) return { points: r, ridden: true }; } catch {}
try { let gpx = await BRouter.route([[a.lon, a.lat], [b.lon, b.lat]], "shortest"); return { points: (GPS.parseRouteGPX(gpx) || {}).points || [], ridden: false }; } catch { return { points: [], ridden: false }; }
}
});
if (!res.gaps.length) { showToast("Пропусков для достройки нет"); b.classList.remove("active"); b.disabled = false; return; }
gapCache = res;
let s = drawGapPatches(res.gaps);
if (!s.filledN && !s.skippedN) { showToast("Пропусков для достройки нет"); clearGapPreview(); b.disabled = false; return; }
let km = (s.addedM / 1000).toFixed(2);
// Clear reason for the ones we couldn't bridge: they aren't on roads you've ridden,
// and the public router either detours far (skipped-long) or has no data (noroute).
let why = [];
if (s.skippedLong) why.push(`${s.skippedLong} — публичный маршрут уводит в объезд`);
if (s.skippedNoroute) why.push(`${s.skippedNoroute} — нет дорожных данных`);
let skippedTxt = s.skippedN ? ` Не достроено: <b>${s.skippedN}</b> (нет твоей проеханной дороги${why.length ? "; " + why.join(", ") : ""}).` : "";
gapPanel = panel(`<div class="gap-panel-txt">Достроено пропусков: <b>${s.filledN}</b> (+${km} км).${skippedTxt}<br><span class="gap-panel-hint">Так трек будет выглядеть после сохранения.</span></div>
<div class="gap-panel-btns"><button class="btn-primary" id="gap-save">Сохранить</button><button class="btn-secondary" id="gap-cancel">Отмена</button></div>`);
gapPanel.querySelector("#gap-cancel").onclick = () => clearGapPreview();
gapPanel.querySelector("#gap-save").onclick = async () => {
if (!s.filledN) { showToast("Нечего сохранять"); return; }
let sv = gapPanel.querySelector("#gap-save"); sv.disabled = true; sv.textContent = "Сохранение...";
try {
await DB.saveGapFill(sessionId, gapCache.points);
await GPS.recalculateSession(sessionId, { noDelete: true });
showToast("Пропуски достроены");
clearGapPreview(); reopenDetail(sessionId);
} catch (e) { showToast("Сохранение: " + (e.message || e)); sv.disabled = false; sv.textContent = "Сохранить"; }
};
} catch (e) { showToast("Пропуски: " + (e.message || e)); clearGapPreview(); }
b.disabled = false;
};
return b;
},
});
map.addControl(new GapCtl());
// Active saved-route overlay on the detail map + a toggle button (default ON when
// a route is selected). Shares baldav_route_shown with the recording screen.
let sdRouteLayer = null, sdRouteDiv = null;
async function sdSetRoute(shown) {
localStorage.setItem("baldav_route_shown", shown ? "1" : "0");
if (shown) sdRouteLayer = await drawActiveRouteOverlay(map, sdRouteLayer);
else if (sdRouteLayer) { map.removeLayer(sdRouteLayer); sdRouteLayer = null; }
if (sdRouteDiv) sdRouteDiv.classList.toggle("active", !!sdRouteLayer);
}
if (localStorage.getItem("baldav_active_route")) {
let routeCtl = L.control({ position: "topleft" });
routeCtl.onAdd = () => {
sdRouteDiv = L.DomUtil.create("div", "sd-map-fs-btn");
sdRouteDiv.innerHTML = "М";
sdRouteDiv.title = "Маршрут";
sdRouteDiv.onclick = (e) => { e.stopPropagation(); sdSetRoute(!sdRouteLayer); };
return sdRouteDiv;
};
routeCtl.addTo(map);
// Route colour mode toggle (slope <-> elevation), same as on the route-preview
// screen. Flips the shared preference and redraws the overlay if it's shown.
let sdColorCtl = L.control({ position: "topleft" });
sdColorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
let m0 = localStorage.getItem("baldav_route_color_mode") || "slope";
d.innerHTML = m0 === "slope" ? "∡" : "⛰";
d.title = "Раскраска маршрута: уклон / высота";
d.onclick = async (e) => {
e.stopPropagation();
let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", nm);
d.innerHTML = nm === "slope" ? "∡" : "⛰";
if (sdRouteLayer) sdRouteLayer = await drawActiveRouteOverlay(map, sdRouteLayer);
};
return d;
};
sdColorCtl.addTo(map);
// Initial draw is deferred to after the track is fitted (below), so it awaits
// a ready map and doesn't delay the track rendering.
}
// --- De-jitter (Savitzky-Golay) tuning panel: preview + save/revert ---
let smParams = { W: session.smooth_window || 4, it: session.smooth_iters || 3 };
let smPreview = null, smPanel = null, smBtnDiv = null;
let smSetActive = on => { if (smBtnDiv) smBtnDiv.classList.toggle("active", on); };
let drawPreview = () => {
let sm = GPS.smoothTrackSG(rawPoints, smParams.W, smParams.it);
if (smPreview) map.removeLayer(smPreview);
smPreview = L.polyline(sm.map(p => [p.lat, p.lon]), { color: "#00e5ff", weight: 3, opacity: 0.95 }).addTo(map);
let d = 0; for (let i = 1; i < sm.length; i++) d += GPS.distance(sm[i-1].lat, sm[i-1].lon, sm[i].lat, sm[i].lon);
if (smPanel) smPanel.querySelector("#sm-stat").textContent = "Сглаж.: " + (d / 1000).toFixed(2) + " км";
return sm;
};
async function recomputeStatsFor(pts) {
let latestW = await DB.getLatestWeight(), profile = await DB.getFullProfile();
let weightKg = parseFloat(session.rider_weight_kg) || parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
let CRR = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 };
let st = GPS.computeStats(pts, weightKg, {
bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike",
surfaceCrr: CRR[bike?.surface_type] ?? 0.006, tirePressureBar: bike?.tire_pressure_bar ?? 3.0,
chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch,
gearCount: bike?.gear_count,
});
await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time,
st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss);
}
let toggleSmoothPanel = () => {
if (smPanel) { smPanel.remove(); smPanel = null; if (smPreview) { map.removeLayer(smPreview); smPreview = null; } smSetActive(false); return; }
smSetActive(true);
smPanel = document.createElement("div");
smPanel.className = "sd-smooth-panel";
smPanel.innerHTML = `<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; }
// smoothFactor: Douglas-Peucker tolerance in PIXELS, so it drops points that collapse
// together on screen - full detail zoomed in, far fewer segments when zoomed out (where
// a 10k-point track otherwise janks and floods the compositor). 2.5px is imperceptible.
L.polyline(seg, { color: "#ffffff", weight: 8, opacity: 0.8, smoothFactor: 2.5 }).addTo(map);
L.polyline(seg, { color: GPS.config.trackColor || "#bb86fc", weight: 5, smoothFactor: 2.5 }).addTo(map);
L.circleMarker(seg[0], { radius: 5, color: "#4caf50", fillColor: "#4caf50", fillOpacity: 1 }).addTo(map);
L.circleMarker(seg[seg.length - 1], { radius: 5, color: "#f44336", fillColor: "#f44336", fillOpacity: 1 }).addTo(map);
allCoords.push(...seg);
});
// Draw dashed lines for accel-confirmed gaps (GPS lost signal but person was moving)
for (let gl of gapLines) {
L.polyline(gl, { color: GPS.config.trackColor || "#bb86fc", weight: 3, opacity: 0.5, dashArray: "8, 8" }).addTo(map);
}
// Mark BRouter gap-fill runs (estimated points) in cyan so saved dropout-fills stay
// visible — each run bracketed by the adjacent real points so it connects to the track.
{
let run = [];
let flush = () => { if (run.length >= 2) L.polyline(run, { color: "#00e5ff", weight: 4, opacity: 0.95 }).addTo(map); run = []; };
for (let i = 0; i < points.length; i++) {
if (points[i].estimated) {
if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]);
run.push([points[i].lat, points[i].lon]);
} else if (run.length) {
run.push([points[i].lat, points[i].lon]); flush();
}
}
flush();
}
// Colour "walking" runs inside a ride (pushed bike / on foot across a field) so they stand
// apart from riding; also report the pushed total. Walk/run sessions are all on foot already.
if (session.activity_type !== "walk" && session.activity_type !== "run") {
let walk = GPS.detectWalk(points);
let run = [], flush = () => { if (run.length >= 2) L.polyline(run, { color: GPS.config.roadColorWalk || "#8B5E3C", weight: 6, opacity: 0.95 }).addTo(map); run = []; };
let wSec = 0, wDist = 0;
for (let i = 0; i < points.length; i++) {
if (walk[i]) {
if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]);
run.push([points[i].lat, points[i].lon]);
if (i > 0) { wSec += Math.max(0, (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000); wDist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); }
} else if (run.length) { run.push([points[i].lat, points[i].lon]); flush(); }
}
flush();
let ws = document.querySelector("#sd-walk-stat");
if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км";
}
// Interval / repeat analysis: the same segment ridden several times in one ride
// (intervals). Passes found by self-similarity (GPS.detectRepeats), per-pass stats
// via computeStats, plus the last/first verdict; the corridor is highlighted on the
// map. Length-normalised speed (segLength / pass time) cancels GPS length noise so
// passes compare fairly. Bike only.
if (session.activity_type === "bike") {
(async () => {
let body = document.querySelector("#sd-repeats-body");
if (!body) return;
let rep = GPS.detectRepeats(points);
if (!rep.found) { body.textContent = "Повторов не найдено (обычная поездка)."; return; }
let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
let weightKg = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
let opts = {
activityType: "bike", bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type,
chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth,
wheelDiameterInch: bike?.wheel_diameter_inch, gearCount: bike?.gear_count,
};
let rows = rep.passes.map((p, i) => {
let seg = points.slice(p.startIdx, p.endIdx + 1);
let st = GPS.computeStats(seg, weightKg, opts);
let T = Math.max(1, p.movingSec); // moving time only (stops trimmed)
return { i: i + 1, T, normV: rep.segLength / T * 3.6, cad: st.avgCadence, pw: st.avgPower };
});
let ratio = rows[rows.length - 1].normV / rows[0].normV, pct = Math.round(ratio * 100);
let verdict, vClass;
if (ratio >= 1.0) { verdict = "последний не медленнее первого - можно повторить завтра"; vClass = "ok"; }
else if (ratio >= 0.90) { verdict = "нормальная тяжёлая тренировка"; vClass = "warn"; }
else { verdict = "просел больше 10% - два дня спокойно"; vClass = "bad"; }
let segStr = rep.segLength >= 1000 ? (rep.segLength / 1000).toFixed(2) + " км" : rep.segLength + " м";
body.innerHTML =
`<div class="repeats-head">Участок ${segStr}, заездов ${rep.passCount} (скорость нормирована на длину участка)</div>` +
`<table class="repeats-tbl"><thead><tr><th>№</th><th>время</th><th>км/ч</th><th>каденс</th><th>Вт</th></tr></thead><tbody>` +
rows.map(r => `<tr><td>${r.i}</td><td>${GPS.formatDuration(Math.round(r.T))}</td><td>${r.normV.toFixed(1)}</td><td>${r.cad ?? "-"}</td><td>${r.pw ?? "-"}</td></tr>`).join("") +
`</tbody></table>` +
`<div class="repeats-verdict ${vClass}"><span class="rv-num">${pct}%</span> последний / первый<br>${verdict}</div>`;
// Highlight the repeated corridor on the map (every pass draws the same line).
try {
for (let p of rep.passes) {
let seg = points.slice(p.startIdx, p.endIdx + 1).map(q => [q.lat, q.lon]);
if (seg.length >= 2) L.polyline(seg, { color: "#00e5ff", weight: 5, opacity: 0.85 }).addTo(map);
}
} catch {}
})();
}
// Invisible but tappable hit-targets on top of track
for (let i = 0; i < points.length; i++) {
let p = points[i];
let alt = p.altitude_srtm_m ?? p.altitude_gps_m ?? p.altitude;
let spd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) + " km/h" : "";
let time = p.ts.split("T")[1].slice(0, 8);
let hr = p.heart_rate ? `${p.heart_rate} bpm` : "";
let spo2 = p.spo2 ? `SpO2:${p.spo2}%` : "";
let bp = (p.bp_sys && p.bp_dia) ? `${p.bp_sys}/${p.bp_dia}` : "";
let bleInfo = [hr, spo2, bp].filter(Boolean).join(" ");
let info = `#${i} ${time}${alt != null ? " | " + Math.round(alt) + "m" : ""}${spd ? " | " + spd : ""}${bleInfo ? " | " + bleInfo : ""}`;
// "Split here" button lives in every point popup but is shown only in split mode
// (mapEl.split-mode, toggled by the "Разрезать трек" button) — see CSS.
info += `<button class="split-here" data-idx="${i}">✂ Разрезать здесь</button>`;
L.circleMarker([p.lat, p.lon], {
radius: 8, weight: 0, fillOpacity: 0, interactive: true,
}).bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
// Delegated: tap "Разрезать здесь" in a point popup → split the track at that point.
mapEl.addEventListener("click", (ev) => {
let b = ev.target.closest(".split-here");
if (b) { ev.stopPropagation(); confirmSplitAt(parseInt(b.dataset.idx)); }
});
async function confirmSplitAt(k) {
if (!(k >= 2 && k <= points.length - 3)) { showToast("Выберите точку не у самого края трека"); return; }
try { map.closePopup(); } catch {}
let tOf = (p) => (p.ts.split("T")[1] || "").slice(0, 8);
let distToHere = 0;
for (let i = 1; i <= k; i++) distToHere += GPS.distance(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
if (!(await appConfirm("Разрезать трек после точки #" + k + " (время " + tOf(points[k]) + ", ~" + (distToHere / 1000).toFixed(2) + " км от старта)?\n\nВторой кусок станет отдельной тренировкой с тем же велосипедом. Потом можно сменить велосипед у неё и пересчитать.", "Разрезать"))) return;
let part2Ids = points.slice(k + 1).map(p => p.id).filter(id => id != null);
if (part2Ids.length < 2) { showToast("Во втором куске слишком мало точек"); return; }
try {
let newId = await DB.splitGpsSession(sessionId, part2Ids, tOf(points[k]), tOf(points[k + 1]));
if (newId) {
await GPS.recalculateSession(sessionId, { noDelete: true });
await GPS.recalculateSession(newId, { noDelete: true });
}
showToast("Трек разрезан на две тренировки");
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("gps");
} catch (e) { console.error("[split]", e); showToast("Ошибка разреза: " + (e.message || e)); }
}
// Ensure Leaflet has correct container size, then fit
map.invalidateSize();
let savedView = _sdMapView[sessionId];
if (savedView) {
map.setView(savedView.c, savedView.z); // reopen after recompute/return → keep the zoom
} else if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
} else if (points.length) {
// Degenerate track (a single usable point): fitBounds needs 2+ coords, so
// give the map a view explicitly — otherwise it never fires 'load' and the
// route-overlay `await whenMapReady(map)` below hangs forever, blocking the
// rest of showSessionDetail (including the Delete button binding).
map.setView([points[0].lat, points[0].lon], 15);
}
mapEl._bounds = allCoords.length > 1 ? L.latLngBounds(allCoords).pad(0.1) : null;
// Draw the active route (default ON) now that the map is sized & fitted — after
// the track, awaiting a ready map so the line actually renders.
if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") {
await whenMapReady(map);
await sdSetRoute(true);
}
// Fullscreen toggle button on map
// Fullscreen map toggle
function toggleMapFs() {
if (mapEl.classList.contains("sd-map-full")) {
// Collapse via history.back() — winStack close handler will reset CSS
history.back();
} else {
// Expand — push to history stack
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
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 = "&#x26F6;";
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 = "&#x26F6;";
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 = "&#x25CE;";
div.title = "Моё местоположение";
div.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => {
let ll = [pos.coords.latitude, pos.coords.longitude];
map.setView(ll, 15);
L.circleMarker(ll, { radius: 8, color: "#4285f4", fillColor: "#4285f4", fillOpacity: 0.8, weight: 2 }).addTo(map);
},
err => console.error("GPS error:", err.message),
{ enableHighAccuracy: true, timeout: 10000 }
);
};
return div;
};
locBtn.addTo(map);
// Click map to toggle fullscreen (same as button)
map.on("click", () => {
if (!mapEl.classList.contains("sd-map-full")) toggleMapFs();
});
}
// Downsample arrays for smooth chart rendering (max ~500 points)
function downsample(arr, maxPts) {
if (arr.length <= maxPts) return arr;
let step = arr.length / maxPts;
let result = [];
for (let i = 0; i < maxPts; i++) {
let idx = Math.floor(i * step);
result.push(arr[idx]);
}
return result;
}
let MAX_CHART_PTS = 500;
let dsSmoothed = downsample(smoothed, MAX_CHART_PTS);
let dsTimeLabels = downsample(timeLabels, MAX_CHART_PTS);
let dsElevations = downsample(elevations, MAX_CHART_PTS);
let dsElevLabels = downsample(
points.filter(p => (p.altitude_srtm_m ?? p.altitude_gps_m) != null).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
// Render speed chart
let speedCtx = document.querySelector("#sd-speed-chart");
if (speedCtx && smoothed.length) {
let maxEl = document.querySelector("#sd-max-speed");
if (maxEl) maxEl.textContent = graphMaxSpeed + " км/ч";
new Chart(speedCtx, {
type: "line",
data: {
labels: dsTimeLabels,
datasets: [{
data: dsSmoothed,
borderColor: "#bb86fc",
borderWidth: 1.5,
pointRadius: 0,
fill: false,
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
limits: { x: { minRange: 0.5 } },
onZoomStart: ({chart, event}) => {
// Allow wheel zoom (Ctrl+scroll) always; pinch only in fullscreen
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: true, mode: "x" },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "км/ч", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}
// Render elevation chart
let elevCtx = document.querySelector("#sd-elev-chart");
if (elevCtx && elevations.length) {
new Chart(elevCtx, {
type: "line",
data: {
labels: dsElevLabels,
datasets: [{
data: dsElevations,
borderColor: "#ffb74d",
borderWidth: 1.5,
pointRadius: 0,
fill: true,
backgroundColor: "rgba(255,183,77,0.1)",
tension: 0.3,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
limits: { x: { minRange: 0.5 } },
onZoomStart: ({chart, event}) => {
// Allow wheel zoom (Ctrl+scroll) always; pinch only in fullscreen
if (event && event.type === "wheel") return true;
let wrap = chart.canvas.closest(".chart-wrap");
return wrap && wrap.classList.contains("chart-full");
},
},
pan: { enabled: true, mode: "x" },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "м", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}
// Render HR chart
let hrData = points.map(p => p.heart_rate || null);
let hrCtx = document.querySelector("#sd-hr-chart");
if (hrData.some(v => v) && hrCtx) {
let wrap = document.querySelector("#sd-hr-wrap");
if (wrap) wrap.style.display = "";
let dsHr = downsample(hrData, MAX_CHART_PTS);
let dsHrLabels = downsample(
points.map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1)),
MAX_CHART_PTS
);
new Chart(hrCtx, {
type: "line",
data: {
labels: dsHrLabels,
datasets: [{
data: dsHr,
borderWidth: 2,
pointRadius: 0,
fill: false,
tension: 0.3,
spanGaps: false,
segment: {
borderColor: function(ctx2) {
let v = ctx2.p1.parsed.y;
if (v == null || v === 0) return "rgba(100,181,246,0.3)";
return hrZoneColor(v);
}
},
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
zoom: {
zoom: {
wheel: { enabled: true, modifierKey: "ctrl", speed: 0.04 },
pinch: { enabled: true },
mode: "x",
onZoomStart: ({chart, event}) => {
if (event && event.type === "wheel") return true;
let w = chart.canvas.closest(".chart-wrap");
return w && w.classList.contains("chart-full");
},
},
pan: { enabled: true, mode: "x" },
},
},
scales: {
x: { display: true, title: { display: true, text: "мин", color: "#888" },
ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "bpm", color: "#888" },
ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}
// Buttons + bike selector
{
let exp = document.querySelector("#sd-export");
if (exp) exp.onclick = () => GPS.exportGPX(sessionId);
let shareBtn = document.querySelector("#sd-share");
if (shareBtn) shareBtn.onclick = () => showShareModal(session, stats, points);
// Track ID — click to copy to clipboard
let idEl = document.querySelector("#sd-track-id");
if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId);
// Chart fullscreen in modal — push to winStack, back collapses
document.querySelectorAll(".chart-wrap").forEach(wrap => {
wrap.onclick = () => {
if (wrap.classList.contains("chart-full")) return;
wrap.classList.add("chart-full");
let closeBtn = document.createElement("div");
closeBtn.className = "chart-close-btn";
closeBtn.innerHTML = "&#x2715;";
closeBtn.onclick = (e) => { e.stopPropagation(); history.back(); };
wrap.appendChild(closeBtn);
let parentOverlay = wrap.closest(".modal-overlay");
winPush("fsChart", () => {
wrap.classList.remove("chart-full");
closeBtn.remove();
});
winStack[winStack.length - 1].overlay = parentOverlay;
};
});
// Bike name display
if (session.bike_id) {
DB.getBike(session.bike_id).then(b => {
let el = document.querySelector("#sd-bike-name");
if (el && b) el.textContent = b.name;
else if (el) el.textContent = "—";
});
}
// Bike selector (for bike sessions)
let bikeSelect = document.querySelector("#sd-bike-select");
if (bikeSelect) {
DB.getBikes().then(bikes => {
for (let b of bikes) {
let opt = document.createElement("option");
opt.value = b.id;
opt.textContent = b.name + (b.is_active ? " [активный]" : "");
if (session.bike_id && session.bike_id === b.id) opt.selected = true;
bikeSelect.appendChild(opt);
}
});
bikeSelect.onchange = async () => {
let val = bikeSelect.value ? parseInt(bikeSelect.value) : null;
await DB.updateSessionBike(sessionId, val);
session.bike_id = val;
};
}
}
// Session action buttons — bound OUTSIDE the map/points guard so a 0-point
// (0 km) track can still be deleted / recalculated / exported. Previously these
// sat inside `if (mapEl && points.length)`, so an empty track's Delete did nothing.
// Compare button
let compareBtn = document.querySelector("#sd-compare");
if (compareBtn) compareBtn.onclick = () => showCompareModal(sessionId, session, points);
// Split-track button: toggles split mode on the map (shows a "Разрезать здесь" action
// inside each point popup). Hidden for tracks too short to split.
let splitBtn = document.querySelector("#sd-split");
if (splitBtn) {
if (points.length < 5) splitBtn.style.display = "none";
else splitBtn.onclick = () => {
let m = document.querySelector("#sd-map");
if (!m) return;
let on = m.classList.toggle("split-mode");
splitBtn.textContent = on ? "Готово (выберите точку на карте)" : "Разрезать трек";
if (on) { showToast("Тапните точку на карте, затем «Разрезать здесь»"); m.scrollIntoView({ behavior: "smooth", block: "center" }); }
};
}
// Un-merge button: only for a merged session (points carry >1 orig_session_id). Splits it
// back into the original rides, recomputing each; shown only when it's actually a merge.
let unmergeBtn = document.querySelector("#sd-unmerge");
if (unmergeBtn) (async () => {
let n = 0; try { n = await DB.countMergedOrigins(sessionId); } catch {}
if (n < 2) return;
unmergeBtn.style.display = "";
unmergeBtn.textContent = `Разделить склейку (${n})`;
unmergeBtn.onclick = async () => {
if (!(await appConfirm(`Разделить трек обратно на ${n} исходные поездки? Статистика каждой пересчитается (точную по физике можно потом «Пересчитать» в каждой).`, "Разделить"))) return;
unmergeBtn.disabled = true; unmergeBtn.textContent = "Разделяю...";
try {
let ids = await DB.unmergeGpsSession(sessionId);
if (!ids) { showToast("Это не склейка"); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; return; }
let w = session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
for (let id of ids) {
let pts = await DB.getGpsPoints(id);
if (!pts.length) continue;
let st = GPS.computeStats(pts, w, { activityType: session.activity_type });
let endT = new Date(pts[pts.length - 1].ts).toTimeString().slice(0, 5);
try { await DB.finishGpsSession(id, endT, st.distance, st.kcal, st.avgSpeed, st.maxSpeed, st.elevGain, st.elevLoss, []); } catch (e) { console.error("[unmerge] recompute", e); }
}
showToast(`Разделено на ${ids.length} трека`);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1; history.back(); renderScreen("gps");
} catch (e) { showToast("Ошибка: " + (e.message || e)); unmergeBtn.disabled = false; unmergeBtn.textContent = `Разделить склейку (${n})`; }
};
})();
// Recalculate button
let recalcBtn = document.querySelector("#sd-recalc");
if (recalcBtn) recalcBtn.onclick = async () => {
recalcBtn.textContent = "Пересчёт...";
recalcBtn.disabled = true;
setBtnProgress(recalcBtn, 0);
let newStats = await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(recalcBtn, p) });
setBtnProgress(recalcBtn, null);
recalcBtn.textContent = "Пересчитать";
recalcBtn.disabled = false;
// reopenDetail awaits the popstate before re-rendering (no blank map/charts race).
if (newStats) reopenDetail(sessionId);
};
let refineBtn = document.querySelector("#sd-refine");
if (refineBtn) refineBtn.onclick = async () => {
let reset = (t) => { setBtnProgress(refineBtn, null); refineBtn.textContent = t; refineBtn.disabled = false; };
refineBtn.disabled = true;
refineBtn.textContent = "Высоты...";
setBtnProgress(refineBtn, 0);
try {
let pts = await DB.getGpsPoints(sessionId);
if (pts.length < 2) return reset("Уточнить высоты (онлайн)");
// Sample to ~300 points to limit API load; terrain is smooth → interpolate the rest.
let step = Math.max(1, Math.ceil(pts.length / 300));
let sampleIdx = [];
for (let i = 0; i < pts.length; i += step) sampleIdx.push(i);
if (sampleIdx[sampleIdx.length - 1] !== pts.length - 1) sampleIdx.push(pts.length - 1);
// The network fetch is the slow part → drive the bar 0-80% by fetched batches.
let terr = await fetchElevations(sampleIdx.map(i => pts[i]), (done, total) => setBtnProgress(refineBtn, total ? done / total * 80 : 0));
if (!terr || !terr.some(x => x != null)) {
if (typeof showToast === "function") showToast("Сервис высот недоступен");
return reset("Уточнить высоты (онлайн)");
}
// Interpolate terrain to every point (linear by index between samples).
let alt = new Array(pts.length).fill(null);
for (let s = 0; s < sampleIdx.length - 1; s++) {
let i0 = sampleIdx[s], i1 = sampleIdx[s + 1], a0 = terr[s], a1 = terr[s + 1];
if (a0 == null || a1 == null) continue;
for (let i = i0; i <= i1; i++) alt[i] = a0 + (a1 - a0) * (i1 > i0 ? (i - i0) / (i1 - i0) : 0);
}
let rows = pts.map((p, i) => ({ id: p.id, srtm: alt[i] })).filter(r => r.srtm != null);
refineBtn.textContent = "Сохранение...";
setBtnProgress(refineBtn, 85);
await DB.updatePointsSrtm(rows);
await GPS.recalculateSession(sessionId, { onProgress: p => setBtnProgress(refineBtn, 85 + p * 0.15) });
setBtnProgress(refineBtn, null);
if (typeof showToast === "function") showToast("Высоты уточнены по " + rows.length + " точкам");
reopenDetail(sessionId); // awaits popstate → no blank map/charts race
} catch (e) {
console.log("[refine] fail:", e.message);
if (typeof showToast === "function") showToast("Ошибка высот: " + e.message);
reset("Уточнить высоты (онлайн)");
}
};
let del = document.querySelector("#sd-delete");
if (del) del.onclick = async () => {
await DB.deleteGpsSession(sessionId);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("gps");
};
// Goal markers — closest approach for each marker
try {
let goalSection = document.querySelector("#sd-goals-section");
let goalsList = document.querySelector("#sd-goals-list");
let markers = (goalSection && goalsList && points.length >= 2) ? await DB.getGoalMarkers() : [];
if (markers.length > 0) {
// For each marker, find closest point in this track
let results = [];
for (let marker of markers) {
let minDist = Infinity;
let closestIdx = 0;
for (let i = 0; i < points.length; i++) {
let d = GPS.distance(points[i].lat, points[i].lon, marker.lat, marker.lon);
if (d < minDist) { minDist = d; closestIdx = i; }
}
// Check previous sessions for progress comparison
let prevSessions = await DB.getGpsSessions(50);
let bestPrevDist = Infinity;
let bestPrevDate = null;
for (let ps of prevSessions) {
let psId = ps.id_str ?? String(ps.id);
if (psId === String(sessionId)) continue;
let psPts = await DB.getGpsPoints(psId);
if (psPts.length < 2) continue;
for (let pp of psPts) {
let d = GPS.distance(pp.lat, pp.lon, marker.lat, marker.lon);
if (d < bestPrevDist) {
bestPrevDist = d;
bestPrevDate = ps.date;
}
}
}
results.push({
name: marker.name,
minDist: Math.round(minDist),
closestIdx,
bestPrevDist: bestPrevDist < Infinity ? Math.round(bestPrevDist) : null,
bestPrevDate,
});
}
goalSection.style.display = "";
goalsList.innerHTML = results.map(r => {
let distStr = r.minDist >= 1000 ? (r.minDist / 1000).toFixed(1) + " км" : r.minDist + " м";
let progressHtml = "";
if (r.bestPrevDist != null) {
let diff = r.bestPrevDist - r.minDist;
if (diff > 0) {
progressHtml = `<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: "Бег", workout: "Зарядка", vr: "Руки/VR", 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, { preferCanvas: true, attributionControl: false, zoomControl: false, touchZoom: true, dragging: true }); // canvas: two big tracks vs SVG compositor tile-memory limit
mapEl._leafletMap = map;
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
// Build speed lookup for older track
let olderCoords = olderPoints.map(p => [p.lat, p.lon]);
// Pre-compute older track speeds
let olderSpeeds = olderPoints.map(p => (p.speed_ms ?? 0) * 3.6);
// Draw newer track colored by speed difference vs older
// Green = newer faster (progress), Red = newer slower (regression)
for (let i = 1; i < newerPoints.length; i++) {
let cp = newerPoints[i];
let cpPrev = newerPoints[i - 1];
let newSpd = (cp.speed_ms ?? 0) * 3.6;
// Find nearest older point
let nearestIdx = 0, nearestDist = Infinity;
for (let j = 0; j < olderPoints.length; j++) {
let d = GPS.distance(cp.lat, cp.lon, olderPoints[j].lat, olderPoints[j].lon);
if (d < nearestDist) { nearestDist = d; nearestIdx = j; }
}
let oldSpd = olderSpeeds[nearestIdx];
let segColor;
if (nearestDist > 100) segColor = "#bb86fc"; // too far to compare
else if (newSpd > oldSpd + 1) segColor = "#4caf50"; // progress
else if (newSpd < oldSpd - 1) segColor = "#f44336"; // regression
else segColor = "#bb86fc"; // similar
L.polyline(
[[cpPrev.lat, cpPrev.lon], [cp.lat, cp.lon]],
{ color: segColor, weight: 5, opacity: 0.9 }
).addTo(map);
}
// Fit to both tracks
let allCoords = newerPoints.map(p => [p.lat, p.lon]).concat(olderCoords);
if (allCoords.length > 1) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.1));
}
// Fullscreen toggle
let fsState = false;
let fsBtn = L.control({ position: "topright" });
let fsDiv;
fsBtn.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "&#x26F6;";
fsDiv.onclick = (e) => {
e.stopPropagation();
if (fsState) {
fsState = false;
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "&#x26F6;";
setTimeout(() => { map.invalidateSize(); map.fitBounds(L.latLngBounds(allCoords).pad(0.05)); }, 100);
} else {
fsState = true;
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "&#x2715;";
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">&#x203A;</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">Фото-подложка под форму трека и статы</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn-secondary" id="s-share-bg" style="padding:6px 12px;font-size:13px;">Выбрать</button>
<button class="btn-secondary" id="s-share-bg-clear" style="padding:6px 12px;font-size:13px;color:#cf6679">Сброс</button>
</div>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Резервная копия всех данных</div>
<div class="settings-item-desc">Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn-secondary" id="s-export-db" style="padding:6px 12px;font-size:13px;">Экспорт</button>
<button class="btn-secondary" id="s-import-db" style="padding:6px 12px;font-size:13px;">Импорт</button>
</div>
</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">Толщина дорог по проездам</div><div class="settings-item-desc">Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)</div></div>
<input id="s-roads-thick" type="checkbox" ${localStorage.getItem("baldav_roads_thick") !== "0" ? "checked" : ""}>
</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>` : ""}
${state.profile.ble_device_addr ? `<button class="btn-primary" id="s-ble-connect" style="padding:6px 12px;font-size:13px">Подключить</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>
<label class="settings-item" style="cursor:pointer">
<div class="settings-item-left"><div class="settings-item-title">Не спрашивать про Bluetooth</div>
<div class="settings-item-desc">Не предлагать включить BT при старте записи</div></div>
<input id="s-dontask-bt" type="checkbox" ${localStorage.getItem("baldav_dontask_bt") === "1" ? "checked" : ""} style="width:20px;height:20px;">
</label>
<label class="settings-item" style="cursor:pointer">
<div class="settings-item-left"><div class="settings-item-title">Не спрашивать про геолокацию</div>
<div class="settings-item-desc">Не предлагать включить GPS при старте записи трека</div></div>
<input id="s-dontask-gps" type="checkbox" ${localStorage.getItem("baldav_dontask_gps") === "1" ? "checked" : ""} style="width:20px;height:20px;">
</label>
<div class="settings-item" id="s-strap-usage">
<div class="settings-item-left">
<div class="settings-item-title">Ремень ЧСС: наработка</div>
<div class="settings-item-desc" id="s-strap-usage-desc">—</div>
</div>
<button class="btn-secondary" id="s-strap-reset" 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" 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));
}
};
// Share-image background: pick a photo, downscale, store as a data URL in localStorage.
let sbgBtn = container.querySelector("#s-share-bg");
if (sbgBtn) sbgBtn.onclick = async () => {
let prev = sbgBtn.textContent, path;
if (/Android/i.test(navigator.userAgent)) {
sbgBtn.disabled = true; sbgBtn.textContent = "Выбор...";
try { path = await pickLocalFile("image/*"); } catch (e) { showToast("Ошибка выбора: " + (e.message || e)); }
sbgBtn.disabled = false; sbgBtn.textContent = prev;
} else {
path = await appPrompt("Путь к фото-подложке:", "");
}
if (!path) return;
sbgBtn.disabled = true; sbgBtn.textContent = "Загрузка...";
try {
let b64 = await window.__TAURI__.core.invoke("read_file_base64", { path });
let mime = /\.png$/i.test(path) ? "image/png" : "image/jpeg";
await new Promise((res, rej) => {
let img = new Image();
img.onload = () => {
let s = Math.min(1, 1080 / img.width);
let c = document.createElement("canvas"); c.width = Math.round(img.width * s); c.height = Math.round(img.height * s);
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
try { localStorage.setItem("baldav_share_bg", c.toDataURL("image/jpeg", 0.85)); res(); } catch (e) { rej(e); }
};
img.onerror = () => rej(new Error("не удалось прочитать изображение"));
img.src = "data:" + mime + ";base64," + b64;
});
showToast("Подложка сохранена");
} catch (e) { showToast("Подложка: " + (e.message || e)); }
sbgBtn.disabled = false; sbgBtn.textContent = prev;
};
let sbgClear = container.querySelector("#s-share-bg-clear");
if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); };
// Full-DB backup: export the whole database to a file (SAF save), and restore from one.
let expDbBtn = container.querySelector("#s-export-db");
if (expDbBtn) expDbBtn.onclick = async () => {
let prev = expDbBtn.textContent;
expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю...";
try {
let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db";
let res = await window.__TAURI__.core.invoke("export_db", { filename });
let size = 0; try { size = JSON.parse(res).size || 0; } catch {}
showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить");
} catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); }
expDbBtn.disabled = false; expDbBtn.textContent = prev;
};
let impDbBtn = container.querySelector("#s-import-db");
if (impDbBtn) impDbBtn.onclick = async () => {
if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; }
if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return;
let prev = impDbBtn.textContent;
impDbBtn.disabled = true; impDbBtn.textContent = "Выбор...";
try {
let path = await pickLocalFile("*/*");
if (!path) { showToast("Файл не выбран"); return; }
impDbBtn.textContent = "Проверка...";
await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it
// No auto-restart: on a single-activity app the process-restart is unreliable (Android 12+
// blocks background activity starts). The staged DB is swapped on the next launch, so ask
// the user to fully reopen the app — reliable, and they confirmed it works.
await appAlert("База проверена и готова. Чтобы применить восстановление:\n\n1. Полностью закройте приложение (смахните его из списка недавних).\n2. Откройте снова.\n\nПосле этого данные будут заменены восстановленными.");
} catch (e) { appAlert("Импорт не удался: " + (e.message || e)); }
impDbBtn.disabled = false; impDbBtn.textContent = prev;
};
// Download tiles from router — the list comes from the router's directory
// listing, so a newly uploaded .db shows up without rebuilding the app.
container.querySelector("#s-download-tiles").onclick = async () => {
let btn = container.querySelector("#s-download-tiles");
let origText = btn.textContent;
btn.disabled = true;
btn.textContent = "Список...";
let files = [];
try {
files = await fetchRouterTiles();
} catch (e) {
btn.disabled = false; btn.textContent = origText;
showToast("Роутер недоступен: " + (e.message || e));
return;
}
btn.disabled = false; btn.textContent = origText;
// Show every known region. Ones present on the router are tappable; the rest
// are greyed out so the list never silently loses entries. Any extra .db on
// the router that isn't a known region is listed too.
let byKey = {};
for (let f of files) byKey[f.name] = f;
let rows = Object.keys(KNOWN_REGIONS).map(k => ({ key: k, label: KNOWN_REGIONS[k], f: byKey[k] }));
for (let f of files) if (!KNOWN_REGIONS[f.name]) rows.push({ key: f.name, label: f.name, f });
let items = rows.map((r, i) => r.f
? `<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 roadsThickEl = container.querySelector("#s-roads-thick");
if (roadsThickEl) localStorage.setItem("baldav_roads_thick", roadsThickEl.checked ? "1" : "0");
let trackColorEl = container.querySelector("#s-track-color");
if (trackColorEl) GPS.config.trackColor = trackColorEl.value;
let fusedEl = container.querySelector("#s-use-fused");
if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked;
let gpxzEl = container.querySelector("#s-gpxz-key");
if (gpxzEl) GPS.config.gpxzKey = gpxzEl.value.trim();
GPS.saveConfig();
};
let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed", "s-track-color", "s-road-color-bike", "s-road-color-walk", "s-road-color-run", "s-gpxz-key"];
for (let id of gpsInputs) {
let el = container.querySelector("#" + id);
if (el) { el.onchange = readGpsConfig; el.oninput = readGpsConfig; }
}
let fusedEl = container.querySelector("#s-use-fused");
if (fusedEl) fusedEl.onchange = () => { readGpsConfig(); showToast("Настройки GPS сохранены"); };
let gpsSaveBtn = container.querySelector("#s-gps-save");
if (gpsSaveBtn) gpsSaveBtn.onclick = () => { readGpsConfig(); showToast("Настройки GPS сохранены"); };
// HR/BP norms — save on change
for (let [id, key] of [["s-hr-rest-min","hr_rest_min"],["s-hr-rest-max","hr_rest_max"],["s-hr-max","hr_max"],["s-bp-sys","bp_sys_norm"],["s-bp-dia","bp_dia_norm"]]) {
let el = container.querySelector("#" + id);
if (el) el.onchange = async () => {
await DB.setProfileValue(key, el.value);
state.profile[key] = el.value;
};
}
// Bikes list
async function loadBikesList() {
let bikes = await DB.getBikes();
let listEl = container.querySelector("#s-bikes-list");
if (!listEl) return;
listEl.innerHTML = bikes.map(b => `
<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">&#x203A;</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();
};
}
// Manual connect to the already-paired band. Works any time, including during a ride.
// Nothing auto-connects anymore (battery), so this is how HR starts flowing into the track.
let bleConnectBtn = container.querySelector("#s-ble-connect");
if (bleConnectBtn) bleConnectBtn.onclick = async () => {
let addr = state.profile.ble_device_addr, type = state.profile.ble_device_type || "hr_standard";
if (!addr) return;
let statusEl = container.querySelector("#s-ble-status");
let bt = "on";
try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {}
if (bt === "off") {
try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {}
showToast("Включите Bluetooth, затем нажмите «Подключить»");
return;
}
if (bt === "no_adapter") { showToast("Bluetooth недоступен"); return; }
bleConnectBtn.disabled = true; bleConnectBtn.textContent = "...";
if (statusEl) statusEl.textContent = "Подключение...";
try {
await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: type });
// If a ride is recording, make sure the HR poll runs so HR starts landing in the track.
if (GPS.recording.active && !GPS.recording.hrPollInterval) { try { await GPS._startHrMonitor(); } catch {} }
// Poll briefly (Kotlin scans+connects in the background) for a connection or first reading.
let ok = false;
for (let i = 0; i < 6; i++) {
await new Promise(r => setTimeout(r, 1000));
try {
if (await window.__TAURI__.core.invoke("ble_is_connected")) { ok = true; break; }
let rd = await window.__TAURI__.core.invoke("ble_get_hr");
if (rd && rd.hr > 0) { ok = true; break; }
} catch {}
}
if (statusEl) statusEl.textContent = ok ? addr + " (подключён)" : "браслет не найден";
showToast(ok ? "Пульсометр подключён" : "Браслет не найден - включите его рядом");
} catch (e) {
if (statusEl) statusEl.textContent = "ошибка подключения";
showToast("Ошибка подключения: " + (e.message || e));
}
bleConnectBtn.disabled = false; bleConnectBtn.textContent = "Подключить";
};
let dontAskBt = container.querySelector("#s-dontask-bt");
if (dontAskBt) dontAskBt.onchange = () => { try { localStorage.setItem("baldav_dontask_bt", dontAskBt.checked ? "1" : "0"); } catch {} };
let dontAskGps = container.querySelector("#s-dontask-gps");
if (dontAskGps) dontAskGps.onchange = () => { try { localStorage.setItem("baldav_dontask_gps", dontAskGps.checked ? "1" : "0"); } catch {} };
// Strap usage readout: total HR-delivering time since last "charged" reset + battery steps,
// so we can judge real battery life against the claimed 300h (the % reading is coarse/laggy).
let strapDesc = container.querySelector("#s-strap-usage-desc");
let fillStrapUsage = async () => {
if (!strapDesc) return;
try {
let raw = await window.__TAURI__.core.invoke("ble_read_strap_uptime");
let u = JSON.parse(raw || "{}");
let sec = Math.round(u.active_sec || 0);
let h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60);
let bat = (u.bat != null) ? `${u.bat}%` : "?";
let steps = Array.isArray(u.steps) ? u.steps : [];
let since = u.since ? new Date(u.since * 1000).toLocaleDateString("ru-RU") : "—";
let stepStr = steps.length
? " · шаги: " + steps.map(s => `${s.bat}%`).join("->")
: " · шагов заряда нет (ещё 100%?)";
strapDesc.textContent = `заряд ${bat} · в работе ${h}ч ${m}м с ${since}${stepStr}`;
} catch (e) { strapDesc.textContent = "нет данных"; }
};
fillStrapUsage();
let strapReset = container.querySelector("#s-strap-reset");
if (strapReset) strapReset.onclick = async () => {
if (!(await appConfirm("Сбросить счётчик наработки ремня (отметить как заряженный)?", "Сбросить"))) return;
try { await window.__TAURI__.core.invoke("ble_reset_strap_uptime"); } catch {}
fillStrapUsage();
};
let bleScanBtn = container.querySelector("#s-ble-scan");
let bleResults = container.querySelector("#s-ble-results");
if (bleScanBtn) {
bleScanBtn.onclick = async () => {
bleScanBtn.textContent = "Поиск...";
bleScanBtn.disabled = true;
bleResults.innerHTML = `<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">&#x203A;</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 style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-tef" class="toggle" type="checkbox" ${(GPS.config.tefFrac ?? 0) > 0 ? "checked" : ""}>
Учитывать переваривание (TEF 10%)
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.</div>
</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>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-hydr-on" class="toggle" type="checkbox" ${localStorage.getItem("baldav_hydr_on") !== "0" ? "checked" : ""}>
Следить за питьём на тренировке
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.</div>
<label style="font-size:13px;display:block;margin-top:4px">Сигнал при отставании, мл <input id="m-hydr-alert" type="number" inputmode="numeric" min="100" max="3000" value="${localStorage.getItem("baldav_hydr_alert") || "600"}" style="width:80px"></label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="m-live-on" class="toggle" type="checkbox" ${localStorage.getItem("baldav_live_on") === "1" ? "checked" : ""}>
Делиться местоположением онлайн
</label>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.</div>
<label style="font-size:13px;display:block;margin-top:4px">Адрес приёма<input id="m-live-url" type="url" value="${((localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push").replace("/api/live/push", "/api/trackme/push")).replace(/"/g, "&quot;")}" placeholder="https://4aiman.top/api/trackme/push" style="width:100%;margin-top:2px"></label>
<label style="font-size:13px;display:block;margin-top:4px">Токен<input id="m-live-token" type="password" value="${(localStorage.getItem("baldav_live_token") || "").replace(/"/g, "&quot;")}" placeholder="вставь токен" style="width:100%;margin-top:2px"></label>
</div>
<div class="form-group">
<label>Контакт для СМС «Я тут»</label>
<input id="m-sms-number" type="tel" inputmode="tel" value="${(localStorage.getItem("baldav_sms_number") || "").replace(/"/g, "&quot;")}" placeholder="+79990001122" style="width:100%" />
<button type="button" id="m-sms-pick" class="btn-secondary" style="margin-top:6px">Выбрать из контактов</button>
<div style="font-size:11px;color:#9aa4ae;margin-top:2px">Во время записи появится кнопка «СМС»: по нажатию спросит подтверждение и отправит на этот номер текст с координатами и ссылкой на Яндекс.Карты. Номер можно вписать вручную или выбрать из контактов. Работает без интернета (по сотовой сети).</div>
</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 tefEl = overlay.querySelector("#m-tef");
if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); }
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
await DB.setProfileValue("daily_water_ml", waterNorm);
let hydrOn = !!overlay.querySelector("#m-hydr-on")?.checked;
let hydrAlert = Math.min(3000, Math.max(100, parseInt(overlay.querySelector("#m-hydr-alert")?.value) || 600));
localStorage.setItem("baldav_hydr_on", hydrOn ? "1" : "0");
localStorage.setItem("baldav_hydr_alert", String(hydrAlert));
try { await window.__TAURI__.core.invoke("set_hydration_config", { enabled: hydrOn, alertMl: hydrAlert, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 }); } catch (e) { console.error("hydration cfg", e); }
let liveOn = !!overlay.querySelector("#m-live-on")?.checked;
let liveUrl = (overlay.querySelector("#m-live-url")?.value || "").trim();
let liveToken = (overlay.querySelector("#m-live-token")?.value || "").trim();
localStorage.setItem("baldav_live_on", liveOn ? "1" : "0");
localStorage.setItem("baldav_live_url", liveUrl);
localStorage.setItem("baldav_live_token", liveToken);
try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url: liveUrl, token: liveToken }); } catch (e) { console.error("live cfg", e); }
let smsNumber = (overlay.querySelector("#m-sms-number")?.value || "").trim();
localStorage.setItem("baldav_sms_number", smsNumber);
state.profile = { ...state.profile,
weight_kg: weight ? weight : state.profile.weight_kg,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
activity_level: activityLevel };
});
// Initialize modal controls
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Sex chips
overlay.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
updateBmrDisplay();
};
});
// Activity level dropdown
let actSelect = overlay.querySelector("#m-activity-level");
if (actSelect) actSelect.value = p.activity_level || "1.2";
// BMR display
function updateBmrDisplay() {
let w = parseFloat(overlay.querySelector("#m-weight").value) || parseFloat(p.weight_kg) || 80;
let h = parseInt(overlay.querySelector("#m-height").value) || parseInt(p.height_cm) || 178;
let y = parseInt(overlay.querySelector("#m-year").value) || parseInt(p.birth_year) || 1988;
let bmrVal = BMR.calculateBmr(w, h, BMR.getAge(y), selectedSex);
let el = overlay.querySelector("#m-bmr-display");
if (el) el.textContent = bmrVal + " ккал/день";
}
updateBmrDisplay();
// Recalculate BMR when weight/height/year change
for (let id of ["#m-weight", "#m-height", "#m-year"]) {
let el = overlay.querySelector(id);
if (el) el.oninput = updateBmrDisplay;
}
// Pick a phone number from the system contacts (fills the SMS number field)
let pickBtn = overlay.querySelector("#m-sms-pick");
if (pickBtn) pickBtn.onclick = async () => {
let field = overlay.querySelector("#m-sms-number");
pickBtn.disabled = true;
try {
await window.__TAURI__.core.invoke("pick_contact");
// The picker is an Activity; poll for its result for a few seconds.
let got = null;
for (let i = 0; i < 40 && !got; i++) {
await new Promise(r => setTimeout(r, 300));
let raw = await window.__TAURI__.core.invoke("get_contact_result");
if (raw) got = JSON.parse(raw);
}
if (got && got.number) {
field.value = got.number;
}
} catch (e) {
appAlert("Не удалось выбрать контакт: " + e);
} finally {
pickBtn.disabled = false;
}
};
}, 100);
}
// ---------------------------------------------------------------------------
// Bike profile modal
// ---------------------------------------------------------------------------
async function showBikeModal(bikeId) {
let isNew = bikeId == null;
let bike = isNew ? {} : await DB.getBike(bikeId);
if (!isNew && !bike) return;
let surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" };
let surfaceVal = bike.surface_type ?? "mixed";
showModal(`
<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}">&times;</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" });
}
// BRouter road-segment (.rd5) download manager. Tiles for offline routing are pulled
// from the mirror (LAN -> WAN -> brouter.de) by BRouter.downloadTileAndWait.
async function showBrouterSegments() {
let region = [
{ name: "E35_N45", label: "E35_N45 — запад (35–40°E, 45–50°N)" },
{ name: "E40_N45", label: "E40_N45 — восток (40–45°E)" },
];
let haveSet = new Set();
try { (await BRouter.listTiles()).forEach(t => haveSet.add(t.name)); } catch (e) {}
let rows = region.map(t => {
let dl = haveSet.has(t.name);
return `<div class="bseg-row" data-tile="${t.name}" style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)">
<span style="flex:1;font-size:13px">${t.label}</span>
<span class="bseg-status" style="font-size:12px;color:var(--muted);min-width:70px;text-align:right">${dl ? "✓ есть" : ""}</span>
<button class="btn-secondary bseg-dl" data-tile="${t.name}" style="padding:6px 12px;font-size:12px">${dl ? "Обновить" : "Скачать"}</button>
</div>`;
}).join("");
showModal(`<h3>Сегменты дорог (BRouter)</h3>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Дорожные тайлы для оффлайн-роутинга. Качаются с зеркала (LAN→WAN→brouter.de), ~14–24 МБ каждый. Нужны один раз.</div>
${rows}`);
document.querySelectorAll(".bseg-dl").forEach(btn => {
btn.onclick = async () => {
let tile = btn.dataset.tile, row = btn.closest(".bseg-row"), st = row.querySelector(".bseg-status");
btn.disabled = true;
try {
await BRouter.downloadTileAndWait(tile, (b, tot) => {
st.textContent = tot ? Math.round(100 * b / tot) + "%" : (Math.round(b / 1024 / 1024) + " МБ");
});
st.textContent = "✓ есть"; btn.textContent = "Обновить";
} catch (e) { st.textContent = "ошибка"; showToast("Сегмент: " + (e.message || e)); }
btn.disabled = false;
};
});
}
async function showRoadsMap() {
let container = document.querySelector(".screen-content");
container.innerHTML = `
<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-segments" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Сегменты</button>
<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");
document.querySelector("#roads-segments").onclick = () => showBrouterSegments();
let mapEl = document.querySelector("#roads-map");
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { preferCanvas: true, attributionControl: false }).setView([47.42, 40.08], 13); // canvas: many road polylines vs SVG compositor tile-memory limit
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
addDarkMapControl(map); // dark-tiles toggle on the roads map
POI.attach(map); // POI overlay + category filter
let infoEl = document.querySelector("#roads-info");
infoEl.textContent = "Загрузка...";
// Rebuild button — always visible
let rebuildBtn = document.querySelector("#roads-rebuild");
async function doRebuild() {
rebuildBtn.disabled = true;
rebuildBtn.textContent = "Построение...";
setBtnProgress(rebuildBtn, 0);
infoEl.textContent = "Построение (может занять минуту)...";
// Rust emits roads_progress [current,total] as it scans points → fill the button by %.
let unlisten = null;
try {
try {
unlisten = await window.__TAURI__.event.listen("roads_progress", (ev) => {
let cur = ev.payload?.[0] ?? 0, tot = ev.payload?.[1] ?? 0;
if (tot > 0) { setBtnProgress(rebuildBtn, cur / tot * 100); infoEl.textContent = `Построение: ${cur} / ${tot}`; }
});
} catch {}
let count = await DB.rebuildRoads();
if (unlisten) unlisten();
setBtnProgress(rebuildBtn, null);
infoEl.textContent = `${count} точек`;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
showRoadsMap();
} catch (e) {
if (unlisten) unlisten();
setBtnProgress(rebuildBtn, null);
infoEl.textContent = "Ошибка: " + e;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
}
}
rebuildBtn.onclick = doRebuild;
let pts;
try {
pts = await DB.getRoadPoints();
} catch (e) {
// Table missing or schema mismatch — need rebuild
infoEl.textContent = "Нужно построить дороги";
return;
}
if (pts.length === 0) {
infoEl.textContent = "Нет дорог — нажмите Перестроить";
return;
}
// Split into segments (with bbox) for viewport culling; thickness follows pass count.
let segments = buildRoadSegments(pts);
// Roads in their own canvas pane so the dark-map CSS filter (body.dark-map .leaflet-roadsPane)
// can invert/shift road colours like the tiles. Only segments in view are drawn, redrawn on move.
if (!map.getPane("roadsPane")) { map.createPane("roadsPane"); map.getPane("roadsPane").style.zIndex = 240; }
let roadsRenderer = L.canvas({ pane: "roadsPane", padding: 0.2 });
let drawn = [];
let drawExtra = { renderer: roadsRenderer, base: 4, opacity: 1, smoothFactor: 2.5 };
let redraw = () => cullRoadsInto(map, segments, drawn, 0.2, drawExtra);
map.on("moveend zoomend", debounce(redraw, 120));
if (pts.length > 0) map.fitBounds(L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.05), { animate: false });
redraw();
let walkSegs = segments.filter(s => s.atype === "w").length;
let bikeSegs = segments.length - walkSegs;
infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`;
}
function addZoomDisplay(map) {
let ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {
let div = L.DomUtil.create("div", "zoom-display");
div.textContent = map.getZoom();
map.on("zoomend", () => { div.textContent = map.getZoom(); });
return div;
};
ctrl.addTo(map);
}
// ---------------------------------------------------------------------------
// Export/Import: recipes, supplements
// ---------------------------------------------------------------------------
async function downloadFile(filename, content) {
// Use Tauri save_file (works on Android via SAF)
try {
if (window.__TAURI__) {
await window.__TAURI__.core.invoke("save_file", { filename, content });
return;
}
} catch (e) {
console.error("[download] Tauri save error:", e);
}
// Fallback for desktop browsers
let blob = new Blob([content], { type: "application/json" });
let a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
async function exportAllRecipes() {
let recipes = await DB.getRecipes();
let result = [];
for (let r of recipes) {
let ings = await DB.getRecipeIngredients(r.id);
result.push({
name: r.name,
cooking_method_id: r.cooking_method_id,
cooking_method_name: r.cooking_method_name,
servings: r.servings,
notes: r.notes,
ingredients: ings.map(i => ({
product_name: i.product_name,
custom_name: i.custom_name,
amount_g: i.amount_g,
cooking_method_id: i.cooking_method_id,
cooking_method_name: i.cooking_method_name,
// Store product nutrients so recipient can create product if missing
kcal_per_100g: i.kcal_per_100g,
protein_per_100g: i.protein_per_100g,
fat_per_100g: i.fat_per_100g,
carbs_per_100g: i.carbs_per_100g,
})),
});
}
return result;
}
async function importRecipes(data) {
let arr = Array.isArray(data) ? data : [data];
for (let r of arr) {
if (!r.name) continue;
let rid = await DB.saveRecipe(r.name, r.cooking_method_id ?? null, r.servings ?? 1, r.notes ?? null);
if (!rid) continue;
for (let ing of (r.ingredients || [])) {
// Try to find product by name
let pid = 0;
if (ing.product_name) {
let found = await DB.searchProducts(ing.product_name, 1);
if (found.length) {
pid = found[0].id;
} else if (ing.kcal_per_100g != null) {
// Create product from exported data
pid = await DB.addProduct({
name: ing.product_name,
kcal_per_100g: ing.kcal_per_100g,
protein_per_100g: ing.protein_per_100g,
fat_per_100g: ing.fat_per_100g,
carbs_per_100g: ing.carbs_per_100g,
source: "recipe_import",
});
}
}
await DB.addRecipeIngredient(rid, pid, ing.custom_name ?? null, ing.amount_g ?? 100, ing.cooking_method_id ?? null);
}
}
}
async function importSupplements(data) {
let arr = Array.isArray(data) ? data : [data];
for (let s of arr) {
if (!s.name) continue;
// Remove id — will get new one
let clean = { ...s };
delete clean.id;
await DB.addSupplement(clean);
}
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
let input = document.createElement("input");
input.type = "file";
if (accept) input.accept = accept;
input.style.display = "none";
document.body.appendChild(input);
input.addEventListener("change", async () => {
console.log("[pickFile] change event, files:", input.files.length);
let file = input.files[0];
document.body.removeChild(input);
if (!file) return;
console.log("[pickFile] file:", file.name, file.size);
try {
if (asBinary) {
let buf = await file.arrayBuffer();
await callback(buf, file);
} else {
let text = await file.text();
await callback(text, file);
}
} catch (e) {
console.error("[pickFile] error:", e);
}
});
input.click();
}
function sourceBadge(source) {
let map = {
magnit: ["Магнит", "src-magnit"],
"5ka": ["5ка", "src-5ka"],
kdv: ["KDV", "src-kdv"],
ion: ["ИОН", "src-ion"],
chzn: ["ЧЗ", "src-chzn"],
manual: ["Свой", "src-manual"],
recipe_import: ["Импорт", "src-manual"],
};
let [label, cls] = map[source] || ["", ""];
if (!label) return "";
return `<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);
}
// Copy text to clipboard (navigator.clipboard with a hidden-textarea fallback for
// WebViews where the async API is unavailable). Optional toast on success.
function copyToClipboard(text, toastMsg) {
let ok = () => { if (toastMsg) showToast(toastMsg); };
let fallback = () => {
try {
let ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.focus(); ta.select();
document.execCommand("copy"); ta.remove(); ok();
} catch (e) { showToast("Не удалось скопировать"); }
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(ok).catch(fallback);
} else fallback();
}
// Dark map: invert the tile layer only (overlays keep their colours). User-tuned filter
// on OSM standard tiles — invert(1) hue-rotate(180deg) brightness(1.5). Toggled by the
// user, remembered in localStorage, applied globally to every Leaflet tile pane. No
// tile repacking: works the same on offline (tiles_*.db) and online tiles.
function isDarkMap() { try { return localStorage.getItem("baldav_dark_map") === "1"; } catch (e) { return false; } }
function applyDarkMap() { document.body.classList.toggle("dark-map", isDarkMap()); }
function toggleDarkMap() {
let on = !isDarkMap();
try { localStorage.setItem("baldav_dark_map", on ? "1" : "0"); } catch (e) {}
document.body.classList.toggle("dark-map", on);
document.querySelectorAll(".map-dark-btn, .rec-dark-toggle").forEach(b => b.classList.toggle("active", on));
return on;
}
// Leaflet control button (top-right) for maps without a custom button stack.
function addDarkMapControl(map) {
let Ctl = L.Control.extend({
options: { position: "topright" },
onAdd: function () {
let b = L.DomUtil.create("button", "rec-map-btn map-dark-btn" + (isDarkMap() ? " active" : ""));
b.textContent = "☾"; b.title = "Тёмная карта"; b.style.position = "static";
L.DomEvent.disableClickPropagation(b);
b.onclick = () => toggleDarkMap();
return b;
},
});
map.addControl(new Ctl());
}
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// 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>`;
});