// 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 = `
`;
return;
}
// Diagnostic logs stay in the source but only fire in debug builds. Rust reports
// the compiled build profile (cfg!(debug_assertions)); in a release build we
// silence console.log lines that start with a diagnostic tag ([poll]/[recover]/
// [stop]/[GPS]/[HR]/[SD]/...). console.error/warn are never touched. This means
// no adding/removing logs by hand — a tagged log auto-disappears in release.
try {
let info = await window.__TAURI__.core.invoke("get_app_info");
window.__BALDAV_DEBUG = !!info.debug;
} catch { window.__BALDAV_DEBUG = false; }
if (!window.__BALDAV_DEBUG) {
let _log = console.log.bind(console);
let DIAG = /^\[(poll|recover|stop|GPS|HR|SD|seq|native|flush|watch|import|rec)\]/;
console.log = function (...a) { if (typeof a[0] === "string" && DIAG.test(a[0])) return; _log.apply(console, a); };
}
// Show loading immediately so the user sees something
app.innerHTML = `
Загрузка...
`;
let bootEl = document.getElementById("boot-label");
let bootCount = document.getElementById("seed-count");
let bootFill = document.getElementById("seed-fill");
// Wait for DB migrations (fast — just checks columns exist)
try { if (DB._migrationsReady) await DB._migrationsReady; } catch (e) { console.error("[BOOT]", e); }
// Recover any orphaned GPS recording session
try {
let recovered = await GPS.recoverSession();
if (recovered) showToast("Восстановлена незавершённая тренировка");
} catch (e) { console.error("[Recovery]", e); }
// Load RDI standard and half-life data from profile (default: RU)
try {
let rdiCode = (await DB.getProfileValue("rdi_standard")) || "RU";
await BMR.loadRDI(rdiCode);
await BMR.loadHalflife();
} catch (e) { /* RDI stays at fallback RU values */ }
// Import seed products if needed — show progress only if actually importing
try {
let unlisten = null;
try {
unlisten = await window.__TAURI__.event.listen("seed_progress", (ev) => {
let { current, total } = ev.payload;
let pct = total > 0 ? Math.round(current / total * 100) : 0;
if (bootEl) bootEl.textContent = "Обновление продуктов (" + pct + "%)";
if (bootFill) { bootFill.classList.add("determinate"); bootFill.style.width = pct + "%"; }
if (bootCount) bootCount.textContent = current + " / " + total;
});
} catch {}
let result = await window.__TAURI__.core.invoke("import_seed_products");
if (unlisten) unlisten();
} catch (e) { console.error("[SEED]", e); appAlert("Seed error: " + e); }
// Apply safe area insets (Android status bar / nav bar)
try {
let insets = await window.__TAURI__.core.invoke("get_safe_insets");
if (insets.top || insets.bottom) {
document.documentElement.style.setProperty("--safe-area-top", insets.top + "px");
document.documentElement.style.setProperty("--safe-area-bottom", insets.bottom + "px");
}
} catch {}
state.profile = await DB.getFullProfile();
state.latestWeight = await DB.getLatestWeight();
// Migrate reserves for historical days (one-time)
try { await migrateReserves(); } catch (e) { console.error("[Reserves]", e); }
// Seed browser history from navStack so back gesture works after restart
history.replaceState({ screen: navStack[0] }, "");
for (let i = 1; i < navStack.length; i++) {
history.pushState({ screen: navStack[i] }, "");
}
// Add padding entries so first back doesn't exit
for (let i = 0; i < 3; i++) history.pushState({ screen: navStack[navStack.length - 1] }, "");
// Restore last screen (fromPopstate=true to avoid duplicate navStack push)
renderScreen(navStack[navStack.length - 1], true);
// Initialize sync
try { await Sync.init(); if (Sync.userId) Sync.now(); } catch {}
// Check for updates in background (non-blocking)
checkForUpdate();
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
// Keep the vertical scroll position across a re-render of the SAME screen (day nav, adding
// food, deletes) instead of jumping to the top. Anchor on the topmost visible top-level
// section + its offset (robust to height changes, e.g. one more food row or async charts).
function captureScrollAnchor(el) {
let top = el.scrollTop, kids = el.children, idx = 0, delta = top;
for (let i = 0; i < kids.length; i++) {
if (kids[i].offsetTop <= top + 4) { idx = i; delta = top - kids[i].offsetTop; } else break;
}
return { idx, delta };
}
function applyScrollAnchor(el, a) {
let kid = el.children[a.idx];
if (kid) el.scrollTop = Math.max(0, kid.offsetTop + a.delta);
}
// The screen fills asynchronously (charts, nutrient strips), so re-apply for a short window as
// content settles; stop early if the user scrolls themselves.
function scheduleScrollRestore(el, a) {
let stop = false, off = () => { stop = true; };
el.addEventListener("wheel", off, { passive: true, once: true });
el.addEventListener("touchmove", off, { passive: true, once: true });
let apply = () => { if (!stop) applyScrollAnchor(el, a); };
apply();
requestAnimationFrame(apply);
[60, 180, 400].forEach(ms => setTimeout(apply, ms));
}
function renderScreen(name, fromPopstate) {
// Release screen wake lock and compass listener when leaving recording screen
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
try { TileCache.stopHealer(); } catch {} // leaving a map screen stops online tile-healing; map screens restart it
let prevScreen = state.screen;
state.screen = name;
// Push to nav stack and browser history (unless triggered by popstate)
if (!fromPopstate && prevScreen && prevScreen !== name) {
navStack.push(name);
if (navStack.length > NAV_MAX) navStack.shift();
persistNavStack();
history.pushState({ screen: name }, "");
}
let screens = {
today: renderToday,
products: renderProducts,
recipes: renderRecipes,
gps: renderGpsScreen,
recording: renderRecordingInline,
supplements: renderSupplements,
settings: renderSettings,
};
// Re-render of the same screen: remember where we were scrolled so we can stay put.
let scrollAnchor = null;
if (prevScreen === name) {
let oldContent = app.querySelector(".screen-content");
if (oldContent && oldContent.scrollTop > 0) scrollAnchor = captureScrollAnchor(oldContent);
}
app.innerHTML = "";
// Floating recording bar (visible on all screens except recording)
if (GPS.recording.active && name !== "recording") {
let bar = document.createElement("div");
bar.className = "rec-floating-bar";
bar.onclick = () => renderScreen("recording");
bar.innerHTML = `Запись...`;
app.appendChild(bar);
// Update bar every second
if (window._recBarTimer) clearInterval(window._recBarTimer);
window._recBarTimer = setInterval(() => {
if (!GPS.recording.active) { clearInterval(window._recBarTimer); return; }
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime() - GPS.recording.totalPauseMs) / 1000) : 0;
let el = document.querySelector("#rec-bar-text");
if (el) el.textContent = `${GPS.formatDistance(stats.distance)} | ${GPS.formatDuration(sec)}${GPS.recording.manualPaused ? " | Пауза" : ""}`;
}, 1000);
} else if (window._recBarTimer) {
clearInterval(window._recBarTimer);
}
app.appendChild(buildNav(name));
let content = document.createElement("div");
content.className = "screen-content";
app.appendChild(content);
screens[name](content);
if (scrollAnchor) scheduleScrollRestore(content, scrollAnchor);
}
function buildNav(active) {
let nav = document.createElement("nav");
nav.className = "bottom-nav";
[
{ id: "today", label: "Сегодня" },
{ id: "products", label: "Продукты" },
{ id: "gps", label: "Трек" },
{ id: "supplements", label: "Добавки" },
{ id: "settings", label: "Настройки" },
].forEach(item => {
let btn = document.createElement("button");
btn.className = "nav-btn" + (item.id === active ? " active" : "");
btn.textContent = item.label;
btn.onclick = () => renderScreen(item.id);
nav.appendChild(btn);
});
return nav;
}
// ---------------------------------------------------------------------------
// TODAY screen
// ---------------------------------------------------------------------------
async function renderToday(container) {
container.innerHTML = `
`;
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 = `
`;
document.body.appendChild(overlay);
let stackBase = winStack.length; // remember stack depth before our push
winPush("modal", () => overlay.remove());
function closeModal() {
// Pop all entries this modal added (fullscreen children + modal itself)
let count = winStack.length - stackBase;
for (let i = 0; i < count; i++) winStack.pop().close();
// Jump history back by that many steps; skip the resulting popstate events
_skipPops = count;
if (count > 0) history.go(-count);
}
overlay.querySelector("#modal-back").onclick = () => closeModal();
overlay.querySelector("#modal-cancel").onclick = () => closeModal();
overlay.querySelector("#modal-ok").onclick = async () => {
try {
let ok = await onSubmit(overlay);
if (ok !== false) { closeModal(); if (ok !== "norender") renderScreen(state.screen); }
} catch (e) {
console.error("[showModal] onSubmit error:", e);
appAlert("Ошибка: " + e);
}
};
if (onMount) onMount(overlay);
}
// In-app confirm dialog (replaces the ugly native confirm()). Integrated with the
// winStack/history so the hardware back button cancels it. Returns Promise.
function appConfirm(message, okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
${message}
`;
document.body.appendChild(ov);
let settled = false;
let base = winStack.length;
winPush("confirm", () => { ov.remove(); if (!settled) { settled = true; resolve(false); } });
let finish = (val) => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve(val);
};
ov.querySelector("#cf-cancel").onclick = () => finish(false);
ov.querySelector("#cf-ok").onclick = () => finish(true);
});
}
// In-app text prompt (replaces native prompt()). Returns Promise.
function appPrompt(message, defaultValue = "", okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
${message}
`;
document.body.appendChild(ov);
let input = ov.querySelector("#cf-input");
let settled = false;
let base = winStack.length;
winPush("prompt", () => { ov.remove(); if (!settled) { settled = true; resolve(null); } });
let finish = (val) => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve(val);
};
ov.querySelector("#cf-cancel").onclick = () => finish(null);
ov.querySelector("#cf-ok").onclick = () => finish(input.value);
input.focus();
input.select();
});
}
// In-app info dialog (replaces native alert()). One OK button. Returns Promise.
function appAlert(message, okText = "OK") {
return new Promise(resolve => {
let ov = document.createElement("div");
ov.className = "modal-overlay confirm-overlay";
ov.innerHTML = `
${message}
`;
document.body.appendChild(ov);
let settled = false;
let base = winStack.length;
winPush("alert", () => { ov.remove(); if (!settled) { settled = true; resolve(); } });
let finish = () => {
if (settled) return;
settled = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
resolve();
};
ov.querySelector("#cf-ok").onclick = () => finish();
});
}
// Close the currently-open track-detail modal (plus its fullscreen-map child, if any),
// balance the history stack, then reopen it fresh. Mirrors the recalc/refine pattern
// but pops down to the detail modal so it also works while the map is fullscreen —
// re-rendering in place instead of stacking a second overlay (which broke the map/charts).
// Fill a button's background to `pct` (0-100) as a live progress bar; pct=null clears it.
// The % rides on a CSS custom property (.btn-progress reads --pct) so no inline style rule.
function setBtnProgress(el, pct) {
if (!el) return;
if (pct == null) { el.classList.remove("btn-progress"); el.style.removeProperty("--pct"); return; }
el.classList.add("btn-progress");
el.style.setProperty("--pct", Math.max(0, Math.min(100, Math.round(pct))) + "%");
}
// Show the HR strap charge as a background fill on the HR toggle (0-100%, coloured by level).
function setHrBatteryFill(el, bat) {
if (!el) return;
if (bat == null || bat < 0 || bat > 100) { el.style.removeProperty("--bat"); el.style.removeProperty("--bat-color"); return; }
el.style.setProperty("--bat", bat + "%");
el.style.setProperty("--bat-color", bat > 40 ? "rgba(76,175,80,0.5)" : bat > 15 ? "rgba(255,183,77,0.55)" : "rgba(239,83,80,0.6)");
}
// Before a recording, nudge the user to turn on what it needs: Bluetooth (for the HR strap) and
// location (for the track). Can't silently toggle them on modern Android, so prompt + act: BT via
// the system enable dialog, location via the location-settings screen. Declining is fine, and the
// two "Не спрашивать" settings suppress each prompt. Never prompts when the state is unknown.
async function ensureSystemReady(needBt, needGps) {
let inv = window.__TAURI__.core.invoke;
if (needBt && localStorage.getItem("baldav_dontask_bt") !== "1") {
let bt = "on"; try { bt = await inv("ble_check_bluetooth"); } catch {}
if (bt === "off" && await appConfirm("Для пульсометра нужен Bluetooth. Включить?", "Включить")) {
try { await inv("ble_request_enable"); } catch {}
}
}
if (needGps && localStorage.getItem("baldav_dontask_gps") !== "1") {
let locOn = await GPS.isLocationEnabled();
if (locOn === false && await appConfirm("Для записи трека нужна геолокация. Открыть настройки, чтобы включить?", "Открыть")) {
try { await inv("gps_service_command", { cmd: "open_location_settings" }); } catch {}
}
}
}
async function reopenDetail(sessionId) {
let count = 0;
while (winStack.length > 0) {
let top = winStack.pop();
count++;
try { top.close(); } catch {}
if (top.type === "modal") break;
}
// AWAIT the history.go(-count) popstate(s) before re-rendering. Rendering synchronously
// after history.go races it: showSessionDetail pushes its own history entry, then the
// pending popstates unwind THAT entry and desync the stack, and the async map/chart build
// comes up blank until the next event repaints it (the "graphs+map gone until Cancel" bug).
// Same fix as closeThen() — resolve on the popstates, never a timeout.
if (count > 0) {
_skipPops = count;
await new Promise(res => {
let remaining = count;
let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } };
window.addEventListener("popstate", h);
history.go(-count);
});
}
showSessionDetail(sessionId);
}
function showWeightModal() {
showModal(`
Вес
`,
async overlay => {
let kg = parseFloat(overlay.querySelector("#m-kg").value);
let time = overlay.querySelector("#m-time").value;
if (isNaN(kg)) return false;
await DB.addWeight(state.date, time, kg); // also syncs profile weight_kg to the latest weigh-in
// Refresh in-memory state so kcal/BMR and the settings weight field are current at once.
try {
state.latestWeight = await DB.getLatestWeight();
if (state.profile && state.latestWeight?.weight_kg != null) state.profile.weight_kg = state.latestWeight.weight_kg;
} catch {}
});
}
function showFoodModal() {
let selectedProduct = null;
let selectedUnit = null; // { id, name, name_short, grams_per_unit }
let availableUnits = [];
showModal(`
Еда
`,
async overlay => {
let time = overlay.querySelector("#m-time").value;
let rawAmount = parseAmount(overlay.querySelector("#m-amount").value) || 100;
if (selectedProduct) {
let amountG = rawAmount;
let unitId = null;
let unitAmount = null;
if (selectedUnit && selectedUnit.grams_per_unit) {
// Convert unit amount to grams: e.g. 2 шт * 60г = 120г
unitId = selectedUnit.id;
unitAmount = rawAmount;
amountG = Math.round(rawAmount * selectedUnit.grams_per_unit * 10) / 10;
}
await DB.addFoodEntry(state.date, time, selectedProduct.id, amountG, null, unitId, unitAmount);
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 = ``;
for (let u of availableUnits) {
if (u.grams_per_unit == null) continue; // skip units without conversion
html += ``;
}
unitChipsEl.innerHTML = html;
unitChipsEl.style.display = "";
unitChipsEl.querySelectorAll(".unit-chip").forEach(chip => {
chip.onclick = () => {
let uid = parseInt(chip.dataset.unitId);
if (uid === 0) {
selectedUnit = null;
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
} else {
selectedUnit = availableUnits.find(u => u.id === uid);
amountLabel.textContent = `Количество (${selectedUnit.name_short || selectedUnit.name})`;
amountInput.placeholder = "1";
amountInput.value = "1";
}
unitChipsEl.querySelectorAll(".unit-chip").forEach(c => c.classList.remove("selected"));
chip.classList.add("selected");
};
});
}
createProductSearch(overlay.querySelector("#m-search-wrap"), async (product) => {
selectedProduct = product;
selectedUnit = null;
selectedEl.style.display = "block";
selectedEl.innerHTML = `${esc(product.name)}`;
manualFields.style.display = "none";
// Load available units for this product
availableUnits = await DB.getUnitsForProduct(product.id);
if (availableUnits.length > 0) {
renderUnitChips();
} else {
unitChipsEl.style.display = "none";
}
// Reset amount — show "мл" for liquids
let isLiquid = product.water_pct && product.water_pct > 80;
amountLabel.textContent = `Количество (${isLiquid ? "мл" : "г"})`;
amountInput.placeholder = isLiquid ? "250" : "100";
amountInput.value = isLiquid ? "250" : "100";
selectedEl.onclick = () => {
selectedProduct = null;
selectedUnit = null;
availableUnits = [];
selectedEl.style.display = "none";
unitChipsEl.style.display = "none";
manualFields.style.display = "";
amountLabel.textContent = "Количество (г)";
amountInput.placeholder = "100";
amountInput.value = "100";
};
});
}
function showEditFoodModal(entry) {
let newProduct = null; // if user picks a different product
let origLabel = entry.product_name ?? entry.recipe_name ?? entry.note ?? "";
showModal(`
Редактирование
${esc(origLabel)}
${!entry.product_id && !entry.recipe_id ? `
` : ""}
`,
async overlay => {
let amount = parseAmount(overlay.querySelector("#m-amount").value);
let time = overlay.querySelector("#m-time").value;
let note = overlay.querySelector("#m-note")?.value.trim() || null;
if (isNaN(amount) || amount <= 0) return false;
if (newProduct) {
// Replace: delete old, add new
await DB.deleteFoodEntry(entry.id);
await DB.addFoodEntry(entry.date ?? state.date, time, newProduct.id, amount, note);
} else {
let kcalEl = overlay.querySelector("#m-kcal");
let kcalOverride = kcalEl ? (parseFloat(kcalEl.value) || null) : null;
await DB.updateFoodEntry(entry.id, time, amount, note, entry.product_id ? null : kcalOverride);
}
});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let currentEl = overlay.querySelector("#m-current");
let searchBlock = overlay.querySelector("#m-search-block");
currentEl.onclick = () => {
searchBlock.style.display = "block";
searchBlock.querySelector("#m-prod-search").focus();
};
let searchInput = overlay.querySelector("#m-prod-search");
let resultsEl = overlay.querySelector("#m-prod-results");
let debounce = null;
searchInput.oninput = () => {
clearTimeout(debounce);
debounce = setTimeout(async () => {
let q = searchInput.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ""; return; }
let results = await DB.searchProducts(q);
resultsEl.innerHTML = results.map(p =>
`
`;
}
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: `
`,
async overlay => {
let name = overlay.querySelector("#np-name").value.trim();
if (!name) return false;
let data = {
name,
kcal_per_100g: parseFloat(overlay.querySelector("#np-kcal").value) || null,
protein_per_100g: parseFloat(overlay.querySelector("#np-protein").value) || null,
fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null,
carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null,
water_pct: parseFloat(overlay.querySelector("#np-water").value) || null,
source: pf.source === "chzn" ? "chzn" : "manual",
};
for (let f of PRODUCT_MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#np-${f.key}`).value) || null;
if (val && f.mult) val *= f.mult;
data[f.key] = val;
}
await DB.addProduct(data);
if (onSaved) onSaved();
});
}
// Честный знак scanner: camera + photo/manual, decoded by bundled zxing-wasm
// (ZXing-C++: frontend/lib/zxing-wasm.js + zxing_reader.wasm) — fully offline,
// no Google Play Services, and far better at LOCATING a DataMatrix than the
// pure-JS port. Live frames are cropped to the central ROI and upscaled for
// speed/reliability. On decode -> ChZ.resolve -> prefill the new-product modal.
function showChznScanModal(onSaved) {
if (!ChZ.hasSession()) {
appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh).");
return;
}
let ov = document.createElement("div");
ov.className = "modal-overlay";
ov.innerHTML = `
Скан «Честный знак»
🔍
Запуск камеры...
`;
document.body.appendChild(ov);
let stream = null, scanning = false, busy = false, zoomFactor = 1;
let base = winStack.length;
let statusEl = ov.querySelector("#cz-status");
let video = ov.querySelector("#cz-video");
let canvas = document.createElement("canvas");
let cctx = canvas.getContext("2d", { willReadFrequently: true });
function stopCamera() {
scanning = false;
try {
let s = video && video.srcObject;
if (s && s.getTracks) s.getTracks().forEach(t => t.stop());
if (video) video.srcObject = null;
} catch {}
}
winPush("chzn-scan", () => { stopCamera(); ov.remove(); });
function close() {
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
}
// Close this modal AND wait for its history.go popstate(s) to flush before the
// caller opens the next modal. Opening it synchronously after close() races the
// async history.go (it would unwind the NEW modal's entry) and desyncs the back
// stack — that was the "swipe-back does nothing" bug on scan -> add-product.
function closeThen() {
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
if (count <= 0) return Promise.resolve();
_skipPops = count;
return new Promise(res => {
let remaining = count;
let h = () => { if (--remaining <= 0) { window.removeEventListener("popstate", h); res(); } };
window.addEventListener("popstate", h);
history.go(-count);
});
}
async function onDecoded(rawVal) {
if (busy) return;
busy = true; scanning = false;
statusEl.textContent = "Код распознан, запрашиваю КБЖУ...";
try {
let prefill = await ChZ.resolve(rawVal);
stopCamera();
await closeThen();
showAddProductModal(onSaved, prefill);
} catch (e) {
busy = false;
statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз.";
if (stream) { scanning = true; tick(); }
}
}
// Central ROI of the current video frame, upscaled to a square -> ImageData.
// Cropping to the guide box removes clutter and makes the code larger, which
// is exactly what the locator needs (full-frame search was slow/unreliable).
function grabROI() {
let vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return null;
// The square container shows the video center-cropped (object-fit:cover), so
// sample exactly that center square (WYSIWYG). zxing-wasm locates the code
// anywhere within it — no need for the user to fill a tiny box; use zoom to
// enlarge the code at a focusable distance instead.
let side = Math.min(vw, vh) / zoomFactor; // digital zoom crops tighter to center
let sx = (vw - side) / 2, sy = (vh - side) / 2;
let out = Math.min(Math.min(vw, vh), 768); // enough detail, still fast
canvas.width = out; canvas.height = out;
cctx.drawImage(video, sx, sy, side, side, 0, 0, out, out);
return cctx.getImageData(0, 0, out, out);
}
async function tick() {
if (busy || !scanning) return;
let img = grabROI();
if (img) {
try {
let raw = await ChZ.decodeImageData(img, ["DataMatrix"]);
if (raw) { onDecoded(raw); return; }
} catch (e) { /* keep scanning */ }
}
if (scanning) setTimeout(tick, 100);
}
// Wire zoom slider / torch / continuous focus from the track capabilities.
// Zoom is the real answer to "the code is too small at focus distance" — the
// user enlarges the code optically instead of moving the phone dangerously close.
let torchOn = false;
function setupCameraControls() {
let track = stream && stream.getVideoTracks && stream.getVideoTracks()[0];
if (!track || !track.getCapabilities) return;
let caps = {};
try { caps = track.getCapabilities() || {}; } catch {}
try {
if (caps.focusMode && caps.focusMode.includes("continuous")) {
track.applyConstraints({ advanced: [{ focusMode: "continuous" }] }).catch(() => {});
}
} catch {}
// Zoom slider: prefer hardware zoom; otherwise fall back to digital zoom
// (CSS-scale the preview + crop the decode ROI). Always shown so the user can
// enlarge a small code instead of moving the phone inside its focus range.
let zw = ov.querySelector("#cz-zoom-wrap");
let zi = ov.querySelector("#cz-zoom");
if (caps.zoom && caps.zoom.max > caps.zoom.min) {
zi.min = caps.zoom.min; zi.max = caps.zoom.max;
zi.step = caps.zoom.step || (caps.zoom.max - caps.zoom.min) / 100 || 0.1;
zi.value = ((track.getSettings && track.getSettings()) || {}).zoom || caps.zoom.min;
zi.oninput = () => { track.applyConstraints({ advanced: [{ zoom: parseFloat(zi.value) }] }).catch(() => {}); };
} else {
zi.min = 1; zi.max = 5; zi.step = 0.1; zi.value = 1;
zi.oninput = () => {
zoomFactor = parseFloat(zi.value) || 1;
video.style.transformOrigin = "center center";
video.style.transform = "scale(" + zoomFactor + ")";
};
}
zw.style.display = "flex";
if (caps.torch) {
let tb = ov.querySelector("#cz-torch");
tb.style.display = "block";
tb.onclick = () => {
torchOn = !torchOn;
track.applyConstraints({ advanced: [{ torch: torchOn }] }).catch(() => {});
tb.style.background = torchOn ? "rgba(255,214,10,.35)" : "";
};
}
}
async function startCamera() {
try {
statusEl.textContent = "Загрузка декодера...";
await ChZ.zxReady();
} catch (e) {
statusEl.textContent = "Декодер не загрузился: " + (e.message || e);
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" }, width: { ideal: 1920 }, height: { ideal: 1080 } },
});
video.srcObject = stream;
await video.play();
setupCameraControls();
scanning = true;
statusEl.textContent = "Наведите на код (используйте зум, если код мелкий)…";
tick();
} catch (e) {
statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код.";
}
}
ov.querySelector("#cz-photo").onclick = () => {
pickFile("image/*", async (buf) => {
try {
await ChZ.zxReady();
let bmp = await createImageBitmap(new Blob([buf]));
let c = document.createElement("canvas");
c.width = bmp.width; c.height = bmp.height;
let x = c.getContext("2d", { willReadFrequently: true });
x.drawImage(bmp, 0, 0);
let img = x.getImageData(0, 0, bmp.width, bmp.height);
let raw = await ChZ.decodeImageData(img, ["DataMatrix", "QRCode"]);
if (raw) onDecoded(raw);
else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое/крупное фото.";
} catch (e) { statusEl.textContent = "Ошибка распознавания: " + (e.message || e); }
}, true);
};
ov.querySelector("#cz-manual").onclick = async () => {
let code = await appPrompt("Содержимое кода DataMatrix (01…21…93…):", "");
if (code) onDecoded(code.trim());
};
ov.querySelector("#cz-close").onclick = () => close();
startCamera();
}
function showEditProductModal(p, onSaved) {
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
let val = p[f.key] ?? "";
if (val && f.mult) val = Math.round(val / f.mult * 1000) / 1000;
return `
`;
}).join("");
showModal(`
Редактирование продукта
Нутриенты на 100г:
${microHtml}
`,
async overlay => {
let name = overlay.querySelector("#ep-name").value.trim();
if (!name) return false;
let waterPct = parseFloat(overlay.querySelector("#ep-water").value) || null;
let sets = ["name=?1", "kcal_per_100g=?2", "protein_per_100g=?3", "fat_per_100g=?4", "carbs_per_100g=?5", "water_pct=?6"];
let vals = [
name,
parseFloat(overlay.querySelector("#ep-kcal").value) || null,
parseFloat(overlay.querySelector("#ep-protein").value) || null,
parseFloat(overlay.querySelector("#ep-fat").value) || null,
parseFloat(overlay.querySelector("#ep-carbs").value) || null,
waterPct,
];
let idx = 7;
for (let f of PRODUCT_MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#ep-${f.key}`).value) || null;
if (val && f.mult) val *= f.mult;
sets.push(`${f.key}=?${idx}`);
vals.push(val);
idx++;
}
sets.push(`user_edited=1`);
vals.push(p.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE products SET ${sets.join(", ")} WHERE id=?${idx}`,
values: vals,
});
if (onSaved) onSaved();
});
// Export single product
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let expBtn = overlay?.querySelector("#ep-export");
if (expBtn) expBtn.onclick = () => {
let clean = { ...p };
delete clean.id;
downloadFile(`baldav-product-${(p.name || "").slice(0, 30)}.json`, JSON.stringify({ products: [clean] }, null, 2), "application/json");
};
}, 100);
}
// ---------------------------------------------------------------------------
// RECIPES screen
// ---------------------------------------------------------------------------
async function renderRecipes(container) {
let recipes = await DB.getRecipes();
// Pre-calculate nutrition for each recipe to show kcal in list
let nutritionMap = {};
for (let r of recipes) {
try {
nutritionMap[r.id] = await DB.calculateRecipeNutrition(r.id);
} catch (e) {
nutritionMap[r.id] = null;
}
}
container.innerHTML = `
Рецепты
${recipes.length ? recipes.map(r => {
let n = nutritionMap[r.id];
let kcalStr = n ? n.kcal + " кк" : "";
let macroStr = n ? `Б${n.protein} Ж${n.fat} У${n.carbs}` : "";
return `
`, async overlay => {
let name = overlay.querySelector("#re-name").value.trim();
if (!name) return false;
if (ingredients.length === 0) return false;
let methodId = parseInt(overlay.querySelector("#re-method").value) || null;
let servings = parseFloat(overlay.querySelector("#re-servings").value) || 1;
let notes = overlay.querySelector("#re-notes").value.trim() || null;
let rid;
if (isNew) {
rid = await DB.saveRecipe(name, methodId, servings, notes);
} else {
rid = recipeId;
await DB.updateRecipe(rid, name, methodId, servings, notes);
await DB.clearRecipeIngredients(rid);
}
for (let ing of ingredients) {
await DB.addRecipeIngredient(
rid, ing.product_id, ing.custom_name, ing.weight_g, ing.cooking_method_id
);
}
// Cache computed nutrients on the recipe for daily tracking
await DB.cacheRecipeNutrients(rid);
renderScreen("recipes");
});
// Set initial method value
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (recipe.cooking_method_id) {
overlay.querySelector("#re-method").value = recipe.cooking_method_id;
}
// Servings or method change recalculates totals
overlay.querySelector("#re-servings").oninput = updateTotalsDisplay;
overlay.querySelector("#re-method").onchange = updateTotalsDisplay;
// Bind delete buttons and add ingredient button
function rebindIngredients() {
let listEl = overlay.querySelector("#re-ing-list");
listEl.innerHTML = renderIngredientRows();
listEl.querySelectorAll(".recipe-ing-del").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
let idx = parseInt(btn.dataset.idx);
ingredients.splice(idx, 1);
rebindIngredients();
updateTotalsDisplay();
};
});
}
rebindIngredients();
// Add ingredient button — shows inline search
overlay.querySelector("#re-add-ing").onclick = () => {
showAddIngredientModal(cookingMethods, (newIng) => {
ingredients.push(newIng);
rebindIngredients();
updateTotalsDisplay();
});
};
// Load product data for existing ingredients (for nutrition calc)
for (let i = 0; i < ingredients.length; i++) {
if (ingredients[i].product_id) {
ingredients[i]._product = await DB.getProduct(ingredients[i].product_id);
}
}
updateTotalsDisplay();
}
// ---------------------------------------------------------------------------
// Add ingredient sub-modal — search product, set weight, optional cooking method
// ---------------------------------------------------------------------------
function showAddIngredientModal(cookingMethods, onAdd) {
let selectedProduct = null;
let cmOptions = cookingMethods.map(cm =>
``
).join("");
showModal(`
Добавить ингредиент
`, async overlay => {
let weight = parseFloat(overlay.querySelector("#ing-weight").value);
if (isNaN(weight) || weight <= 0) return false;
let methodId = parseInt(overlay.querySelector("#ing-method").value) || null;
let methodName = methodId
? cookingMethods.find(cm => cm.id === methodId)?.name ?? null
: null;
if (selectedProduct) {
onAdd({
product_id: selectedProduct.id,
product_name: selectedProduct.name,
custom_name: null,
weight_g: weight,
cooking_method_id: methodId,
cooking_method_name: methodName,
_product: selectedProduct,
});
} else {
let custom = overlay.querySelector("#ing-custom").value.trim();
if (!custom) return false;
onAdd({
product_id: null,
product_name: null,
custom_name: custom,
weight_g: weight,
cooking_method_id: methodId,
cooking_method_name: methodName,
_product: null,
});
}
return "norender"; // sub-modal: close without re-rendering parent screen
});
let overlays = document.querySelectorAll(".modal-overlay");
let overlay = overlays[overlays.length - 1];
let selectedEl = overlay.querySelector("#ing-selected");
let customBlock = overlay.querySelector("#ing-custom-block");
let search = createProductSearch(overlay.querySelector("#ing-search-wrap"), (product) => {
selectedProduct = product;
selectedEl.style.display = "block";
selectedEl.innerHTML = `${esc(product.name)}`;
customBlock.style.display = "none";
selectedEl.onclick = () => {
selectedProduct = null;
selectedEl.style.display = "none";
customBlock.style.display = "";
};
});
search.focus();
}
// ---------------------------------------------------------------------------
// Food modal: add recipe option
// ---------------------------------------------------------------------------
function showFoodFromRecipeModal() {
// Show a list of saved recipes to quickly log one
showModal(`
Записать рецепт
Загрузка...
`, async overlay => {
let selected = overlay.querySelector(".recipe-pick-item.selected");
if (!selected) return false;
let rid = parseInt(selected.dataset.rid);
let time = overlay.querySelector("#rfr-time").value;
let amountG = parseAmount(overlay.querySelector("#rfr-amount").value);
if (isNaN(amountG) || amountG <= 0) return false;
await DB.addFoodFromRecipe(state.date, time, rid, amountG);
});
// Load recipes into the list
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let listEl = overlay.querySelector("#rfr-list");
(async () => {
let recipes = await DB.getRecipes();
if (!recipes.length) {
listEl.innerHTML = `
Нет рецептов. Создайте в Продукты → Рецепты.
`;
return;
}
// Calculate nutrition for display
let items = [];
for (let r of recipes) {
let n = await DB.calculateRecipeNutrition(r.id);
items.push({ ...r, nutr: n });
}
listEl.innerHTML = items.map(r =>
`
`
).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) => `
`;
// 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 selDeleteBtn = container.querySelector("#gps-sel-delete");
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; // merge needs 2+
if (selDeleteBtn) selDeleteBtn.disabled = selected.size < 1; // delete works from 1
};
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 = "Объединить";
}
};
if (selDeleteBtn) selDeleteBtn.onclick = async () => {
if (selected.size < 1) return;
let ids = [...selected];
let n = ids.length;
let word = (n % 10 === 1 && n % 100 !== 11) ? "трек"
: (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) ? "трека" : "треков";
if (!(await appConfirm(`Удалить ${n} ${word}? Это необратимо.`, "Удалить"))) return;
try {
selDeleteBtn.disabled = true; selDeleteBtn.textContent = "Удаляю...";
let done = 0;
for (let id of ids) {
try { await DB.deleteGpsSession(id); done++; } catch (e) { console.error("[bulk delete]", id, e); }
}
showToast(`Удалено: ${done}`);
exitSel();
renderScreen("gps");
} catch (e) {
showToast("Ошибка удаления: " + (e.message || e));
selDeleteBtn.disabled = false; selDeleteBtn.textContent = "Удалить";
}
};
// Pagination: "Показать ещё" loads the next page from the DB and appends it.
let sessOffset = sessions.length;
let moreBtn = container.querySelector("#gps-more");
if (moreBtn) moreBtn.onclick = async () => {
moreBtn.disabled = true;
let label = moreBtn.textContent;
moreBtn.textContent = "Загрузка...";
let next = await DB.getGpsSessions(PAGE_SIZE, sessOffset);
let listEl = container.querySelector("#gps-session-list");
next.forEach(s => {
let tmp = document.createElement("div");
tmp.innerHTML = sessionCard(s);
let item = tmp.firstElementChild;
bindSessionClick(item);
listEl.appendChild(item);
});
sessOffset += next.length;
moreBtn.disabled = false;
moreBtn.textContent = label;
if (next.length < PAGE_SIZE) moreBtn.style.display = "none";
};
// Three-dot menu
let menuBtn = container.querySelector("#gps-menu-btn");
let menu = container.querySelector("#gps-menu");
menuBtn.onclick = () => menu.classList.toggle("hidden");
document.addEventListener("click", e => {
if (!e.target.closest(".gps-menu-wrap")) menu.classList.add("hidden");
}, { once: false });
// Download map tiles — region picker
container.querySelector("#gps-draw-route").onclick = () => showDrawRouteScreen(container);
container.querySelector("#gps-routes").onclick = () => {
menu.classList.add("hidden");
showRoutesScreen(container);
};
container.querySelector("#gps-download-map").onclick = () => {
menu.classList.add("hidden");
showMapDownloadModal();
};
// Clear tile cache
container.querySelector("#gps-clear-cache").onclick = async () => {
menu.classList.add("hidden");
let count = await TileCache.count();
await TileCache.clear();
console.log("Cache cleared");
};
// Recover points from native files — rebuilds sessions whose DB rows fell behind
// the service's JSONL (e.g. screen-off import glitch). Works fully on-device.
container.querySelector("#gps-recover").onclick = async () => {
menu.classList.add("hidden");
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
// Keep the screen awake — recovery runs in the WebView and stalls if it sleeps.
let wl = null;
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
// Visible progress bar (fixed at the bottom) so it's clear it's working.
let prog = document.createElement("div");
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
prog.textContent = "Восстановление: подготовка...";
document.body.appendChild(prog);
try {
let res = await GPS.recoverFromFiles((i, n) => { prog.textContent = `Восстановление: файл ${i}/${n}`; });
if (res.error === "recording") { prog.remove(); showToast("Нельзя во время записи"); return; }
let list = res.results || [];
let totalPts = list.reduce((a, r) => a + (r.imported || 0), 0);
let sessions = list.filter(r => r.imported > 0).length;
let unmatched = list.filter(r => r.unmatched).length;
let removed = res.removed || 0;
// Show the result IN the bar — a toast gets overwritten by the Bluetooth
// check when the screen re-renders. Tap the bar to close and refresh.
prog.textContent = `Готово: файлов ${list.length}, вставлено ${totalPts} в ${sessions} сессий, удалено ${removed}, сирот ${unmatched}. Нажмите, чтобы закрыть`;
prog.style.cursor = "pointer";
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
} catch (e) {
console.error("[recover] failed", e);
prog.textContent = "Ошибка восстановления: " + (e.message || e) + " — нажмите, чтобы закрыть";
prog.onclick = () => prog.remove();
} finally {
if (wl) { try { await wl.release(); } catch {} }
}
};
// Recompute stored elevation/kcal for every recorded session (formula fix).
// Stats-only: never touches points. Same progress-bar UX as recovery.
container.querySelector("#gps-recalc-stats").onclick = async () => {
menu.classList.add("hidden");
if (GPS.recording.active) { showToast("Нельзя во время записи"); return; }
let wl = null;
try { if (navigator.wakeLock) wl = await navigator.wakeLock.request("screen"); } catch {}
let prog = document.createElement("div");
prog.style.cssText = "position:fixed;left:0;right:0;bottom:0;z-index:99999;background:var(--primary);color:#000;text-align:center;padding:14px;font-size:15px;font-weight:600";
prog.textContent = "Пересчёт: подготовка...";
document.body.appendChild(prog);
try {
let res = await GPS.recomputeAllStats((i, n) => { prog.textContent = `Пересчёт: ${i}/${n}`; });
if (res.error) { prog.textContent = "Ошибка: " + res.error + " — нажмите, чтобы закрыть"; prog.onclick = () => prog.remove(); return; }
prog.textContent = `Готово: пересчитано ${res.updated}, пропущено ${res.skipped}. Нажмите, чтобы закрыть`;
prog.style.cursor = "pointer";
prog.onclick = () => { prog.remove(); renderScreen("gps"); };
} catch (e) {
console.error("[recalc-all] failed", e);
prog.textContent = "Ошибка пересчёта: " + (e.message || e) + " — нажмите, чтобы закрыть";
prog.onclick = () => prog.remove();
} finally {
if (wl) { try { await wl.release(); } catch {} }
}
};
// Unified import — detect by extension
container.querySelector("#gps-import").onclick = () => {
menu.classList.add("hidden");
pickFile("", async (data, file) => {
console.log("[IMPORT] file:", file.name, "size:", data.byteLength || data.length, "type:", typeof data);
let ext = file.name.split(".").pop().toLowerCase();
console.log("[IMPORT] ext:", ext);
let wkg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
try {
if (ext === "gpx") {
let text = typeof data === "string" ? data : new TextDecoder().decode(data);
await GPS.importGPX(text, wkg);
} else if (ext === "ftb" || ext === "zip") {
let buf = data instanceof ArrayBuffer ? data : (typeof data === "string" ? new TextEncoder().encode(data).buffer : data);
console.log("[IMPORT] FTB buf size:", buf.byteLength);
let count = await GPS.importFTB(buf, wkg);
console.log("[IMPORT] done, count:", count);
} else {
console.error("[IMPORT] Unsupported file type: " + ext);
}
renderScreen("gps");
} catch (e) {
console.error("[IMPORT] error:", e, String(e));
}
}, true);
};
// Goals button — manage goal markers
container.querySelector("#gps-goals-btn").onclick = () => showGoalMarkersModal();
}
// ---------------------------------------------------------------------------
// Goal markers management modal (IDEA 2)
// ---------------------------------------------------------------------------
async function showGoalMarkersModal() {
let markers = await DB.getGoalMarkers();
let html = `
Цели (маркеры)
${markers.length ? markers.map(m => `
${esc(m.name)}
${m.lat.toFixed(5)}, ${m.lon.toFixed(5)}
${m.notes ? `
${esc(m.notes)}
` : ""}
`).join("") : `
Нет маркеров. Нажмите "Добавить" для создания.
`}
`;
showModal(html, () => {});
// Hide save/cancel since this is a management modal
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
let btns = overlay.querySelector(".modal-btns");
if (btns) btns.style.display = "none";
// Delete buttons
overlay.querySelectorAll(".gm-del").forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteGoalMarker(parseInt(btn.dataset.mid));
// Close and reopen modal
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showGoalMarkersModal();
};
});
// Add button
overlay.querySelector("#gm-add").onclick = () => {
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
showAddGoalMarkerModal();
};
}, 100);
}
async function showAddGoalMarkerModal() {
showModal(`
Новый маркер
Нажмите на карту для выбора точки
`, async overlay => {
let name = overlay.querySelector("#gm-name").value.trim();
let lat = parseFloat(overlay.querySelector("#gm-lat").value);
let lon = parseFloat(overlay.querySelector("#gm-lon").value);
let notes = overlay.querySelector("#gm-notes").value.trim() || null;
if (!name) return false;
if (isNaN(lat) || isNaN(lon)) return false;
await DB.addGoalMarker(name, lat, lon, notes);
// Return to goals list
showGoalMarkersModal();
});
// Initialize map after DOM is ready
setTimeout(() => {
let mapEl = document.querySelector("#gm-map");
let coordsEl = document.querySelector("#gm-coords");
let latEl = document.querySelector("#gm-lat");
let lonEl = document.querySelector("#gm-lon");
if (!mapEl) return;
// Center on user's last known position or default
let defaultLat = 55.75, defaultLon = 37.62;
// Try to get last GPS point from recent sessions
DB.getGpsSessions(1).then(async sessions => {
if (sessions.length > 0) {
let pts = await DB.getGpsPoints(sessions[0].id_str ?? sessions[0].id);
if (pts.length > 0) {
let last = pts[pts.length - 1];
defaultLat = last.lat;
defaultLon = last.lon;
map.setView([defaultLat, defaultLon], 14);
}
}
}).catch(() => {});
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([defaultLat, defaultLon], 14);
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let marker = null;
map.on("click", (e) => {
let lat = e.latlng.lat;
let lon = e.latlng.lng;
latEl.value = lat;
lonEl.value = lon;
coordsEl.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
if (marker) map.removeLayer(marker);
marker = L.marker([lat, lon]).addTo(map);
});
// Also try current position
navigator.geolocation?.getCurrentPosition(
pos => {
if (!latEl.value) {
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
}
},
() => {},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
);
}, 200);
}
// ---------------------------------------------------------------------------
// Draw manual route on map
// ---------------------------------------------------------------------------
async function fetchElevations(points, onProgress) {
// Terrain elevation, tried in order until one answers a batch:
// 1. GPXZ (if a key is set) — Copernicus GLO-30, best for grades (abs <4m).
// 2. open-elevation.com (SRTM, reachable in RU).
// 3. open-meteo (often blocked by RU carriers, kept in case).
// 4. OpenTopoData public (free, no key, srtm30m) — last-resort keyless analog.
// Batches of 100 (also OpenTopoData's max locations/request). The GPXZ key lives
// only in GPS.config (localStorage), never in git.
let elevations = new Array(points.length).fill(null);
let gpxzKey = GPS.config.gpxzKey && GPS.config.gpxzKey.trim();
for (let i = 0; i < points.length; i += 100) {
let batch = points.slice(i, i + 100);
let got = null;
if (gpxzKey) {
try {
let latlons = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");
let resp = await fetch("https://api.gpxz.io/v1/elevation/points?latlons=" + encodeURIComponent(latlons),
{ headers: { "x-api-key": gpxzKey } });
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
} else { console.log("[gpxz] http", resp.status); }
} catch (e) { console.log("[gpxz] batch fail:", e.message); }
}
if (got) { for (let j = 0; j < got.length; j++) elevations[i + j] = got[j]; continue; }
try {
let resp = await fetch("https://api.open-elevation.com/api/v1/lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locations: batch.map(p => ({ latitude: p.lat, longitude: p.lon })) }),
});
if (resp.ok) {
let d = await resp.json();
if (d.results) got = d.results.map(r => r.elevation);
}
} catch (e) { console.log("[open-elevation] batch fail:", e.message); }
if (!got) {
try {
let lats = batch.map(p => p.lat.toFixed(5)).join(",");
let lons = batch.map(p => p.lon.toFixed(5)).join(",");
let resp = await fetch(`https://api.open-meteo.com/v1/elevation?latitude=${lats}&longitude=${lons}`);
if (resp.ok) { let d = await resp.json(); if (d.elevation) got = d.elevation; }
} catch (e) { console.log("[open-meteo] batch fail:", e.message); }
}
if (!got) {
try {
let locs = batch.map(p => p.lat.toFixed(6) + "," + p.lon.toFixed(6)).join("|");
let resp = await fetch("https://api.opentopodata.org/v1/srtm30m?locations=" + encodeURIComponent(locs));
if (resp.ok) { let d = await resp.json(); if (d.results) got = d.results.map(r => r.elevation); }
} catch (e) { console.log("[opentopodata] batch fail:", e.message); }
}
if (got) for (let j = 0; j < got.length; j++) elevations[i + j] = got[j];
if (onProgress) try { onProgress(Math.min(i + 100, points.length), points.length); } catch {}
}
return elevations.some(x => x != null) ? elevations : null;
}
// Colour for the roads overlay by activity type (road_points.atype holds the full
// activity_type: "walk" / "bike" / "run"). Configurable in GPS settings.
function roadColor(atype) {
// get_road_points (Rust) returns atype as a single letter: "w" walk, "r" run, "b" bike/other.
let c = GPS.config;
if (atype === "w") return c.roadColorWalk || "#8B5E3C";
if (atype === "r") return c.roadColorRun || "#e67e22";
return c.roadColorBike || "#2266aa";
}
// Line thickness by how often a stretch was ridden (road_points.passes from rebuild_roads):
// base width x (1..3), +0.2 per extra pass, capped at 3x. Optional — disabled by the flag.
function roadsThickOn() { return localStorage.getItem("baldav_roads_thick") !== "0"; }
function roadWeight(passes, base) {
base = base || 3;
if (!roadsThickOn()) return base;
return base * Math.min(3, 1 + 0.2 * ((passes || 1) - 1));
}
// Draw one road segment (array of {lat,lon,passes}) as ONE polyline, its width from the
// segment's MEDIAN pass count. Earlier this split each segment into a run per weight change,
// which exploded a 1.4k-segment network into ~84k polylines and janked the map; one width per
// segment keeps it at one polyline per segment while still reading thicker on often-ridden
// routes. `extra`: { pane, renderer, base, opacity, smoothFactor }.
function drawRoadSegment(map, seg, color, out, extra) {
if (!seg || seg.length < 2) return;
extra = extra || {};
let base = extra.base || 3;
let opts = { color, opacity: extra.opacity != null ? extra.opacity : 0.6 };
if (extra.pane) opts.pane = extra.pane;
if (extra.renderer) opts.renderer = extra.renderer;
if (extra.smoothFactor != null) opts.smoothFactor = extra.smoothFactor;
let weight = base;
if (roadsThickOn()) {
let ps = seg.map(p => p.passes || 1).sort((a, b) => a - b);
weight = roadWeight(ps[ps.length >> 1], base); // median pass → one width per segment
}
out.push(L.polyline(seg.map(p => [p.lat, p.lon]), { ...opts, weight }).addTo(map));
}
function debounce(fn, ms) { let t; return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); }; }
// Split flat road points into segments (break on >100 m gap or activity-type change) and
// precompute each segment's bbox [minLat,minLon,maxLat,maxLon] for viewport culling.
function buildRoadSegments(pts) {
let segs = [], seg = [];
const push = () => {
if (seg.length >= 2) {
let minLa = Infinity, maxLa = -Infinity, minLo = Infinity, maxLo = -Infinity;
for (let p of seg) {
if (p.lat < minLa) minLa = p.lat; if (p.lat > maxLa) maxLa = p.lat;
if (p.lon < minLo) minLo = p.lon; if (p.lon > maxLo) maxLo = p.lon;
}
segs.push({ pts: seg, atype: seg[0].atype, b: [minLa, minLo, maxLa, maxLo] });
}
seg = [];
};
for (let i = 0; i < pts.length; i++) {
if (seg.length > 0) {
let prev = seg[seg.length - 1];
let dlat = (pts[i].lat - prev.lat) * 111000;
let dlon = (pts[i].lon - prev.lon) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || pts[i].atype !== prev.atype) push();
}
seg.push(pts[i]);
}
push();
return segs;
}
// Viewport culling: clear `out` layers, then draw ONLY segments whose bbox meets the padded
// view. Keeps the drawn set to a screenful instead of the whole (possibly huge) road network.
function cullRoadsInto(map, segments, out, pad, extra) {
out.forEach(l => map.removeLayer(l)); out.length = 0;
let v = map.getBounds().pad(pad);
let S = v.getSouth(), N = v.getNorth(), W = v.getWest(), E = v.getEast();
for (let s of segments) {
let bb = s.b; // [minLat,minLon,maxLat,maxLon]
if (bb[2] < S || bb[0] > N || bb[1] > E || bb[3] < W) continue; // bbox fully outside view
drawRoadSegment(map, s.pts, roadColor(s.atype), out, extra);
}
}
// Resolve once the Leaflet map has finished its initial layout, so layers added
// after this actually render (adding a polyline before the map is loaded/sized
// leaves it invisible until the next redraw). Awaits the real 'load' event — the
// single event→await bridge — then forces a size recalc.
async function whenMapReady(map) {
if (!map._loaded) await new Promise(res => map.once("load", res));
map.invalidateSize();
}
// Draw the active saved route (localStorage baldav_active_route) as a colored line
// on a Leaflet map. Removes `prev` first. Returns the new layer, or null if none.
// Hypsometric tint (like old topo maps): low = green, mid = yellow, high = brown.
// t in [0,1] (normalized elevation).
function routeElevColor(t) {
t = Math.max(0, Math.min(1, t));
let lerp = (a, b, f) => Math.round(a + (b - a) * f);
let c1, c2, f;
if (t < 0.5) { c1 = [46, 125, 50]; c2 = [253, 216, 53]; f = t / 0.5; } // green -> yellow
else { c1 = [253, 216, 53]; c2 = [109, 76, 65]; f = (t - 0.5) / 0.5; } // yellow -> brown
return `rgb(${lerp(c1[0], c2[0], f)},${lerp(c1[1], c2[1], f)},${lerp(c1[2], c2[2], f)})`;
}
async function drawActiveRouteOverlay(map, prev) {
if (prev) { try { map.removeLayer(prev); } catch {} }
let id = localStorage.getItem("baldav_active_route");
if (!id) return null;
let route = await DB.getRoute(id);
if (!route || !route.points || route.points.length < 2) return null;
return drawRoutePolyline(map, route.points, route.color);
}
// Draw the impassable "nogo" circles (DB.nogo_areas) as red translucent circles. Tapping a
// circle offers to remove it; onChange() (if given) fires after a removal so the caller can
// re-fetch nogos and re-route. Returns the layer group (removed via the `prev` arg on redraw).
async function drawNogoAreas(map, prev, onChange) {
if (prev) { try { map.removeLayer(prev); } catch {} }
let areas = await DB.getNogoAreas();
let group = L.layerGroup();
for (let a of areas) {
let c = L.circle([a.lat, a.lon], { radius: a.radius_m || 25, color: "#e53935", weight: 2, fillColor: "#e53935", fillOpacity: 0.22 });
((area) => c.on("click", async (e) => {
L.DomEvent.stop(e);
if (await appConfirm("Убрать пометку «нет проезда»?", "Убрать")) {
await DB.deleteNogoArea(area.id);
if (onChange) onChange();
}
}))(a);
c.addTo(group);
}
group.addTo(map);
return group;
}
// nogos for the router: [{lat,lon,radius}] from the nogo_areas table.
async function getNogosForRouter() {
return (await DB.getNogoAreas()).map(a => ({ lat: a.lat, lon: a.lon, radius: a.radius_m || 25 }));
}
// ---------------------------------------------------------------------------
// Ridden-roads router: route on the user's own tracks first, BRouter for the rest
// ---------------------------------------------------------------------------
let _rideGraph = null; // { nodes:[[lat,lon]], adj:Map, grid:Map, cellDeg }
async function loadRideGraph(force) {
if (_rideGraph && !force) return _rideGraph;
let raw = await window.__TAURI__.core.invoke("get_ride_graph", { minSpeedMs: 0.8 });
let g = JSON.parse(raw);
let nodes = g.nodes || [];
let adj = new Map();
for (let e of (g.edges || [])) {
let a = e[0], b = e[1], d = e[2];
if (!adj.has(a)) adj.set(a, []); adj.get(a).push([b, d]);
if (!adj.has(b)) adj.set(b, []); adj.get(b).push([a, d]);
}
let cellDeg = 0.00045; // ~50 m nearest-node buckets
let grid = new Map();
for (let i = 0; i < nodes.length; i++) {
let key = Math.round(nodes[i][0] / cellDeg) + "," + Math.round(nodes[i][1] / cellDeg);
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(i);
}
_rideGraph = { nodes, adj, grid, cellDeg };
return _rideGraph;
}
function _nearestRideNode(g, lat, lon, maxM) {
let cx = Math.round(lat / g.cellDeg), cy = Math.round(lon / g.cellDeg), best = -1, bestD = Infinity;
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
let arr = g.grid.get((cx + dx) + "," + (cy + dy)); if (!arr) continue;
for (let i of arr) { let d = GPS.distance(lat, lon, g.nodes[i][0], g.nodes[i][1]); if (d < bestD) { bestD = d; best = i; } }
}
return (best >= 0 && bestD <= maxM) ? { node: best, dist: bestD } : null;
}
function _rideDijkstra(g, src, dst, maxDist) {
let distMap = new Map([[src, 0]]), prev = new Map(), heap = [[0, src]], seen = new Set();
while (heap.length) {
let mi = 0; for (let i = 1; i < heap.length; i++) if (heap[i][0] < heap[mi][0]) mi = i;
let popped = heap.splice(mi, 1)[0], d = popped[0], u = popped[1];
if (seen.has(u)) continue; seen.add(u);
if (u === dst) break;
if (d > maxDist) return null;
let nb = g.adj.get(u); if (!nb) continue;
for (let vw of nb) {
let v = vw[0], w = vw[1]; if (seen.has(v)) continue;
let nd = d + w;
if (nd < (distMap.get(v) ?? Infinity)) { distMap.set(v, nd); prev.set(v, u); heap.push([nd, v]); }
}
}
if (!seen.has(dst)) return null;
let path = [], u = dst;
while (u != null) { path.push([g.nodes[u][0], g.nodes[u][1]]); u = prev.get(u); }
path.reverse();
return { path, dist: distMap.get(dst) };
}
// Route a→b on ridden roads. Returns [{lat,lon}] (with the exact waypoints at the ends) or null
// when either end isn't near ridden roads or the ridden path is too indirect (fall back to BRouter).
async function rideRoute(aLat, aLon, bLat, bLon, opts) {
opts = opts || {};
let g = await loadRideGraph();
if (!g || !g.nodes.length) return null;
let sn = _nearestRideNode(g, aLat, aLon, opts.snapM ?? 60);
let dn = _nearestRideNode(g, bLat, bLon, opts.snapM ?? 60);
if (!sn || !dn) return null;
if (sn.node === dn.node) return null; // same node → let BRouter handle a tiny leg
let straight = GPS.distance(aLat, aLon, bLat, bLon);
let cap = (opts.maxRatio ?? 2.5) * Math.max(300, straight);
let res = _rideDijkstra(g, sn.node, dn.node, cap + 2000);
if (!res || res.dist > cap) return null;
return [{ lat: aLat, lon: aLon }].concat(res.path.map(p => ({ lat: p[0], lon: p[1] })), [{ lat: bLat, lon: bLon }]);
}
// Move a point `d` metres along bearing `brDeg` (for a "start forward, don't U-turn" helper).
function _offsetLatLon(lat, lon, brDeg, d) {
let R = 6378137, br = brDeg * Math.PI / 180, la1 = lat * Math.PI / 180, lo1 = lon * Math.PI / 180;
let la2 = Math.asin(Math.sin(la1) * Math.cos(d / R) + Math.cos(la1) * Math.sin(d / R) * Math.cos(br));
let lo2 = lo1 + Math.atan2(Math.sin(br) * Math.sin(d / R) * Math.cos(la1), Math.cos(d / R) - Math.sin(la1) * Math.sin(la2));
return { lat: la2 * 180 / Math.PI, lon: lo2 * 180 / Math.PI };
}
// On-the-fly detour: from the current position, build a connector back ONTO the saved route
// PAST the blockage, avoiding nogos, preferring to start in the travel direction (no 180 if a
// bearing is given). Does NOT modify the saved route. Blockage reference = nearest nogo that
// sits on the route (<=150 m), else the current position's nearest route point. Target = a
// route point ~1.5 km further along; on failure step further (up to the route end).
// Returns { points:[{lat,lon}], target:{lat,lon} } or null.
// Reroute back onto the active route PAST a blockage ahead. Only acts when a nogo
// actually sits on the route ahead of the current progress point (in the travel
// direction) — otherwise returns {noBlockage:true} and builds nothing (pressing it
// with no blockage used to route to a bogus far point). Returns {points,target} on
// success, null if a detour couldn't be built, {noBlockage:true} if nothing to do.
async function rejoinReroute(cur, bearingDeg, routePts, nogos) {
if (!cur || !routePts || routePts.length < 2) return null;
let cum = [0];
for (let i = 1; i < routePts.length; i++) cum[i] = cum[i-1] + GPS.distance(routePts[i-1].lat, routePts[i-1].lon, routePts[i].lat, routePts[i].lon);
let inNogo = (p) => nogos && nogos.some(n => GPS.distance(p.lat, p.lon, n.lat, n.lon) <= (n.radius || 25) + 15);
// current progress = nearest route point to the current position
let curIdx = 0, curD = Infinity;
for (let i = 0; i < routePts.length; i++) { let d = GPS.distance(cur.lat, cur.lon, routePts[i].lat, routePts[i].lon); if (d < curD) { curD = d; curIdx = i; } }
// travel direction along the route: increasing index unless the compass bearing
// clearly points the other way (handles routes ridden in reverse / loops).
let step = 1;
if (bearingDeg != null && !isNaN(bearingDeg)) {
let ja = Math.min(routePts.length - 1, curIdx + 1), jb = Math.max(0, curIdx - 1);
if (ja !== jb) {
let fb = GPS.bearing(routePts[jb].lat, routePts[jb].lon, routePts[ja].lat, routePts[ja].lon);
let diff = Math.abs(((bearingDeg - fb + 540) % 360) - 180); // 0 = aligned w/ increasing idx, 180 = opposite
if (diff > 90) step = -1; // travelling against increasing index → forward is decreasing idx
}
}
// blockage: first route point within a nogo AHEAD, within a lookahead window
const LOOKAHEAD = 2500;
let blocked = null;
for (let i = curIdx; i >= 0 && i < routePts.length; i += step) {
if (Math.abs(cum[i] - cum[curIdx]) > LOOKAHEAD) break;
if (inNogo(routePts[i])) { blocked = i; break; }
}
if (blocked == null) return { noBlockage: true };
// rejoin target: first route point clear of every nogo AND >= margin past the blockage
const CLEAR_MARGIN = 300;
let ti = blocked;
while (ti >= 0 && ti < routePts.length) {
if (!inNogo(routePts[ti]) && Math.abs(cum[ti] - cum[blocked]) >= CLEAR_MARGIN) break;
ti += step;
}
if (ti < 0 || ti >= routePts.length) ti = step > 0 ? routePts.length - 1 : 0;
let helper = (bearingDeg != null && !isNaN(bearingDeg)) ? _offsetLatLon(cur.lat, cur.lon, bearingDeg, 40) : null;
let jump = Math.max(1, Math.round(routePts.length / 50));
for (let attempt = 0; attempt < 6; attempt++) {
let target = routePts[ti];
let straight = GPS.distance(cur.lat, cur.lon, target.lat, target.lon);
let wps = helper ? [[cur.lon, cur.lat], [helper.lon, helper.lat], [target.lon, target.lat]] : [[cur.lon, cur.lat], [target.lon, target.lat]];
try {
let gpx = await BRouter.route(wps, "trekking", nogos);
let rp = (GPS.parseRouteGPX(gpx) || {}).points || [];
if (rp.length >= 2) {
let rlen = 0; for (let k = 1; k < rp.length; k++) rlen += GPS.distance(rp[k-1].lat, rp[k-1].lon, rp[k].lat, rp[k].lon);
if (rlen <= Math.max(500, straight * 3)) return { points: rp.map(p => ({ lat: p.lat, lon: p.lon })), target: { lat: target.lat, lon: target.lon } };
}
} catch (e) { /* try a further rejoin point */ }
let next = ti + step * jump;
if (next < 0 || next >= routePts.length) break;
ti = next;
}
return null;
}
// ---------------------------------------------------------------------------
// Share image ("Похвастаться"): track shape + stats as a story-format PNG
// ---------------------------------------------------------------------------
// Story-format duration: "1h 29min" / "29 min" (GPS.formatDuration is h:mm:ss).
function _shareDuration(sec) {
let h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
if (m === 60) { h += 1; m = 0; }
return h > 0 ? (m > 0 ? h + "h " + m + "min" : h + "h") : m + " min";
}
// Project track points into a WxH box (equirectangular at track scale, padded, Y down).
function _projectTrack(points, w, h, pad) {
let lats = points.map(p => p.lat), lons = points.map(p => p.lon);
let minLa = Math.min(...lats), maxLa = Math.max(...lats), minLo = Math.min(...lons), maxLo = Math.max(...lons);
let kx = Math.cos((minLa + maxLa) / 2 * Math.PI / 180); // lon → same metric scale as lat
let spanX = ((maxLo - minLo) * kx) || 1e-6, spanY = (maxLa - minLa) || 1e-6;
let scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY);
let ox = (w - spanX * scale) / 2, oy = (h - spanY * scale) / 2;
return points.map(p => ({ x: ox + (p.lon - minLo) * kx * scale, y: oy + (maxLa - p.lat) * scale }));
}
// Which fields the share image can show, and their defaults. `f` is the per-image
// selection (opts.fields), falling back to defaults so an unset key stays on.
let SHARE_FIELDS = ["track", "distance", "time", "speed", "maxspeed", "elev", "kcal", "date"];
let SHARE_FIELD_LABELS = { track: "Форма трека", distance: "Дистанция", time: "Время", speed: "Ср. скорость", maxspeed: "Макс. скорость", elev: "Набор высоты", kcal: "Калории", date: "Дата" };
let SHARE_FIELD_DEFAULTS = { track: true, distance: true, time: true, speed: true, maxspeed: false, elev: true, kcal: true, date: true };
function shareFieldOn(fields, key) { return fields && key in fields ? !!fields[key] : SHARE_FIELD_DEFAULTS[key]; }
// Build the share PNG dataURL. opts.bg: "transparent" | "photo"; opts.fields: per-field on/off.
async function buildShareImage(session, stats, points, opts) {
opts = opts || {};
let f = opts.fields;
let W = 1080, H = 1920;
let cv = document.createElement("canvas"); cv.width = W; cv.height = H;
let ctx = cv.getContext("2d");
if (opts.bg === "photo") {
let bg = localStorage.getItem("baldav_share_bg");
if (bg) {
await new Promise((res) => {
let img = new Image();
img.onload = () => { let s = Math.max(W / img.width, H / img.height); let dw = img.width * s, dh = img.height * s; ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); res(); };
img.onerror = () => res();
img.src = bg;
});
}
let g = ctx.createLinearGradient(0, H * 0.4, 0, H);
g.addColorStop(0, "rgba(0,0,0,0)"); g.addColorStop(1, "rgba(0,0,0,0.55)");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
let hasTrack = shareFieldOn(f, "track") && points && points.length >= 2;
// Track shape (top ~42%).
if (hasTrack) {
let boxTop = 90, boxH = Math.round(H * 0.42);
let pts = _projectTrack(points.map(p => ({ lat: p.lat, lon: p.lon })), W, boxH, 130);
let col = (window.GPS && GPS.config && GPS.config.trackColor) || "#2f6bff";
ctx.lineJoin = "round"; ctx.lineCap = "round";
ctx.shadowColor = col; ctx.shadowBlur = 30; ctx.strokeStyle = col; ctx.lineWidth = 12;
ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y + boxTop) : ctx.moveTo(p.x, p.y + boxTop));
ctx.stroke(); ctx.shadowBlur = 0;
}
// Stats.
let cx = W / 2;
function bigStat(y, value, label) {
ctx.textAlign = "center";
ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 3;
ctx.fillStyle = "#fff"; ctx.font = "700 100px system-ui, -apple-system, sans-serif"; ctx.fillText(value, cx, y);
ctx.shadowBlur = 5; ctx.fillStyle = "rgba(255,255,255,0.85)"; ctx.font = "400 42px system-ui, -apple-system, sans-serif"; ctx.fillText(label, cx, y + 56);
ctx.shadowBlur = 0; ctx.shadowOffsetY = 0;
}
// Collect enabled stats; lay them out adaptively so turning some off leaves no gaps.
let big = [];
if (shareFieldOn(f, "distance")) big.push([(stats.distance / 1000).toFixed(1) + " km", "Distance"]);
if (shareFieldOn(f, "time")) big.push([_shareDuration(stats.duration || 0), "Time"]);
if (shareFieldOn(f, "speed")) big.push([(stats.avgSpeed || 0).toFixed(1) + " km/h", "Speed"]);
if (shareFieldOn(f, "maxspeed") && stats.maxSpeed != null) big.push([(stats.maxSpeed || 0).toFixed(1) + " km/h", "Max speed"]);
let footer = [];
if (shareFieldOn(f, "elev") && stats.elevGain != null) footer.push("↑ " + Math.round(stats.elevGain) + " m");
if (shareFieldOn(f, "kcal") && stats.kcal != null) footer.push(Math.round(stats.kcal) + " kcal");
if (shareFieldOn(f, "date") && session && session.date) footer.push(session.date);
// Position the stats+footer block: just under the track when it's shown, else centred.
let blockH = big.length * 250 + (footer.length ? 90 : 0);
let y0 = hasTrack ? Math.round(H * 0.55) : Math.round((H - blockH) / 2 + 90);
big.forEach((s, i) => bigStat(y0 + i * 250, s[0], s[1]));
if (footer.length) {
ctx.textAlign = "center"; ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 6;
ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "400 36px system-ui, -apple-system, sans-serif";
ctx.fillText(footer.join(" · "), cx, y0 + big.length * 250 + 40); ctx.shadowBlur = 0;
}
return cv.toDataURL("image/png");
}
// Send the generated PNG to the native layer: action "share" (system chooser) or "save" (SAF).
async function exportShareImage(dataUrl, filename, action) {
if (!dataUrl) return;
let base64 = dataUrl.split(",")[1];
try {
await window.__TAURI__.core.invoke("export_image", { filename, base64, action });
showToast(action === "share" ? "Открываю «Поделиться»…" : "Сохранение…");
} catch (e) { showToast("Экспорт картинки: " + (e.message || e)); }
}
// Preview modal with a transparent/photo toggle + Share/Save.
function showShareModal(session, stats, points) {
// Per-field on/off, remembered across shares. Missing keys fall back to defaults.
let fields = {};
try { fields = JSON.parse(localStorage.getItem("baldav_share_fields") || "{}") || {}; } catch { fields = {}; }
let fieldChecks = SHARE_FIELDS.map(k =>
``
).join("");
let ov = document.createElement("div");
ov.className = "share-modal";
ov.innerHTML = `
${fieldChecks}
`;
document.body.appendChild(ov);
// Register in the global window stack so the hardware/gesture back closes THIS
// modal instead of paging the screens behind it (winPush adds a history entry).
let base = winStack.length, closed = false;
winPush("share", () => ov.remove());
let closeShare = () => {
if (closed) return;
closed = true;
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
ov.remove();
};
let bg = "transparent", dataUrl = null;
let img = ov.querySelector("#share-img");
let bt = ov.querySelector("#share-bg-t"), bp = ov.querySelector("#share-bg-p");
async function render() { dataUrl = await buildShareImage(session, stats, points, { bg, fields }); img.src = dataUrl; }
ov.querySelectorAll(".share-field input").forEach(cb => cb.onchange = () => {
fields[cb.dataset.field] = cb.checked;
try { localStorage.setItem("baldav_share_fields", JSON.stringify(fields)); } catch {}
render();
});
bt.onclick = () => { bg = "transparent"; bt.classList.add("active"); bp.classList.remove("active"); render(); };
bp.onclick = () => {
if (!localStorage.getItem("baldav_share_bg")) { showToast("Подложка не выбрана - задайте в Настройках"); return; }
bg = "photo"; bp.classList.add("active"); bt.classList.remove("active"); render();
};
let filename = () => "baldav-" + ((session && session.date) || "track") + ".png";
ov.querySelector("#share-do").onclick = () => exportShareImage(dataUrl, filename(), "share");
ov.querySelector("#share-save").onclick = () => exportShareImage(dataUrl, filename(), "save");
ov.querySelector("#share-close").onclick = () => closeShare();
render();
}
// Slope tint by absolute grade in %, in 1%-wide discrete bands (hard boundaries
// Slope is coloured by DIRECTION of travel (point order = travel direction):
// uphill climbs through a WARM fan (green -> yellow -> red -> brown -> black),
// downhill drops through a COOL fan (green -> cyan -> blue -> navy). Flat (<1%)
// is green either way. 1% bands: <1%=0 .. 9–10%=9, >=10% = last.
let ROUTE_SLOPE_UP = [
"#2e7d32", "#7cb342", "#aeea00", "#ffeb3b", "#ffc107",
"#ff9800", "#ff5722", "#e53935", "#b71c1c", "#6d4c41", "#000000",
];
let ROUTE_SLOPE_DOWN = [
"#2e7d32", "#2e9e5b", "#26a69a", "#00bcd4", "#29b6f6",
"#1e88e5", "#1976d2", "#1565c0", "#0d47a1", "#0a357a", "#001f5c",
];
function routeSlopeColor(gradePct) {
let g = gradePct || 0;
let idx = Math.min(10, Math.floor(Math.abs(g)));
return g < 0 ? ROUTE_SLOPE_DOWN[idx] : ROUTE_SLOPE_UP[idx];
}
// Compact colour strips for the slope legend (every 1% band, 0 → 10%+), both fans.
function slopeLegendHtml() {
let strip = arr => arr
.map(c => ``)
.join("");
return `подъём ${strip(ROUTE_SLOPE_UP)} крутой спуск ${strip(ROUTE_SLOPE_DOWN)} крутой`;
}
// Which BRouter profile to route with: the adventure variant (fords/grade5/bad trails
// allowed) when the rider opted in, else the default trekking (avoids them).
function trekProfile() {
return localStorage.getItem("baldav_allow_adventure") === "1" ? "trekking-adventure" : "trekking";
}
// Draw a route as a feature group: a wide colored casing (per segment — by slope
// or by elevation, per baldav_route_color_mode) UNDER a thin identity-color line,
// when the route has elevations; otherwise just the plain colored line. The casing
// lives in a dedicated pane below the overlay pane, so a recorded track always
// draws ON TOP of the route. Removable via map.removeLayer.
function drawRoutePolyline(map, pts, color, mode) {
mode = mode || localStorage.getItem("baldav_route_color_mode") || "slope";
// On a rotating map (leaflet-rotate) tile/overlay panes live INSIDE _rotatePane;
// a pane created at the default mapPane level wouldn't rotate/zoom with the map
// (route flashes then vanishes). Parent our pane to _rotatePane when it exists.
if (!map.getPane("routePane")) {
map.createPane("routePane", map._rotatePane || undefined);
map.getPane("routePane").style.zIndex = 250; // between tilePane(200) and overlayPane(400)
}
let popts = { pane: "routePane", interactive: false };
let latlngs = pts.map(p => [p.lat, p.lon]);
let group = L.featureGroup();
let st = GPS.routeElevStats(pts);
if (st.hasEle) {
let valid = st.smoothed.filter(e => e != null);
let lo = Math.min(...valid), hi = Math.max(...valid), span = (hi - lo) || 1;
for (let i = 0; i < pts.length - 1; i++) {
// No elevation on this segment (e.g. a straight leg between planner waypoints) → leave
// it as the thin identity line only, so road (casing) and straight (thin) read apart.
if (st.smoothed[i] == null && st.smoothed[i+1] == null) continue;
let col;
if (mode === "elev") {
let e = st.smoothed[i] ?? st.smoothed[i+1];
col = routeElevColor((e - lo) / span);
} else {
col = routeSlopeColor(st.grades[i]);
}
L.polyline([latlngs[i], latlngs[i+1]], { ...popts, color: col, weight: 14, opacity: 0.95 }).addTo(group);
}
}
L.polyline(latlngs, { ...popts, color: color || "#e53935", weight: 3, opacity: 0.95 }).addTo(group);
group.addTo(map);
return group;
}
// Saved routes management page (import GPX, rename, color, delete, pick active).
// Replaces the GPS container like showDrawRouteScreen; back returns to the GPS screen.
async function showRoutesScreen(container) {
let active = localStorage.getItem("baldav_active_route") || "";
async function render() {
let routes = await DB.getRoutes();
let rows = routes.map(r => `
Нет маршрутов. Нажмите «Новый маршрут» — тапайте по карте опорные точки, линия проложится по дорогам (BRouter) прямо в телефоне. Или импортируйте готовый GPX.
`}
`;
container.querySelector("#routes-back").onclick = () => renderScreen("gps");
container.querySelector("#routes-new").onclick = () => showRouteEditor(container, null);
container.querySelector("#route-none").onchange = () => {
active = ""; localStorage.removeItem("baldav_active_route");
showToast("Маршрут отключён");
};
container.querySelector("#routes-import").onclick = () => {
pickFile(".gpx", async (text, file) => {
let parsed;
try { parsed = GPS.parseRouteGPX(text); } catch (e) { showToast("Ошибка чтения GPX"); return; }
if (!parsed.points || parsed.points.length < 2) { showToast("В GPX нет линии маршрута"); return; }
let defName = (parsed.name || (file.name || "маршрут")).replace(/\.gpx$/i, "").trim();
let name = await appPrompt("Название маршрута:", defName, "Импорт");
if (name == null) return;
name = name.trim() || defName;
let pts = parsed.points;
// Best-effort: if the GPX had no , fetch terrain elevations now so the
// route shows relief right away. Offline just skips it (import still works).
if (!pts.some(p => p.ele != null)) {
showToast("Загрузка высот...");
try {
let eles = await fetchElevations(pts);
if (eles) pts = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p));
} catch {}
}
await DB.addRoute(name, "#e53935", pts);
showToast(`Импортирован: ${name} (${pts.length} тчк)`);
render();
});
};
container.querySelectorAll(".route-row").forEach(row => {
let id = row.getAttribute("data-id");
let curName = row.getAttribute("data-name");
row.querySelector(".route-name").onclick = () => showRouteDetail(container, id);
row.querySelector("input[type=radio]").onchange = () => {
active = id;
localStorage.setItem("baldav_active_route", id);
localStorage.setItem("baldav_route_shown", "1"); // selecting a route defaults its overlay ON
showToast("Маршрут выбран активным");
};
row.querySelector(".route-color").onchange = async (e) => {
await DB.setRouteColor(id, e.target.value);
row.querySelector(".route-swatch").style.background = e.target.value;
};
row.querySelector(".route-rename").onclick = async () => {
let name = await appPrompt("Новое название:", curName, "Сохранить");
if (name == null || !name.trim()) return;
await DB.renameRoute(id, name.trim());
render();
};
row.querySelector(".route-del").onclick = async () => {
if (!(await appConfirm(`Удалить маршрут «${curName}»?`, "Удалить"))) return;
await DB.deleteRoute(id);
if (active === id) { active = ""; localStorage.removeItem("baldav_active_route"); }
render();
};
});
}
render();
}
// Route preview: map (topo casing + tappable altitude/grade points) + elevation
// profile chart + ascent/descent/max-grade — so you can see how hilly a hand-drawn
// route is BEFORE riding it. Reuses fetchElevations / GPS.routeElevStats / Chart.js.
// Elevation correction grid from ridden tracks (Rust get_elev_correction): per ~20m
// cell, the median (measured − SRTM) offset over >= N passes. Cached per session.
let _elevCorr = null;
async function loadElevCorrection(force) {
if (_elevCorr && !force) return _elevCorr;
let minPasses = parseInt(localStorage.getItem("baldav_elev_corr_passes")) || 3;
let raw = await window.__TAURI__.core.invoke("get_elev_correction", { minPasses });
let arr = JSON.parse(raw) || [];
let dLat = 0.00018, dLon = 0.00027, cells = new Map();
for (let [lat, lon, delta] of arr) cells.set(Math.round(lat / dLat) + "," + Math.round(lon / dLon), delta);
_elevCorr = { cells, dLat, dLon };
return _elevCorr;
}
// SRTM stays the base; nudge each point up/down by the SMOOTHED ridden offset where
// you've ridden enough. No coverage -> delta 0 -> unchanged. Non-destructive (returns
// a copy), so the stored route keeps its original elevations.
async function applyRiddenElevCorrection(pts) {
let corr;
try { corr = await loadElevCorrection(); } catch { return pts; }
if (!corr || !corr.cells.size) return pts;
let deltas = pts.map(p => {
if (p.ele == null) return 0;
let ci = Math.round(p.lat / corr.dLat), cj = Math.round(p.lon / corr.dLon);
let v0 = corr.cells.get(ci + "," + cj);
if (v0 != null) return v0;
for (let di = -1; di <= 1; di++) for (let dj = -1; dj <= 1; dj++) {
let v = corr.cells.get((ci + di) + "," + (cj + dj));
if (v != null) return v;
}
return 0; // no ridden coverage here
});
let half = 3;
return pts.map((p, i) => {
if (p.ele == null) return p;
let sum = 0, cnt = 0;
for (let j = Math.max(0, i - half); j <= Math.min(pts.length - 1, i + half); j++) { sum += deltas[j]; cnt++; }
let d = cnt ? sum / cnt : 0;
return d ? { ...p, ele: p.ele + d } : p;
});
}
async function showRouteDetail(container, routeId) {
let route = await DB.getRoute(routeId);
if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); showRoutesScreen(container); return; }
let pts = await applyRiddenElevCorrection(route.points);
let st = GPS.routeElevStats(pts);
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
let legendHtml = mode === "slope"
? slopeLegendHtml()
: `▬ низко → ▬ высоко`;
container.innerHTML = `
Высоты ещё не загружены — нажмите кнопку ниже (нужен интернет).
`}
`;
container.querySelector("#rd-back").onclick = () => showRoutesScreen(container);
container.querySelector("#rd-edit").onclick = () => showRouteEditor(container, routeId);
container.querySelector("#rd-fetch").onclick = async (e) => {
let btn = e.currentTarget, orig = btn.textContent;
btn.disabled = true; btn.textContent = "Загрузка высот...";
let eles = null;
try { eles = await fetchElevations(pts); } catch {}
if (!eles) { showToast("Сервис высот недоступен"); btn.disabled = false; btn.textContent = orig; return; }
let merged = pts.map((p, i) => (eles[i] != null ? { ...p, ele: eles[i] } : p));
await DB.updateRoutePoints(routeId, merged);
showToast("Высоты загружены");
showRouteDetail(container, routeId);
};
let mapEl = container.querySelector("#rd-map");
if (mapEl) {
void mapEl.offsetHeight; // force layout so Leaflet reads the real size
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { preferCanvas: true, attributionControl: false, zoomControl: false }); // canvas: slope-coloured route vectors vs SVG compositor tile-memory limit
map.setView([pts[0].lat, pts[0].lon], 13); // give the map a view NOW so vector layers render (full-page timing)
new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map);
let routeLayer = drawRoutePolyline(map, pts, route.color, mode);
// Tappable vertices → altitude + grade to next point
for (let i = 0; i < pts.length; i++) {
let e = st.smoothed[i], g = st.grades[i];
let info = `#${i}${e != null ? " | " + Math.round(e) + " м" : ""}${i < pts.length - 1 ? " | " + (g >= 0 ? "+" : "") + g.toFixed(1) + "%" : ""}`;
L.circleMarker([pts[i].lat, pts[i].lon], { radius: 4, color: "#fff", weight: 1, fillColor: "#000", fillOpacity: 0.5, interactive: true })
.bindPopup(info, { closeButton: false, className: "point-popup" }).addTo(map);
}
let bounds = L.latLngBounds(pts.map(p => [p.lat, p.lon])).pad(0.1);
// On-map color-mode toggle (slope <-> elevation) — redraws the route in place.
if (st.hasEle) {
let colorCtl = L.control({ position: "topleft" });
colorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = mode === "slope" ? "∡" : "⛰";
d.title = "Раскраска: уклон / высота";
d.onclick = (e) => {
e.stopPropagation();
mode = mode === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", mode);
map.removeLayer(routeLayer);
routeLayer = drawRoutePolyline(map, pts, route.color, mode);
d.innerHTML = mode === "slope" ? "∡" : "⛰";
let leg = container.querySelector("#rd-legend");
if (leg) leg.innerHTML = mode === "slope"
? slopeLegendHtml()
: `▬ низко → ▬ высоко`;
};
return d;
};
colorCtl.addTo(map);
}
// Fullscreen expand/collapse — reuses .sd-map-full + winStack, like the track map.
let fsDiv;
function toggleFs() {
if (mapEl.classList.contains("sd-map-full")) { history.back(); return; }
mapEl.classList.add("sd-map-full");
fsDiv.innerHTML = "✕";
map.invalidateSize(); map.fitBounds(bounds);
winPush("fsRouteMap", () => {
mapEl.classList.remove("sd-map-full");
fsDiv.innerHTML = "⛶";
map.invalidateSize(); map.fitBounds(bounds);
});
}
let fsCtl = L.control({ position: "topright" });
fsCtl.onAdd = () => {
fsDiv = L.DomUtil.create("div", "sd-map-fs-btn sd-map-expand-btn");
fsDiv.innerHTML = "⛶";
fsDiv.title = "На весь экран";
fsDiv.onclick = (e) => { e.stopPropagation(); toggleFs(); };
return fsDiv;
};
fsCtl.addTo(map);
map.invalidateSize();
map.fitBounds(bounds);
}
if (st.hasEle) {
let ctx = container.querySelector("#rd-elev-chart");
if (ctx) new Chart(ctx, {
type: "line",
data: {
labels: st.cumKm.map(k => k.toFixed(1)),
datasets: [{
data: st.smoothed,
borderColor: "#ffb74d", borderWidth: 2, pointRadius: 0, fill: true,
backgroundColor: "rgba(255,183,77,0.12)", tension: 0.3,
// Steep (>8%) segments turn red — the sections that hurt on a singlespeed.
segment: { borderColor: c => Math.abs(st.grades[c.p0DataIndex] || 0) > 8 ? "#e53935" : "#ffb74d" },
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: {
title: items => items[0].label + " км",
label: it => Math.round(it.raw) + " м, уклон " + ((st.grades[it.dataIndex] || 0) >= 0 ? "+" : "") + (st.grades[it.dataIndex] || 0).toFixed(1) + "%",
} },
},
scales: {
x: { title: { display: true, text: "км", color: "#888" }, ticks: { color: "#888", maxTicksLimit: 8 }, grid: { color: "#333" } },
y: { title: { display: true, text: "м", color: "#888" }, ticks: { color: "#888" }, grid: { color: "#333" } },
},
},
});
}
}
// Draw a NEW saved route, or edit an existing one's geometry, right in the app —
// tap to add points, undo the last, save. Elevations are fetched afterwards from
// the preview screen (geometry-only here). New -> prompts a name; edit -> replaces
// points_json (drops stale elevations — geometry changed) keeping name/color.
async function showRouteEditor(container, routeId) {
let editing = routeId != null;
let route = editing ? await DB.getRoute(routeId) : null;
// Editable waypoints. Planner-built routes store them separately (5-6 pts) + per-leg mode;
// imported / legacy routes only have dense geometry → load that as waypoints (legs straight).
let wpSaved = (editing && route && route.waypoints && route.waypoints.waypoints) ? route.waypoints : null;
let points = wpSaved
? wpSaved.waypoints.map(p => ({ lat: p.lat, lon: p.lon }))
: ((editing && route) ? route.points.map(p => ({ lat: p.lat, lon: p.lon })) : []);
let color = (editing && route) ? (route.color || "#e53935") : "#e53935";
let markers = [];
// Per-leg road routing: each leg between consecutive waypoints is independently "road"
// (BRouter geometry, with elevation, cached in legGeom[i]) or "straight". Default = road.
let hitLines = []; // transparent fat polyline per leg — the tap/long-press target
let displayLayer = null; // the visible route (casing+slope+thin line) via drawRoutePolyline
let legMode = []; // "road" | "straight", length = points.length - 1
let legGeom = []; // routed [{lat,lon,ele}] per road leg, or null
let legDangers = []; // [{lat,lon,type}] per road leg (fords/rough from BRouter tags)
let dangerLayer = null; // danger markers overlay
let defaultMode = "road";
let routeTimer = null;
let dirty = new Set();
let nogos = []; // [{lat,lon,radius}] impassable circles the route must avoid
let nogoLayer = null; // red circles overlay
let nogoMode = false; // when on, a map tap drops a nogo instead of a waypoint
container.innerHTML = `
0 мДистанция
0Точек
тап карты — точка · тап линии — вставить точку · долгий тап — дорога/прямая · тап точки — удалить
`;
setTimeout(() => {
let mapEl = document.querySelector("#re-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
new (TileCache.createLayer())({ maxZoom: 19 }).addTo(map);
let distEl = document.querySelector("#re-dist");
let ptsEl = document.querySelector("#re-pts");
let dotIcon = L.divIcon({
className: "re-dot-wrap",
html: ``,
iconSize: [18, 18], iconAnchor: [9, 9],
});
function normalizeLegs() {
let need = Math.max(0, points.length - 1);
while (legMode.length < need) { legMode.push(defaultMode); legGeom.push(null); legDangers.push(null); }
legMode.length = need; legGeom.length = need; legDangers.length = need;
}
// Drawn/saved geometry for a leg: routed pts for a routed road leg (carry ele), else the
// straight chord (also the fallback when a road leg couldn't be routed).
function legPts(i) {
if (legMode[i] === "road" && legGeom[i] && legGeom[i].length >= 2) return legGeom[i];
return [points[i], points[i + 1]];
}
// Full dense geometry = concat of every leg (shared junction not duplicated); keeps ele.
function fullGeom() {
if (points.length < 2) return points.map(p => ({ lat: p.lat, lon: p.lon }));
let out = [{ lat: points[0].lat, lon: points[0].lon }];
for (let i = 0; i < points.length - 1; i++) {
let lp = legPts(i);
for (let k = 1; k < lp.length; k++) out.push({ lat: lp[k].lat, lon: lp[k].lon, ele: lp[k].ele });
}
return out;
}
function updateStats() {
let g = fullGeom(), dist = 0;
for (let i = 1; i < g.length; i++) dist += GPS.distance(g[i-1].lat, g[i-1].lon, g[i].lat, g[i].lon);
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
// Visible route: casing (slope/elev colour) + thin identity line, like the detail view.
function drawDisplay() {
if (displayLayer) { map.removeLayer(displayLayer); displayLayer = null; }
if (points.length >= 2) {
let mode = localStorage.getItem("baldav_route_color_mode") || "slope";
displayLayer = drawRoutePolyline(map, fullGeom(), color, mode);
}
drawDangers();
}
// Mark fords (water) and rough/abandoned tracks the route passes, so the rider sees the
// "ад" BEFORE riding it. Counter in the stats row. Fed by BRouter WayTags per leg.
function drawDangers() {
if (dangerLayer) { map.removeLayer(dangerLayer); dangerLayer = null; }
let all = [];
for (let d of legDangers) if (Array.isArray(d)) all = all.concat(d);
let fords = all.filter(x => x.type === "ford").length;
let wet = all.filter(x => x.type === "wetcross").length;
let rough = all.filter(x => x.type === "rough").length;
let dEl = document.querySelector("#re-danger");
if (dEl) {
let bits = [];
if (fords) bits.push(`брод: ${fords}`);
if (wet) bits.push(`возм.брод: ${wet}`);
if (rough) bits.push(`грунт: ${rough}`);
dEl.textContent = bits.length ? "⚠ " + bits.join(" · ") : "";
}
if (!all.length) return;
// ford = confirmed OSM ford (solid blue); wetcross = likely untagged ford from a high
// BRouter river-class on a track (light cyan — a guess); rough = orange; ferry = purple.
let COL = { ford: "#0288d1", wetcross: "#4dd0e1", rough: "#ff9800", ferry: "#9c27b0" };
let LBL = { ford: "брод", wetcross: "возм. брод", rough: "грунт/тропа", ferry: "паром" };
let g = L.layerGroup();
for (let d of all) {
L.circleMarker([d.lat, d.lon], { radius: 7, color: "#fff", weight: 2, fillColor: COL[d.type] || "#9c27b0", fillOpacity: 1 })
.bindTooltip(LBL[d.type] || d.type, { direction: "top" }).addTo(g);
}
g.addTo(map); dangerLayer = g;
}
// Fat transparent hit target per leg (tap = insert, long-press = toggle road/straight).
// pointer-events:stroke (CSS .re-hit) makes the invisible fat stroke clickable.
function drawHitLines() {
hitLines.forEach(l => map.removeLayer(l)); hitLines = [];
for (let i = 0; i < points.length - 1; i++) {
let line = L.polyline(legPts(i).map(p => [p.lat, p.lon]),
{ color: "#000", weight: 26, opacity: 0, interactive: true, className: "re-hit" }).addTo(map);
(function (idx) {
line.on("click", (e) => { L.DomEvent.stop(e); if (nogoMode) placeNogo(e.latlng); else insertInLeg(idx, e.latlng); }); // tap → insert (or nogo)
line.on("contextmenu", (e) => { L.DomEvent.stop(e); toggleLeg(idx); }); // long-press → road/straight
})(i);
hitLines.push(line);
}
}
function redraw() { drawDisplay(); drawHitLines(); }
function markDirty(i) { if (i >= 0 && i < points.length - 1) dirty.add(i); }
function scheduleRoute() { clearTimeout(routeTimer); routeTimer = setTimeout(routeDirty, 450); }
async function routeDirty() {
let idxs = [...dirty]; dirty.clear();
for (let i of idxs) {
if (i >= points.length - 1 || legMode[i] !== "road") continue;
let a = points[i], b = points[i + 1];
// Prefer already-ridden roads; use BRouter (public OSM) only where there is no ridden path.
let ridden = null;
try { ridden = await rideRoute(a.lat, a.lon, b.lat, b.lon); } catch (e) { /* fall back */ }
if (ridden && ridden.length >= 2) { legGeom[i] = ridden.map(p => ({ lat: p.lat, lon: p.lon })); continue; }
try {
// GeoJSON carries per-segment WayTags → detect fords / rough tracks on this leg.
let gj = await BRouter.routeGeoJson([[a.lon, a.lat], [b.lon, b.lat]], trekProfile(), nogos);
let parsed = GPS.parseRouteGeoJson(gj) || {};
let rp = parsed.points || [];
legGeom[i] = (rp.length >= 2) ? rp.map(p => ({ lat: p.lat, lon: p.lon, ele: p.ele })) : null; // keep ele for slope
legDangers[i] = (rp.length >= 2) ? (parsed.dangers || []) : null;
} catch (e) { legGeom[i] = null; legDangers[i] = null; } // silent fallback to straight
}
redraw(); updateStats();
}
function toggleLeg(i) {
if (i < 0 || i >= points.length - 1) return;
legMode[i] = (legMode[i] === "road") ? "straight" : "road";
if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); scheduleRoute(); }
redraw(); updateStats();
}
function afterEdit() { normalizeLegs(); redraw(); updateStats(); scheduleRoute(); }
function makeMarker(p) {
let m = L.marker([p.lat, p.lon], { draggable: true, icon: dotIcon });
m.on("drag", () => {
let idx = markers.indexOf(m); if (idx < 0) return;
points[idx] = { lat: m.getLatLng().lat, lon: m.getLatLng().lng };
if (idx - 1 >= 0) legGeom[idx - 1] = null;
if (idx < points.length - 1) legGeom[idx] = null;
redraw(); updateStats();
});
m.on("dragend", () => {
let idx = markers.indexOf(m); if (idx < 0) return;
markDirty(idx - 1); markDirty(idx); scheduleRoute();
});
m.on("click", (e) => { L.DomEvent.stop(e); deletePoint(m); });
m.addTo(map);
return m;
}
function addPoint(latlng) {
points.push({ lat: latlng.lat, lon: latlng.lng });
markers.push(makeMarker(points[points.length - 1]));
if (points.length >= 2) { let li = points.length - 2; legMode[li] = defaultMode; legGeom[li] = null; markDirty(li); }
afterEdit();
}
function insertInLeg(legIdx, latlng) {
if (legIdx < 0 || legIdx >= points.length - 1) return;
let mode = legMode[legIdx] ?? defaultMode;
points.splice(legIdx + 1, 0, { lat: latlng.lat, lon: latlng.lng });
markers.splice(legIdx + 1, 0, makeMarker(points[legIdx + 1]));
legMode.splice(legIdx, 1, mode, mode); // one leg → two, same mode
legGeom.splice(legIdx, 1, null, null);
markDirty(legIdx); markDirty(legIdx + 1);
afterEdit();
}
function deletePoint(m) {
let idx = markers.indexOf(m); if (idx < 0) return;
points.splice(idx, 1); markers.splice(idx, 1); map.removeLayer(m);
if (legMode.length) {
if (idx === 0) { legMode.splice(0, 1); legGeom.splice(0, 1); }
else if (idx > points.length - 1) { legMode.splice(idx - 1, 1); legGeom.splice(idx - 1, 1); }
else { let mode = legMode[idx - 1] ?? defaultMode; legMode.splice(idx - 1, 2, mode); legGeom.splice(idx - 1, 2, null); markDirty(idx - 1); }
}
afterEdit();
}
// --- Map controls, reused from the track/detail views ---
addDarkMapControl(map); // day/night tiles
// Slope <-> elevation colour toggle (shared preference); redraws the route.
let colorCtl = L.control({ position: "topleft" });
colorCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "∡" : "⛰";
d.title = "Раскраска маршрута: уклон / высота";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
let nm = (localStorage.getItem("baldav_route_color_mode") || "slope") === "slope" ? "elev" : "slope";
localStorage.setItem("baldav_route_color_mode", nm);
d.innerHTML = nm === "slope" ? "∡" : "⛰";
drawDisplay();
};
return d;
};
colorCtl.addTo(map);
// Locate me
let locCtl = L.control({ position: "topleft" });
locCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = "◎";
d.title = "Моё местоположение";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
navigator.geolocation?.getCurrentPosition(
pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
err => console.error("GPS error:", err.message), { enableHighAccuracy: true, timeout: 10000 });
};
return d;
};
locCtl.addTo(map);
// Nogo (impassable) circles: toggle the ⛔ button, then a map/line tap drops a red circle
// the routing avoids. Persist in DB; every road leg re-routes to go around them.
async function refreshNogos(reroute) {
nogos = await getNogosForRouter();
nogoLayer = await drawNogoAreas(map, nogoLayer, () => refreshNogos(true));
if (reroute) {
for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; markDirty(i); }
redraw(); updateStats(); scheduleRoute();
}
}
async function placeNogo(latlng) {
await DB.addNogoArea(latlng.lat, latlng.lng, 25, null);
await refreshNogos(true);
}
let nogoCtl = L.control({ position: "topleft" });
nogoCtl.onAdd = () => {
let d = L.DomUtil.create("div", "sd-map-fs-btn");
d.innerHTML = "⛔";
d.title = "Пометить непроезжее: тап по карте";
L.DomEvent.disableClickPropagation(d);
d.onclick = (e) => {
e.stopPropagation();
nogoMode = !nogoMode;
d.classList.toggle("active", nogoMode);
showToast(nogoMode ? "Тап по карте — пометить непроезжее" : "Режим отметки выключен");
};
return d;
};
nogoCtl.addTo(map);
refreshNogos(false);
// Existing route: load waypoints. Planner-built → its saved leg modes (re-route road legs
// to get fresh geometry + elevations). Imported/legacy dense → all legs straight.
if (points.length) {
points.forEach(p => markers.push(makeMarker(p)));
for (let i = 0; i < points.length - 1; i++) {
legMode[i] = (wpSaved && wpSaved.legMode && wpSaved.legMode[i]) ? wpSaved.legMode[i] : (wpSaved ? "road" : "straight");
legGeom[i] = null;
if (legMode[i] === "road") markDirty(i);
}
normalizeLegs(); redraw(); updateStats();
map.fitBounds(L.latLngBounds(points.map(p => [p.lat, p.lon])).pad(0.1));
if (dirty.size) scheduleRoute();
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => map.setView([pos.coords.latitude, pos.coords.longitude], 15),
() => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
map.on("click", e => { if (nogoMode) placeNogo(e.latlng); else addPoint(e.latlng); });
document.querySelector("#re-undo").onclick = () => {
if (!points.length) return;
let m = markers.pop(); points.pop(); if (m) map.removeLayer(m);
if (legMode.length) { legMode.pop(); legGeom.pop(); }
afterEdit();
};
document.querySelector("#re-cancel").onclick = () => showRoutesScreen(container);
// Global toggle: set ALL legs (and the default for new ones) to road or straight.
let brBtn = document.querySelector("#re-brouter");
brBtn.onclick = () => {
defaultMode = (defaultMode === "road") ? "straight" : "road";
brBtn.classList.toggle("active", defaultMode === "road");
for (let i = 0; i < legMode.length; i++) { legMode[i] = defaultMode; legGeom[i] = null; if (defaultMode === "road") markDirty(i); }
redraw(); updateStats(); if (defaultMode === "road") scheduleRoute();
};
// Broды/сложные тропы: избегать (default) vs разрешить (adventure). Re-routes all road
// legs with the chosen profile; danger markers show what the route then passes.
let advBtn = document.querySelector("#re-adventure");
let advOn = localStorage.getItem("baldav_allow_adventure") === "1";
let syncAdv = () => { advBtn.textContent = advOn ? "Броды: разрешить" : "Броды: избегать"; advBtn.classList.toggle("active", advOn); };
syncAdv();
advBtn.onclick = () => {
advOn = !advOn;
localStorage.setItem("baldav_allow_adventure", advOn ? "1" : "0");
syncAdv();
for (let i = 0; i < legMode.length; i++) if (legMode[i] === "road") { legGeom[i] = null; legDangers[i] = null; markDirty(i); }
scheduleRoute();
};
document.querySelector("#re-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let btn = document.querySelector("#re-save");
btn.disabled = true; btn.textContent = "Сохранение...";
let dense = fullGeom(); // routed geometry with elevations (for display + slope)
if (dense.length < 2) { showToast("Маршрут пуст"); btn.disabled = false; btn.textContent = "Сохранить"; return; }
// Store the editable waypoints + per-leg mode alongside the dense geometry, so re-editing
// shows the 5-6 points again instead of the thousands of routed points.
let wpOut = { waypoints: points.map(p => ({ lat: p.lat, lon: p.lon })), legMode: legMode.slice() };
if (editing) {
await DB.updateRoutePoints(routeId, dense, wpOut);
showToast("Маршрут обновлён");
showRouteDetail(container, routeId);
} else {
let name = await appPrompt("Название маршрута:", "Мой маршрут", "Сохранить");
if (name == null) { btn.disabled = false; btn.textContent = "Сохранить"; return; }
let id = await DB.addRoute(name.trim() || "Мой маршрут", "#e53935", dense, wpOut);
showToast("Маршрут создан");
showRouteDetail(container, id);
}
};
}, 200);
}
function showDrawRouteScreen(container) {
let points = []; // {lat, lon}
let markers = [];
let polyline = null;
container.innerHTML = `
0 мДистанция
0Точек
`;
setTimeout(() => {
let mapEl = document.querySelector("#draw-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
let map = L.map(mapEl, { attributionControl: false }).setView([55.75, 37.62], 15);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
// Try to center on last known position
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
map.setView([pos.coords.latitude, pos.coords.longitude], 15);
}, () => {}, { enableHighAccuracy: false, timeout: 1000, maximumAge: 5000 });
}
let distEl = document.querySelector("#draw-dist");
let ptsEl = document.querySelector("#draw-pts");
function updateStats() {
let dist = 0;
for (let i = 1; i < points.length; i++) {
dist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
}
distEl.textContent = GPS.formatDistance(dist);
ptsEl.textContent = String(points.length);
}
function updatePolyline() {
let latlngs = points.map(p => [p.lat, p.lon]);
if (polyline) map.removeLayer(polyline);
if (latlngs.length >= 2) {
polyline = L.polyline(latlngs, { color: GPS.config.trackColor || "#bb86fc", weight: 4 }).addTo(map);
}
}
// Map click → add point
map.on("click", e => {
let { lat, lng } = e.latlng;
points.push({ lat, lon: lng });
let marker = L.circleMarker([lat, lng], {
radius: 6, color: GPS.config.trackColor || "#bb86fc", fillColor: GPS.config.trackColor || "#bb86fc", fillOpacity: 1, weight: 2
}).addTo(map);
markers.push(marker);
updatePolyline();
updateStats();
});
// Undo
document.querySelector("#draw-undo").onclick = () => {
if (points.length === 0) return;
points.pop();
let m = markers.pop();
if (m) map.removeLayer(m);
updatePolyline();
updateStats();
};
// Cancel
document.querySelector("#draw-cancel").onclick = () => renderScreen("gps");
// Save
document.querySelector("#draw-save").onclick = async () => {
if (points.length < 2) { showToast("Нужно минимум 2 точки"); return; }
let saveBtn = document.querySelector("#draw-save");
saveBtn.textContent = "Сохранение...";
saveBtn.disabled = true;
let activityType = document.querySelector("#draw-type").value;
// Get elevation for each point
let needApi = [];
for (let p of points) {
let alt = await DB.findNearestAltitude(p.lat, p.lon, 50);
if (alt != null) { p.altitude = alt; }
else { needApi.push(p); }
}
if (needApi.length > 0) {
let elevs = await fetchElevations(needApi);
if (elevs) needApi.forEach((p, i) => { if (elevs[i] != null) p.altitude = elevs[i]; });
}
for (let p of points) { if (p.altitude == null) p.altitude = 0; }
// Build points for DB with realistic timestamps based on activity speed
let avgSpeedMs = (activityType === "bike" ? GPS.config.bikeAvgSpeedKmh
: activityType === "run" ? GPS.config.runAvgSpeedKmh
: GPS.config.walkAvgSpeedKmh) / 3.6;
let now = Date.now();
let cumTime = 0;
let dbPoints = points.map((p, i) => {
if (i > 0) {
let d = GPS.distance(points[i-1].lat, points[i-1].lon, p.lat, p.lon);
cumTime += d / avgSpeedMs; // seconds to cover this segment
}
return {
lat: p.lat, lon: p.lon,
altitude_gps_m: p.altitude,
speed_ms: avgSpeedMs,
ts: new Date(now + cumTime * 1000).toISOString(),
seq: i + 1,
};
});
// Compute stats
let latestW = await DB.getLatestWeight();
let profile = await DB.getFullProfile();
let weightKg = parseFloat(latestW?.weight_kg) || parseFloat(profile?.weight_kg) || 80;
let bike = await DB.getActiveBike();
let opts = { activityType, bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type };
let stats = GPS.computeStats(dbPoints, weightKg, opts);
// Create session
let date = new Date().toISOString().slice(0, 10);
let time = new Date().toTimeString().slice(0, 5);
let session = {
id: now, date, start_time: time, end_time: time,
activity_type: activityType, source: "manual",
total_distance_m: Math.round(stats.distance),
total_kcal: Math.round(stats.kcal),
avg_speed_kmh: stats.avgSpeed,
max_speed_kmh: stats.maxSpeed,
elevation_gain_m: Math.round(stats.elevGain),
elevation_loss_m: Math.round(stats.elevLoss),
bike_id: bike?.id ?? null,
};
await DB.importGpxSession(session, dbPoints);
showToast(`Маршрут сохранён: ${GPS.formatDistance(stats.distance)}, ${Math.round(stats.kcal)} ккал`);
renderScreen("gps");
};
}, 200);
}
// Darken a #rrggbb colour by factor f (0.8 = 20% darker) — for the position puck fill.
function darkenHex(hex, f) {
let m = /^#?([0-9a-fA-F]{6})$/.exec(hex || "");
if (!m) return hex || "#bb86fc";
let n = parseInt(m[1], 16);
let r = Math.round(((n >> 16) & 255) * f), g = Math.round(((n >> 8) & 255) * f), b = Math.round((n & 255) * f);
return "#" + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
}
// SVG "navigation puck": a position circle (track colour, white ring, 1px black
// outer ring so it never blends with the map) + an optional direction triangle
// above it. Circle is centred in the icon so it stays put while the icon rotates
// (leaflet-rotate rotates the icon by heading around the anchor).
function posPuckIcon(fill, withTriangle) {
let tri = withTriangle
? ``
: "";
return L.divIcon({
className: "rec-puck",
html: ``,
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 = `
`;
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 ?? "--"}`);
}
renderScreen("gps"); // back to the tracks list after stop, like a GPS ride (not into details)
};
}
// Send an SMS with the current position and a Yandex Maps pin link. Direct send if the system
// granted SEND_SMS (the in-app confirm is the only tap), otherwise the native side opens the SMS
// app prefilled. Works over 2G/EDGE with no internet — it is a plain text message.
async function sendLocationSms() {
let number = (localStorage.getItem("baldav_sms_number") || "").trim();
if (!number) { appAlert("Сначала укажи номер в профиле"); return; }
let pts = GPS.recording.points || [];
let last = GPS.recording.lastMovingPoint || pts[pts.length - 1];
if (!last || last.lat == null) { appAlert("Нет координат — подожди фикс GPS"); return; }
let lat = (+last.lat).toFixed(5), lon = (+last.lon).toFixed(5);
// Yandex Maps: pt=lon,lat drops a pin at the position. Link ONLY (ASCII, ~54 chars)
// so the whole message is GSM-7 and fits in a single SMS — any Cyrillic would force
// UCS-2 (70-char limit) and risk splitting into two.
let text = `https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map`;
let esc = s => String(s).replace(/ Кому: ${esc(number)}
${esc(text)}`,
"Отправить");
if (!ok) return;
try {
await window.__TAURI__.core.invoke("send_sms", { number, text });
} catch (e) { appAlert("Ошибка отправки: " + e); }
}
// "Отдать смотрящему": hand the viewer the active route + ridden roads + track-so-far. Sent to
// the server's RAM relay (NOT stored on its disk); the viewer pulls it once into their own
// localStorage. One-shot; press again to resend (e.g. after the track grew).
async function shareRideContext() {
let url = (localStorage.getItem("baldav_live_url") || "").trim();
let token = (localStorage.getItem("baldav_live_token") || "").trim();
if (!url || !token) { appAlert("Сначала задай адрес и токен трансляции в профиле"); return; }
let shareUrl = url.endsWith("/push") ? url.slice(0, -5) + "/share" : url.replace(/\/[^/]*$/, "/share");
// Round coords to 5 decimals (~1 m) — enough to draw, and cuts the payload a lot
// (full doubles are ~2x longer). Roads can be big; this keeps the POST reasonable.
let r5 = v => Math.round((+v) * 1e5) / 1e5;
let track = (GPS.recording.points || []).map(p => [r5(p.lat), r5(p.lon)]);
let route = [];
try {
let rid = localStorage.getItem("baldav_active_route");
if (rid) { let r = await DB.getRoute(rid); if (r && r.points) route = r.points.map(p => [r5(p.lat), r5(p.lon)]); }
} catch (e) {}
let roads = [];
try {
let segs = buildRoadSegments(await DB.getRoadPoints());
roads = segs.map(s => ({ p: s.pts.map(q => [r5(q.lat), r5(q.lon)]), t: s.atype || "b" }));
} catch (e) {}
// POI near the ride only (bbox of route+track + margin) — the full set is too big to send.
let pois = [];
try {
let all = await POI.load();
let bpts = track.concat(route);
if (all && all.length && bpts.length) {
let minLa = 1e9, maxLa = -1e9, minLo = 1e9, maxLo = -1e9;
for (let p of bpts) { if (p[0] < minLa) minLa = p[0]; if (p[0] > maxLa) maxLa = p[0]; if (p[1] < minLo) minLo = p[1]; if (p[1] > maxLo) maxLo = p[1]; }
minLa -= 0.05; maxLa += 0.05; minLo -= 0.07; maxLo += 0.07; // ~5 km margin
pois = all.filter(p => p.la >= minLa && p.la <= maxLa && p.lo >= minLo && p.lo <= maxLo)
.map(p => ({ la: r5(p.la), lo: r5(p.lo), c: p.c, n: p.n }));
}
} catch (e) {}
showToast("Отдаю смотрящему...");
try {
let raw = await window.__TAURI__.core.invoke("http_request", {
url: shareUrl, method: "POST", body: JSON.stringify({ token, route, roads, track, pois })
});
let res = JSON.parse(raw || "{}");
if (res.ok) showToast(`Отдано: маршрут ${route.length}, дороги ${roads.length}, трек ${track.length}, POI ${pois.length}`);
else appAlert("Не отдалось: " + (res.error || "ошибка"));
} catch (e) { appAlert("Не отдалось: " + (e.message || e)); }
}
async function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
// Keep screen on while recording is visible
if (navigator.wakeLock) {
navigator.wakeLock.request("screen").then(lock => {
window._screenLock = lock;
}).catch(() => {});
}
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
if (GPS.isHrOnly(GPS.recording.activityType)) { renderWorkoutInline(container); return; }
container.innerHTML = `
0:00—${new Date().toTimeString().slice(0,5)}
${GPS.recording.state === "waiting_gps" ? '
Поиск GPS...
' : ''}
--Пульс
0 мДистанция
0км/ч
—Высота
`;
let mapEl = container.querySelector("#rec-map");
if (!mapEl) return;
L.Icon.Default.imagePath = "lib/";
// Use last known position from profile (reliable across sessions)
let lastPt = GPS.recording.lastMovingPoint;
let initLat = lastPt?.lat || parseFloat(state.profile.last_lat) || 47.47;
let initLon = lastPt?.lon || parseFloat(state.profile.last_lon) || 40.07;
// Remember the recording map's zoom across screen switches (persist on zoom, restore here).
let recZoom = parseInt(localStorage.getItem("baldav_rec_zoom"), 10);
if (!(recZoom >= 1 && recZoom <= 19)) recZoom = 15;
let map = L.map(mapEl, { attributionControl: false, scrollWheelZoom: false, rotate: true, bearing: 0, compassBearing: false }).setView([initLat, initLon], recZoom);
map.on("zoomend", () => { try { localStorage.setItem("baldav_rec_zoom", String(map.getZoom())); } catch {} });
mapEl.addEventListener("wheel", (e) => {
if (e.ctrlKey) { e.preventDefault(); map.setZoom(map.getZoom() + (e.deltaY < 0 ? 1 : -1)); }
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along the ridden route whenever OSM is reachable.
TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon]));
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
let borderLine = L.polyline([], { color: "#ffffff", weight: 8, opacity: 0.8 }).addTo(map);
let trailLine = L.polyline([], { color: GPS.config.trackColor || "#bb86fc", weight: 5 }).addTo(map);
// Map orientation: a mode button picks the source — magnetic compass or GPS course.
// Manual 90° steps (↑) rotate the whole screen on top. Two on-map arrows always show
// BOTH directions (orange = compass, green = GPS course) for visual comparison.
let rotOffset = 0; // 0/90/180/270 — whole-screen rotation
let compassFrozen = false; // X button: freeze current heading
let followMode = true; // ⊕ button: keep the map centred on the current position
let frozenHeading = 0; // heading captured at the moment of freezing
let smoothedHeading = null; // EMA of calibrated compass heading (deg, 0=N, CW)
let courseHeading = null; // EMA of GPS course over ground (deg, 0=N, CW)
let prevCoursePt = null; // for the computed-bearing fallback
let lastPos = null; // last position, for drawing the direction arrows
let orientMode = localStorage.getItem("baldav_orient_mode") || "compass"; // "compass" | "course"
// Compass calibration: magnetometer is offset ~53° here; tap the compass while pointing
// north to subtract the offset (deviceorientationabsolute alpha is CW → raw = alpha).
let northOffset = parseFloat(localStorage.getItem("baldav_north_offset2")) || 0;
let lastRawHeading = 0; // uncalibrated heading, kept for the calibration tap
let recScreen = container.querySelector(".rec-screen");
let btnWrap = mapEl.parentElement;
// Always-on compass needle showing where magnetic NORTH is. Tap = calibrate north.
let compassEl = document.createElement("div");
compassEl.className = "rec-compass";
compassEl.innerHTML = '';
compassEl.title = "Тап = выставить север по текущему направлению";
compassEl.onclick = () => {
northOffset = lastRawHeading;
localStorage.setItem("baldav_north_offset2", String(northOffset));
smoothedHeading = 0; // snap to north-up immediately
applyBearing();
if (typeof showToast === "function") showToast("Север откалиброван");
};
btnWrap.appendChild(compassEl);
// Mini-compass (north needle + tap-to-calibrate) is only meaningful in compass mode.
compassEl.style.display = orientMode === "compass" ? "" : "none";
let compassArrow = compassEl.firstChild;
function updateNorthArrow(bearing) {
compassArrow.style.transform = `rotate(${-bearing}deg)`;
}
// Mode button: compass ("К") ↔ GPS course ("GPS").
let modeBtn = document.createElement("button");
modeBtn.className = "rec-map-btn rec-mode-btn";
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
modeBtn.title = "Поворот карты: компас / GPS-курс";
modeBtn.onclick = () => {
orientMode = orientMode === "compass" ? "course" : "compass";
localStorage.setItem("baldav_orient_mode", orientMode);
modeBtn.textContent = orientMode === "course" ? "GPS" : "К";
compassEl.style.display = orientMode === "compass" ? "" : "none";
applyBearing();
refreshMe(); // switch the direction triangle to the newly active source
};
btnWrap.appendChild(modeBtn);
// Two direction arrows drawn on the map from the current position (always visible):
// Position + direction "puck": ONE marker showing the current spot (circle in the
// track colour) and, when a heading is known, a triangle pointing the ACTIVE source
// direction (compass in compass mode, GPS course in GPS mode). leaflet-rotate's
// rotateWithView keeps the geo-heading correct while the map turns.
let puckFill = darkenHex(GPS.config.trackColor || "#bb86fc", 0.8);
let meMarker = null, meHasTri = false;
// The map already rotates to put the active heading UP (course-up in GPS mode,
// compass-up in compass mode), so travel direction is always screen-up. So the
// puck triangle just points UP — no per-frame rotation. (Computing rotation from
// heading + map bearing desynced and jittered badly.) The icon lives in
// markerPane (norotatePane), which never rotates with the map, so "up" stays up.
function haveHeading() {
return (orientMode === "course" ? courseHeading : smoothedHeading) != null;
}
function updateMe(ll) {
let wantTri = haveHeading();
if (!meMarker) {
meMarker = L.marker(ll, { icon: posPuckIcon(puckFill, wantTri), pane: "markerPane", interactive: false, keyboard: false }).addTo(map);
meHasTri = wantTri;
} else {
meMarker.setLatLng(ll);
if (wantTri !== meHasTri) { meMarker.setIcon(posPuckIcon(puckFill, wantTri)); meHasTri = wantTri; }
}
}
// Show/hide the direction triangle when a heading becomes (un)available.
function refreshMe() {
if (!meMarker) return;
let wantTri = haveHeading();
if (wantTri !== meHasTri) { meMarker.setIcon(posPuckIcon(puckFill, wantTri)); meHasTri = wantTri; }
}
// Base heading = chosen source. GPS mode rotates by course when we have it, else falls
// back to the compass so the map ALWAYS rotates to a known-good heading (never static).
function baseHeading() {
// Course-up: setBearing puts geo-dir (360-theta) up and net rotation = h, so to put
// courseHeading at the top we need h = 360 - courseHeading (derived from leaflet-rotate).
if (orientMode === "course" && courseHeading != null) return (360 - courseHeading + 360) % 360;
return smoothedHeading != null ? smoothedHeading : 0;
}
function applyBearing() {
let h = compassFrozen ? frozenHeading : baseHeading();
// Do NOT subtract rotOffset. The whole .rec-screen (map + panel) is CSS-rotated
// by rotOffset in landscape; setBearing adds another CSS rotate on the map's
// rotatePane. Subtracting rotOffset cancelled the screen rotation FOR THE MAP,
// so the map stayed portrait-oriented (course glued to the narrow top edge)
// while the panel rotated. Using h lets the map turn with the screen like the
// panel → course points to the physical top. Portrait (rotOffset=0): h-0=h.
let bearing = h;
map.setBearing(bearing);
updateNorthArrow(bearing);
}
// Update GPS course from a point. Prefer the chip's course-over-ground (p.bearing);
// fall back to bearing between positions ≥15 m apart (long baseline = less jitter).
function updateCourse(p) {
if (!p) return;
lastPos = { lat: p.lat, lon: p.lon };
let b = null;
if (p.bearing != null && (p.speed_ms == null || p.speed_ms > 1.0)) {
b = p.bearing;
} else if (prevCoursePt && GPS.distance(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon) >= 15) {
b = GPS.bearing(prevCoursePt.lat, prevCoursePt.lon, p.lat, p.lon);
prevCoursePt = { lat: p.lat, lon: p.lon };
}
if (!prevCoursePt) prevCoursePt = { lat: p.lat, lon: p.lon };
if (b != null) {
if (courseHeading === null) courseHeading = b;
else {
let diff = b - courseHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
courseHeading = ((courseHeading + diff * 0.4) % 360 + 360) % 360;
}
if (orientMode === "course" && !compassFrozen) applyBearing();
}
refreshMe();
}
function applyScreenRotation() {
if (recScreen) {
recScreen.classList.remove("rot-90", "rot-270");
if (rotOffset) recScreen.classList.add("rot-" + rotOffset);
}
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = rotOffset ? "none" : "";
map.invalidateSize();
}
// Rotation button — 4 states, each tap +90°. Arrow shows chosen "up".
let rotBtn = document.createElement("button");
rotBtn.className = "rec-map-btn rec-rotate-btn";
rotBtn.innerHTML = "↑"; // ↑
rotBtn.title = "Повернуть карту на 90°";
rotBtn.onclick = () => {
// 3 readable states: portrait + both landscapes. 180° is skipped —
// it would flip the panel text upside down ("низ" would end up on top).
rotOffset = rotOffset === 0 ? 90 : (rotOffset === 90 ? 270 : 0);
applyScreenRotation();
applyBearing();
};
btnWrap.appendChild(rotBtn);
// Freeze button — X: lock/unlock compass following (does NOT rotate)
let freezeBtn = document.createElement("button");
freezeBtn.className = "rec-map-btn rec-freeze-btn";
freezeBtn.textContent = "X";
freezeBtn.title = "Заморозить поворот карты";
freezeBtn.onclick = () => {
compassFrozen = !compassFrozen;
if (compassFrozen) frozenHeading = baseHeading();
freezeBtn.classList.toggle("active", compassFrozen);
applyBearing();
};
btnWrap.appendChild(freezeBtn);
// Follow button — ⊕: toggle whether the map keeps re-centring on the current position.
// Off = free pan (look around when lost / off-route); tapping it back on recentres now.
let followBtn = document.createElement("button");
followBtn.className = "rec-map-btn rec-follow-btn active";
followBtn.textContent = "⊕"; // ⊕
followBtn.title = "Следить за позицией";
followBtn.onclick = () => {
followMode = !followMode;
followBtn.classList.toggle("active", followMode);
if (followMode) {
// Re-enabling: jump straight back to where we are now.
let pts = GPS.recording.points;
if (pts && pts.length) {
let last = pts.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts))[pts.length - 1];
if (last) map.panTo([last.lat, last.lon]);
}
}
};
btnWrap.appendChild(followBtn);
// Left-side group (bottom-left, opposite the right controls): live-location toggle + SMS.
// Live sharing can be switched on/off right here even if it was never set in the profile.
let liveBtn = document.createElement("button");
liveBtn.className = "rec-map-btn rec-live-btn";
liveBtn.textContent = "LIVE";
liveBtn.title = "Трансляция местоположения вкл/выкл";
let liveOn = localStorage.getItem("baldav_live_on") === "1";
liveBtn.classList.toggle("active", liveOn);
liveBtn.onclick = async () => {
liveOn = !liveOn;
localStorage.setItem("baldav_live_on", liveOn ? "1" : "0");
liveBtn.classList.toggle("active", liveOn);
let url = localStorage.getItem("baldav_live_url") || "https://4aiman.top/api/trackme/push";
let token = localStorage.getItem("baldav_live_token") || "";
try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url, token }); }
catch (e) { console.error("live toggle", e); }
if (liveOn && !token) appAlert("Трансляция включена, но токен не задан в профиле - точки не уйдут, пока не впишешь токен.");
};
btnWrap.appendChild(liveBtn);
// SMS "я тут" button
let smsBtn = document.createElement("button");
smsBtn.className = "rec-map-btn rec-sms-btn";
smsBtn.textContent = "СМС";
smsBtn.title = "Отправить СМС с местоположением";
smsBtn.onclick = () => sendLocationSms();
btnWrap.appendChild(smsBtn);
// Hand the viewer the route + ridden roads + track (via the server's RAM relay, not its disk).
let shareBtn = document.createElement("button");
shareBtn.className = "rec-map-btn rec-share-btn";
shareBtn.textContent = "Отд";
shareBtn.title = "Отдать смотрящему: маршрут, проезженные дороги и пройденный трек";
shareBtn.onclick = () => shareRideContext();
btnWrap.appendChild(shareBtn);
// Roads toggle button. Roads are viewport-culled onto a canvas pane (only the segments in
// view are drawn, redrawn on pan/zoom) so a huge road network doesn't jank the WebView.
let roadsBtn = document.createElement("button");
roadsBtn.className = "rec-map-btn rec-roads-toggle";
roadsBtn.textContent = "R";
let roadLayers = [];
let roadSegs = null, roadsOn = false, roadsRenderer = null;
let redrawRoads = () => { if (roadsOn && roadSegs) cullRoadsInto(map, roadSegs, roadLayers, 0.2, { pane: "roadsPane", renderer: roadsRenderer }); };
let roadsMove = debounce(redrawRoads, 120);
roadsBtn.onclick = async () => {
if (roadsOn) {
roadsOn = false;
map.off("moveend zoomend", roadsMove);
roadLayers.forEach(l => map.removeLayer(l)); roadLayers.length = 0;
roadsBtn.classList.remove("active");
localStorage.setItem("baldav_show_roads", "0");
return;
}
roadsOn = true;
roadsBtn.classList.add("active");
localStorage.setItem("baldav_show_roads", "1");
// Roads live in their own pane BELOW route/track: tiles(200) setRouteShown(!routeLayer);
btnWrap.appendChild(routeBtn);
// Dark-map toggle (moon): invert the tile layer only, remembered globally.
let darkBtn = document.createElement("button");
darkBtn.className = "rec-map-btn rec-dark-toggle" + (isDarkMap() ? " active" : "");
darkBtn.textContent = "☾";
darkBtn.title = "Тёмная карта";
darkBtn.onclick = () => toggleDarkMap();
btnWrap.appendChild(darkBtn);
// Nogo: mark the current spot impassable while riding (the planner + on-the-fly reroute
// will avoid it). Shows existing nogo circles too.
let recNogoLayer = null;
async function refreshRecNogos() { recNogoLayer = await drawNogoAreas(map, recNogoLayer, refreshRecNogos); }
refreshRecNogos();
let recNogoBtn = document.createElement("button");
recNogoBtn.className = "rec-map-btn rec-nogo-btn";
recNogoBtn.textContent = "⛔";
recNogoBtn.title = "Здесь нет проезда (отметить текущее место)";
recNogoBtn.onclick = async () => {
let p = GPS.recording.lastMovingPoint;
if (!p) { showToast("Нет позиции"); return; }
await DB.addNogoArea(p.lat, p.lon, 25, null);
await refreshRecNogos();
showToast("Отмечено: нет проезда");
};
btnWrap.appendChild(recNogoBtn);
// Reroute back onto the active route past a blockage (nogo), from the current position,
// avoiding nogos and 180° turns. Transient navigation overlay — does NOT touch the saved
// route or the recording. Tap again to rebuild from the new position; long-press to clear.
let rerouteLayer = null;
let rerouteBtn = document.createElement("button");
rerouteBtn.className = "rec-map-btn rec-reroute-btn";
rerouteBtn.textContent = "↪";
rerouteBtn.title = "Объехать: вернуться на маршрут за непроезжим местом";
rerouteBtn.onclick = async () => {
let rid = localStorage.getItem("baldav_active_route");
if (!rid) { showToast("Нет активного маршрута"); return; }
let cur = GPS.recording.lastMovingPoint;
if (!cur) { showToast("Нет позиции"); return; }
rerouteBtn.disabled = true; rerouteBtn.classList.add("active");
try {
let route = await DB.getRoute(rid);
if (!route || !route.points || route.points.length < 2) { showToast("Маршрут пуст"); return; }
let ng = await getNogosForRouter();
let bearing = (typeof smoothedHeading === "number") ? smoothedHeading : null;
let res = await rejoinReroute(cur, bearing, route.points, ng);
if (res && res.noBlockage) { showToast("Впереди затыка нет (нет nogo на маршруте)"); rerouteBtn.classList.remove("active"); return; }
if (!res) { showToast("Объезд не найден"); rerouteBtn.classList.remove("active"); return; }
if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; }
let ll = res.points.map(p => [p.lat, p.lon]);
let g = L.layerGroup();
L.polyline(ll, { color: "#000", weight: 9, opacity: 0.9 }).addTo(g);
L.polyline(ll, { color: "#fff", weight: 6, opacity: 0.9 }).addTo(g);
L.polyline(ll, { color: "#ff00ff", weight: 3, opacity: 1 }).addTo(g);
L.circleMarker([res.target.lat, res.target.lon], { radius: 7, color: "#ff00ff", fillColor: "#fff", fillOpacity: 1, weight: 3 }).addTo(g);
g.addTo(map); rerouteLayer = g;
showToast("Объезд построен");
} catch (e) { showToast("Объезд: " + (e.message || e)); }
finally { rerouteBtn.disabled = false; }
};
rerouteBtn.oncontextmenu = (e) => { e.preventDefault(); if (rerouteLayer) { map.removeLayer(rerouteLayer); rerouteLayer = null; rerouteBtn.classList.remove("active"); showToast("Объезд убран"); } };
btnWrap.appendChild(rerouteBtn);
POI.attach(map); // POI overlay + category filter on the recording map
// The default-ON draw is deferred to the END of this function (after all the
// critical wiring), where it awaits whenMapReady — so it can't delay setup.
function handleOrientation(e) {
let raw;
if (e.webkitCompassHeading != null) raw = e.webkitCompassHeading; // iOS: true heading
else if (e.alpha != null) raw = e.alpha % 360; // this device: alpha is CW from N (not CCW) — no inversion
else return;
lastRawHeading = raw; // for the calibration tap
let heading = (raw - northOffset + 360) % 360; // subtract the fixed magnetometer offset
// EMA smoothing to reduce needle jitter (factor 0.3).
if (smoothedHeading === null) smoothedHeading = heading;
else {
let diff = heading - smoothedHeading;
if (diff > 180) diff -= 360;
if (diff < -180) diff += 360;
smoothedHeading = (smoothedHeading + diff * 0.3 + 360) % 360;
}
// DEBUG overlay: raw sensor values on screen (to verify calibration).
// Only in debug builds — window.__BALDAV_DEBUG is set in init() from
// get_app_info().debug (cfg!(debug_assertions)); silent in release.
if (window.__BALDAV_DEBUG) {
let dbg = document.getElementById("orient-dbg");
if (!dbg) {
dbg = document.createElement("div");
dbg.id = "orient-dbg";
dbg.style.cssText = "position:absolute;bottom:4px;left:4px;z-index:1002;background:rgba(0,0,0,0.85);color:#0f0;font:11px monospace;padding:3px 5px;border-radius:4px;pointer-events:none;white-space:nowrap;";
mapEl.parentElement.appendChild(dbg);
}
dbg.textContent = "a=" + Math.round(e.alpha || 0) + " raw=" + Math.round(raw) + " off=" + Math.round(northOffset) + " h=" + Math.round(heading) + " sm=" + Math.round(smoothedHeading);
}
refreshMe(); // update the direction triangle from the live compass heading
if (!compassFrozen) applyBearing();
}
let throttledOrientation = L.Util.throttle(handleOrientation, 100);
let orientEvent = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
window.addEventListener(orientEvent, throttledOrientation);
applyBearing(); // initial: north-up until first compass reading
GPS.recording._compassCleanup = () => {
window.removeEventListener(orientEvent, throttledOrientation);
if (recScreen) recScreen.classList.remove("rot-90", "rot-270");
let nav = document.querySelector(".bottom-nav");
if (nav) nav.style.display = "";
};
// State changes
GPS.recording.onStateChange = (state, p) => {
if (state === "ready") {
let ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
statusEl.textContent = "GPS OK. Начните движение...";
statusEl.style.color = "#03dac6";
if (followMode) map.setView([p.lat, p.lon], 16);
} else if (state === "recording") {
statusEl.textContent = "";
statusEl.style.color = "";
} else if (state === "resumed") {
statusEl.textContent = "";
statusEl.style.color = "";
}
};
// Live point updates
let distEl = document.querySelector("#rec-dist");
let spdEl = document.querySelector("#rec-speed");
let timeEl = document.querySelector("#rec-time");
let elevEl = document.querySelector("#rec-elev");
let statusEl = { style: {} }; // status line removed from the UI; keep a no-op sink so the existing statusEl.* writes don't crash
// Speed buffer for median
let speedBuf = [];
GPS.recording.onPoint = (p) => {
let ph = document.getElementById('gps-placeholder');
if (ph) ph.remove();
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
if (followMode) map.panTo(ll);
// Save position for next session start (avoid black screen)
DB.setProfileValue("last_lat", String(p.lat));
DB.setProfileValue("last_lon", String(p.lon));
updateMe(ll);
// Stats
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
// Show current state
let recState = GPS.recording.state;
if (recState === "waiting_gps") {
statusEl.textContent = "Поиск GPS...";
statusEl.style.color = "#ffb74d";
if (followMode) map.setView([p.lat, p.lon], 15);
return;
}
if (recState === "ready") {
updateMe([p.lat, p.lon]); // position only (no heading yet)
return;
}
// Orient map by direction of movement (GPS course) + update direction arrows
updateCourse(p);
// Speed
let curSpeed = (p.speed_ms != null && p.speed_ms >= 0) ? p.speed_ms * 3.6 : 0;
speedBuf.push(curSpeed);
if (speedBuf.length > 5) speedBuf.shift();
let sorted = speedBuf.slice().sort((a, b) => a - b);
let median = sorted[Math.floor(sorted.length / 2)] || 0;
if (spdEl) spdEl.textContent = median.toFixed(1);
// Elevation - prefer the live barometer (smoother, tracks real climbs) over noisy GPS altitude
let baroAlt = (window.BARO && BARO.isLive()) ? BARO.altitude() : null;
let alt = (baroAlt != null) ? baroAlt : p.altitude;
if (alt != null && elevEl) elevEl.textContent = Math.round(alt) + (baroAlt != null ? " м·" : " м");
// 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);
// Barometer readout at 1 Hz, independent of GPS fixes (so altitude/temp move smoothly).
if (window.BARO && BARO.isLive()) {
let ba = BARO.altitude();
if (ba != null && elevEl) elevEl.textContent = Math.round(ba) + " м·"; // trailing dot = from the barometer
let tc = BARO.state ? BARO.state.lastTempC : null;
let tEl = document.querySelector("#rec-temp");
if (tc != null && tEl) tEl.textContent = tc.toFixed(1) + "°"; // one decimal: visibly live + has the dot
}
}, 1000);
// Fetch temperature — try multiple APIs (mobile internet blocks foreign services)
async function fetchTemperature() {
let pos = GPS.recording.lastMovingPoint || { lat: parseFloat(state.profile.last_lat) || 47.42, lon: parseFloat(state.profile.last_lon) || 40.08 };
let lat = pos.lat.toFixed(2), lon = pos.lon.toFixed(2);
let invoke = window.__TAURI__.core.invoke;
let apis = [
// Yandex Pogoda — works on Russian mobile internet, scrape HTML
{ url: `https://yandex.ru/pogoda/?lat=${lat}&lon=${lon}`,
parse: r => { let m = r.match(/FactTemperature_value[^>]*>(-?\d+)/); return m ? parseInt(m[1]) : null; } },
// open-meteo — free API, may be blocked on mobile
{ url: `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m`,
parse: r => { try { return JSON.parse(r).current?.temperature_2m; } catch { return null; } } },
// wttr.in — free, may be blocked on mobile
{ url: `https://wttr.in/${lat},${lon}?format=%t`,
parse: r => { let n = parseFloat(r); return isNaN(n) ? null : n; } },
];
for (let api of apis) {
try {
let resp = await invoke("http_request", { url: api.url, method: "GET", body: null });
let temp = api.parse(resp);
if (temp != null && !isNaN(temp)) {
let el = document.querySelector("#rec-temp");
if (el) el.textContent = Math.round(temp) + "°";
return;
}
} catch {}
}
}
fetchTemperature();
let weatherTimer = setInterval(fetchTemperature, 15 * 60 * 1000);
// Headwind label toggle: tap to open a "wind in the face" interval, tap to close.
// Marks are ground truth for validating the from-track wind estimate — not kcal.
// HR on/off toggle (replaces the old "Ветер" button). ON connects the paired strap + polls;
// OFF disconnects. Grey = off, coloured = on.
let hrToggle = container.querySelector("#rec-hr-toggle");
if (hrToggle) hrToggle.onclick = async () => {
hrToggle.disabled = true;
await GPS.setHrOn(!GPS.recording.hrOn, true); // user tap → may ask OS to enable BT (once)
hrToggle.classList.toggle("active", !!GPS.recording.hrOn);
if (!GPS.recording.hrOn) { let n = container.querySelector("#rec-hr-num"); if (n) n.textContent = ""; setHrBatteryFill(hrToggle, null); } // off -> icon only, no fill
hrToggle.disabled = false;
};
// Pause button
container.querySelector("#rec-pause").onclick = () => {
if (GPS.recording.manualPaused) {
GPS.resumeRecording();
container.querySelector("#rec-pause").textContent = "Пауза";
statusEl.textContent = "";
statusEl.style.color = "";
} else {
GPS.pauseRecording();
container.querySelector("#rec-pause").textContent = "Продолжить";
statusEl.textContent = "Пауза (ручная)";
statusEl.style.color = "#ffb74d";
}
};
// Stop button
container.querySelector("#rec-stop").onclick = async () => {
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
clearInterval(timer);
renderScreen("gps");
};
// Restore trail if already recording — sort by seq/ts to avoid jumps
if (GPS.recording.points.length > 0) {
let sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
let coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
map.fitBounds(trailLine.getBounds().pad(0.1));
}
// Batch update callback — called after poll reads multiple points at once.
// Rebuilds polyline from all sorted points instead of adding one-by-one.
GPS.recording.onBatchUpdate = () => {
let sorted = GPS.recording.points.slice().sort((a, b) => {
return new Date(a.ts) - new Date(b.ts);
});
let coords = sorted.map(p => [p.lat, p.lon]);
borderLine.setLatLngs(coords);
trailLine.setLatLngs(coords);
if (coords.length) {
let last = coords[coords.length - 1];
if (followMode) map.panTo(last);
updateCourse(sorted[sorted.length - 1]); // course from the last point object (has bearing)
updateMe(last);
}
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
};
// Lightweight per-point draw for progressive import (screen-wake catch-up):
// only extends the polyline so the track visibly grows. Heavy work (stats,
// marker, course) is done periodically + at the end by onBatchUpdate.
GPS.recording.onImportPoint = (p) => {
let ll = [p.lat, p.lon];
borderLine.addLatLng(ll);
trailLine.addLatLng(ll);
};
// Finally: if a route is selected (and not toggled off), draw it once the map is
// ready. Done LAST so awaiting map-load never delays the compass/handlers/buttons.
if (localStorage.getItem("baldav_active_route") && localStorage.getItem("baldav_route_shown") !== "0") {
await whenMapReady(map);
await setRouteShown(true);
}
}
function setupLiveUpdates(container) {
let distEl = container.querySelector("#gs-dist");
let spdEl = container.querySelector("#gs-speed");
let timeEl = container.querySelector("#gs-time");
let timer = null;
GPS.recording.onPoint = (p) => {
if (!gpsTrailLine || !gpsMap) return;
gpsTrailLine.addLatLng([p.lat, p.lon]);
gpsMap.panTo([p.lat, p.lon]);
let pts = GPS.recording.points;
let stats = GPS.computeStats(pts, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) {
let curSpd = p.speed_ms != null ? (p.speed_ms * 3.6).toFixed(1) : stats.avgSpeed;
spdEl.textContent = curSpd + " км/ч";
}
};
GPS.recording.onBatchUpdate = () => {
if (!gpsTrailLine || !gpsMap) return;
let sorted = GPS.recording.points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
let coords = sorted.map(p => [p.lat, p.lon]);
gpsTrailLine.setLatLngs(coords);
if (coords.length) gpsMap.panTo(coords[coords.length - 1]);
let stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, GPS.getRecordingOpts());
if (distEl) distEl.textContent = GPS.formatDistance(stats.distance);
if (spdEl) spdEl.textContent = stats.avgSpeed + " км/ч";
};
// Lightweight per-point draw for progressive import (see recording screen).
GPS.recording.onImportPoint = (p) => {
if (!gpsTrailLine) return;
gpsTrailLine.addLatLng([p.lat, p.lon]);
};
// Timer for duration display + auto-finish watchdog for a forgotten session
timer = setInterval(async () => {
if (!GPS.recording.active) { clearInterval(timer); return; }
if (GPS.shouldAutoFinish()) {
clearInterval(timer);
if (GPS.recording._compassCleanup) GPS.recording._compassCleanup();
await GPS.stopRecording();
showToast("Тренировка завершена автоматически — нет движения");
renderScreen("gps");
return;
}
let sec = Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000);
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
}, 1000);
}
// ---------------------------------------------------------------------------
// Map download modal — pick region + zoom range
// ---------------------------------------------------------------------------
let MAP_REGIONS = [
{ name: "Текущий вид карты", bounds: null },
{ name: "--- Города ---", bounds: null, disabled: true },
{ name: "Москва", bounds: [[55.55, 37.35], [55.95, 37.85]] },
{ name: "Санкт-Петербург", bounds: [[59.80, 30.05], [60.10, 30.55]] },
{ name: "Ростов-на-Дону", bounds: [[47.15, 39.55], [47.35, 39.85]] },
{ name: "Мой регион", bounds: [[47.35, 39.95], [47.52, 40.22]] },
{ name: "Краснодар", bounds: [[44.95, 38.85], [45.15, 39.15]] },
{ name: "Казань", bounds: [[55.70, 49.00], [55.90, 49.30]] },
{ name: "Новосибирск", bounds: [[54.90, 82.75], [55.10, 83.15]] },
{ name: "Екатеринбург", bounds: [[56.75, 60.45], [56.95, 60.75]] },
{ name: "--- Области ---", bounds: null, disabled: true },
{ name: "Ростовская область", bounds: [[45.85, 38.20], [50.25, 44.30]] },
{ name: "Московская область", bounds: [[54.25, 35.15], [56.95, 40.20]] },
{ name: "Краснодарский край", bounds: [[43.50, 36.50], [46.80, 41.80]] },
{ name: "Ленинградская область", bounds: [[58.40, 27.80], [61.30, 35.70]] },
];
function showMapDownloadModal() {
let regionOpts = MAP_REGIONS.map((r, i) =>
r.disabled
? `
${r.name.replace(/---? ?/g, "")}
`
: `
${r.name}
`
).join("");
showModal(`
Скачать карту
${regionOpts}
`,
async overlay => {
let sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return false;
let idx = parseInt(sel.dataset.idx);
let region = MAP_REGIONS[idx];
let zMin = parseInt(overlay.querySelector("#m-zmin").value);
let zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) {
bounds = L.latLngBounds(region.bounds);
} else if (gpsMap) {
bounds = gpsMap.getBounds();
} else return false;
let total = TileCache.countTilesForArea(bounds, zMin, zMax);
let okBtn = overlay.querySelector("#modal-ok");
okBtn.textContent = `0 / ${total}`;
okBtn.disabled = true;
await TileCache.downloadArea(bounds, zMin, zMax, (done, tot) => {
okBtn.textContent = `${done} / ${tot}`;
});
okBtn.textContent = "Готово";
okBtn.disabled = false;
});
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
let countEl = overlay.querySelector("#m-tile-count");
// Selection
overlay.querySelectorAll(".supp-pick-item").forEach(item => {
item.onclick = () => {
overlay.querySelectorAll(".supp-pick-item").forEach(i => i.classList.remove("selected"));
item.classList.add("selected");
updateTileCount();
};
});
function updateTileCount() {
let sel = overlay.querySelector(".supp-pick-item.selected");
if (!sel) return;
let idx = parseInt(sel.dataset.idx);
let region = MAP_REGIONS[idx];
let zMin = parseInt(overlay.querySelector("#m-zmin").value);
let zMax = parseInt(overlay.querySelector("#m-zmax").value);
let bounds;
if (region.bounds) bounds = L.latLngBounds(region.bounds);
else if (gpsMap) bounds = gpsMap.getBounds();
else return;
let total = TileCache.countTilesForArea(bounds, zMin, zMax);
countEl.textContent = `~${total} тайлов`;
}
overlay.querySelector("#m-zmin").oninput = updateTileCount;
overlay.querySelector("#m-zmax").oninput = updateTileCount;
}
// ---------------------------------------------------------------------------
// Session detail view
// ---------------------------------------------------------------------------
let _sessionDetailRender = 0;
// Remember each track's last map view (centre+zoom) so a recompute/refine reopen — or coming
// back to a track you looked at — restores where you were instead of re-fitting the whole track.
let _sdMapView = {};
async function showSessionDetail(sessionId) {
let renderToken = ++_sessionDetailRender;
try {
console.log("[SD] loading session", sessionId);
let session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
// HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path.
if (GPS.isHrOnly(session.activity_type)) { renderWorkoutDetail(session); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");
let points = GPS.filterNoise(rawPoints, session.activity_type);
// If this track has a saved smoothed (de-jittered) variant active, display it —
// rawPoints keeps the original lat/lon (for re-tuning / revert).
if (session.smoothed) points = points.map(p => (p.lat_smoothed != null ? { ...p, lat: p.lat_smoothed, lon: p.lon_smoothed } : p));
console.log("[SD] filtered to", points.length, "points");
// Use stored session stats if available (computed correctly at import time),
// fallback to recomputing from points
let storedStats = session.total_distance_m != null;
let stats = storedStats ? {
distance: Math.round(session.total_distance_m || 0),
avgSpeed: session.avg_speed_kmh || 0,
maxSpeed: session.max_speed_kmh || 0,
kcal: Math.round(session.total_kcal || 0),
elevGain: Math.round(session.elevation_gain_m || 0),
elevLoss: Math.round(session.elevation_loss_m || 0),
duration: 0,
} : GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
// Manual-pause total (ms) to exclude from displayed duration
let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0);
// Compute duration from points regardless (minus manual pauses)
if (points.length >= 2) {
stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000));
}
// Always compute segments from points (needed for the Segments section)
if (!stats.segments && points.length >= 3) {
let fullStats = GPS.computeStats(points, session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] });
stats.segments = fullStats.segments;
}
// Speed: prefer device-reported speed (Doppler, smooth), fallback to position-based
let rawSpeeds = [];
for (let i = 1; i < points.length; i++) {
let devSpd = points[i].speed_ms;
if (devSpd != null && devSpd >= 0) {
rawSpeeds.push(devSpd * 3.6);
} else {
let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
rawSpeeds.push(dt > 0 ? (d / dt) * 3.6 : 0);
}
}
// 31-point moving average — kills GPS jitter from phone-in-pocket
let HALF_WIN = 15;
let smoothed = rawSpeeds.map((_, i) => {
let sum = 0, count = 0;
for (let j = Math.max(0, i - HALF_WIN); j <= Math.min(rawSpeeds.length - 1, i + HALF_WIN); j++) {
sum += rawSpeeds[j]; count++;
}
return Math.round(sum / count * 10) / 10;
});
// maxSpeed from smoothed graph (not raw)
let graphMaxSpeed = smoothed.length ? Math.max(...smoothed) : 0;
// Elevation data
// Prefer refined terrain altitude (srtm, written by "Уточнить высоты") over raw
// GPS altitude — same priority computeStats uses, so the chart matches the stats.
let elevations = GPS.filterAndSmoothAlt(points.map(p => p.altitude_srtm_m ?? p.altitude_gps_m));
let minElev = elevations.length ? Math.round(Math.min(...elevations)) : null;
let maxElev = elevations.length ? Math.round(Math.max(...elevations)) : null;
// Time labels (minutes from start)
let t0 = points.length ? new Date(points[0].ts).getTime() : 0;
let timeLabels = points.slice(1).map(p => ((new Date(p.ts).getTime() - t0) / 60000).toFixed(1));
let actNames = { bike: "Велосипед", walk: "Ходьба", run: "Бег", workout: "Зарядка", vr: "Руки/VR", other: "Другое" };
let actName = actNames[session.activity_type] || session.activity_type || "Тренировка";
let duration = session.end_time && session.start_time
? GPS.formatDuration(stats.duration)
: "—";
let tempo = stats.duration > 0 && stats.distance > 0
? GPS.formatDuration(Math.round(stats.duration / (stats.distance / 1000))) + " мин/км"
: "—";
let kcalMin = stats.duration > 0 ? (stats.kcal / (stats.duration / 60)).toFixed(1) : "—";
// HR-based kcal "what if" (Keytel) — comparison ONLY. The activity balance keeps the physics/MET
// number (don't disturb the established diet); here we just show what HR would give, if present.
let _t0hr = points.length ? new Date(points[0].ts).getTime() : 0;
let hrSamples = points.filter(p => p.heart_rate > 0).map(p => [(new Date(p.ts).getTime() - _t0hr) / 1000, p.heart_rate]);
let hasHr = hrSamples.length > 2;
let hrProfile = {
weightKg: session.rider_weight_kg ?? state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 75,
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
sex: state.profile?.sex || "m",
heightCm: parseFloat(state.profile?.height_cm) || 178,
};
let hrKcal = hasHr ? GPS.hrKcalNet(hrSamples, hrProfile) : null;
let hrKcalCalib = hasHr ? Math.round(hrKcal * (GPS.config.hrCalibFactor ?? 0.65)) : null; // грубая справочная прикидка
let hrAvg = hasHr ? Math.round(hrSamples.reduce((a, s) => a + s[1], 0) / hrSamples.length) : null;
let hrMax = hasHr ? Math.max(...hrSamples.map(s => s[1])) : null;
showModal(`
${actName}
ID: ${session.id}
Время
${session.date}Дата
${duration}Длительность
${session.start_time ?? "—"}Начало
${session.end_time ?? "—"}Конец
${GPS.formatDistance(stats.distance)}Расстояние
${tempo}Темп
Маршрут
Скорость
${stats.avgSpeed} км/чСредняя
${stats.maxSpeed} км/чМакс.
Калории
${session.total_kcal ?? stats.kcal} ккал${hasHr ? "Физика (в баланс)" : "Всего"}
${kcalMin} ккал/минРасход
${hasHr ? `
${hrKcal} ккалПо ЧСС (Keytel)
${hrKcalCalib} ккалЧСС откалибр. (грубо)
В баланс идёт «физика». По ЧСС (Keytel) завышает; «откалибр.» = Keytel x ${(GPS.config.hrCalibFactor ?? 0.65)} - грубая прикидка для сравнения.
`;
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
container.querySelector("#export-supps-btn").onclick = async () => {
let supps = await DB.getSupplements();
downloadFile("baldav-supplements.json", JSON.stringify(supps, null, 2), "application/json");
};
container.querySelector("#import-supps-btn").onclick = () => {
pickFile(".json", async (text) => {
let data = JSON.parse(text);
await importSupplements(data);
renderSupplements(container);
});
};
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
let sid = parseInt(card.dataset.sid);
let s = await DB.getSupplement(sid);
if (s) showEditSupplementModal(s);
};
});
}
function showEditSupplementModal(existing) {
let isNew = !existing;
let s = existing || {};
let microHtml = MICRO_FIELDS.map(f => {
let displayVal = s[f.key] ?? "";
if (displayVal && f.mult) displayVal = Math.round(displayVal / f.mult * 1000) / 1000; // mcg→mg for display
return `
`;
}
).join("");
showModal(`
${isNew ? "Новая добавка" : "Редактирование"}
Содержание в 1 таблетке:
${microHtml}
${!isNew ? `` : ""}`,
async overlay => {
let name = overlay.querySelector("#m-name").value.trim();
if (!name) return false;
let data = { name, daily_dose_g: parseFloat(overlay.querySelector("#m-dose").value) || null };
for (let f of MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#m-${f.key}`).value) || null;
if (val && f.mult) val *= f.mult; // convert mg→mcg where needed
data[f.key] = val;
}
if (isNew) {
await DB.addSupplement(data);
} else {
// Update: delete + re-add (simple, keeps log intact via supplement_id)
// Actually need an update function. For now, delete logs referencing old id is bad.
// Just delete and re-add — log entries will be orphaned. Better to add updateSupplement.
// Quick approach: raw execute
let sets = ["name=?1", "daily_dose_g=?2"];
let vals = [name, data.daily_dose_g];
let idx = 3;
for (let f of MICRO_FIELDS) {
sets.push(`${f.key}=?${idx}`);
vals.push(data[f.key]);
idx++;
}
vals.push(existing.id);
await window.__TAURI__.core.invoke("plugin:sql|execute", {
db: "sqlite:baldav.db",
query: `UPDATE supplements SET ${sets.join(",")} WHERE id=?${idx}`,
values: vals,
});
}
});
if (!isNew) {
setTimeout(() => {
let delBtn = document.querySelector("#m-del-supp");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteSupplement(existing.id);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("supplements");
};
}
}, 100);
}
}
// ---------------------------------------------------------------------------
// SETTINGS screen
// ---------------------------------------------------------------------------
// Offline maps live on the router under /baldav/tiles/. The list is read from the
// directory listing, so a new .db uploaded there appears without an app rebuild.
let TILES_BASE_URL = "http://192.168.1.1:88/baldav/tiles/";
// Regions the packing tools know about. Shown even when the router has no .db for
// them yet (greyed out) so nothing silently disappears from the list.
let KNOWN_REGIONS = {
novocherkassk: "Мой регион",
rostov_city: "Ростов-на-Дону",
rostov_oblast: "Ростовская обл.",
moscow_city: "Москва",
moscow_oblast: "Московская обл.",
spb: "Санкт-Петербург",
krasnodar_city: "Краснодар",
krasnodar_krai: "Краснодарский край",
sochi: "Сочи (+Адлер, Кр. Поляна)",
kazan: "Казань",
novosibirsk: "Новосибирск",
ekaterinburg: "Екатеринбург",
};
// Fetch [{ file, name, sizeMB }] from the router's tiles directory listing.
async function fetchRouterTiles() {
let html = await window.__TAURI__.core.invoke("http_request", {
url: TILES_BASE_URL, method: "GET", body: null,
});
let out = [];
let re = /(tiles_[A-Za-z0-9_-]+\.db)<\/a>[\s\S]*?-\s*([\d.]+)\s*kbyte/g;
let m;
while ((m = re.exec(html)) !== null) {
out.push({ file: m[1], name: m[1].replace(/^tiles_|\.db$/g, ""), sizeMB: (parseFloat(m[2]) / 1024).toFixed(1) });
}
if (!out.length) {
for (let f of [...new Set(html.match(/tiles_[A-Za-z0-9_-]+\.db/g) || [])]) {
out.push({ file: f, name: f.replace(/^tiles_|\.db$/g, ""), sizeMB: null });
}
}
return out;
}
// Open the Android SAF file picker and return the path of the picked file.
// A content:// URI has no path, so Kotlin copies the file into the app dir and
// writes the path to open_file_result.txt, which we poll here. Returns null if
// the user cancelled. Desktop has no picker — callers fall back to a path prompt.
async function pickLocalFile(mime) {
let invoke = window.__TAURI__.core.invoke;
await invoke("pick_file_open", { mime: mime || "*/*" });
for (let i = 0; i < 600; i++) { // poll up to ~3 min while the picker is open
await new Promise(r => setTimeout(r, 300));
let res = await invoke("get_picked_file");
if (!res) continue;
if (res === "CANCELLED") return null;
if (res.startsWith("ERROR:")) throw new Error(res.slice(6));
return res;
}
throw new Error("Таймаут выбора файла");
}
// Download a tile DB by URL into the app dir, then import it into the tile cache.
async function importTilesFromUrl(url, label, btn, container) {
let invoke = window.__TAURI__.core.invoke;
let origText = btn.textContent;
btn.disabled = true;
btn.textContent = "Скачивание...";
try {
let appDir = await window.__TAURI__.path.appDataDir();
let dest = appDir + "/" + (label || "tiles_download.db");
let bytes = await invoke("download_file", { url, dest });
btn.textContent = `Импорт (${(parseInt(bytes) / 1048576).toFixed(1)} MB)...`;
let count = await TileCache.importFromSqlite(dest, (done, total) => {
btn.textContent = `${Math.round(done / total * 100)}% (${done}/${total})`;
});
showToast(`${label}: ${count} тайлов импортировано`);
TileCache.count().then(n => {
let el = container.querySelector("#s-tile-count");
if (el) el.textContent = `${n} тайлов в кэше`;
});
} catch (e) {
showToast("Ошибка: " + String(e));
}
btn.disabled = false;
btn.textContent = origText;
}
async function renderSettings(container) {
let p = state.profile;
let profileSummary = [
p.height_cm ? `${p.height_cm} см` : null,
p.birth_year ? `${p.birth_year} г.р.` : null,
p.sex === "f" ? "Ж" : "М",
].filter(Boolean).join(", ");
container.innerHTML = `
Настройки
Профиль
${profileSummary || "Не заполнен"}
›
Нормы нутриентов
Стандарт РСП
Пересчитает % нутриентов
Честный знак
Сессия
${(() => {
let s = ChZ.getSession();
if (!s) return "Не импортирована";
let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0));
return `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`;
})()}
Обновить сессию
Продлить sid через refresh (проверка связи)
Карты
Оффлайн режим
Тайлы только из кэша
Импорт тайлов
Загрузить .db файл с тайлами региона
Фон для картинки «Похвастаться»
Фото-подложка под форму трека и статы
Резервная копия всех данных
Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.
Очистить продукты без магазина
Удалить неиспользованные продукты без метки источника (health-diet, фастфуд). Съеденные и в рецептах остаются.
Скачать с роутера
LAN: 192.168.1.1
Кэш тайлов
...
Просмотр дорог
Все проезженные маршруты на карте
GPS (запись треков)
Мин. скорость (км/ч)
Ниже = пауза
Макс. погрешность GPS (м)
Жёсткий порог: хуже этого = пропуск (рубит сотовые ~800м, оставляет слабый GPS). 50 = разумно. Применяется и при пересчёте трека — снизьте и нажмите «Пересчитать», чтобы отбросить неточные точки (обратимо: поднимите обратно).
Мин. расстояние (м)
Точки ближе = пропуск
Макс. скорость (км/ч)
Выше = глитч GPS, игнор
Макс. пауза GPS (сек)
Пробел длиннее = новый сегмент
Аэросопротивление
Город 0.6, спорт 0.4
Плотность воздуха (кг/м3)
Море 1.225, горы 1.0
КПД педалирования (%)
Человек ~25%
Сглаживание высоты
Полуокно (5=11 точек, 7=15)
Ходьба / Бег
Макс. скорость (км/ч)
Выше = глитч GPS
Мин. скорость (км/ч)
Ниже = стоит
Цвет текущего трека
Линия записи/трека (чтобы не путать с дорогами)
Цвет дорог: вело
Цвет дорог: ходьба
Цвет дорог: бег
Толщина дорог по проездам
Чаще проезжал - толще линия (x1..x3, +0.2 за проезд)
Fused-локация (Google Play)
Альтернатива raw GPS. Иногда стабильнее при выкл. экране. Требует сервисы Google.
GPXZ API ключ (высоты маршрутов)
Точные высоты Copernicus GLO-30 (лучше SRTM для уклонов). Пусто = open-elevation. Ключ хранится только на устройстве, не в git.
Сохранить настройки GPS
Велосипеды
+ Добавить велосипед
Разрешения
Геолокация (GPS)
Проверка...
Фоновая геолокация
Для записи при выключенном экране. Нужно выбрать «Разрешать всегда».
`
).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 });
if (type === "baro") {
// barometer -> its own slot, so it doesn't overwrite the heart-rate strap
await DB.setProfileValue("baro_device_addr", addr);
await DB.setProfileValue("baro_device_name", name);
state.profile.baro_device_addr = addr;
state.profile.baro_device_name = name;
// persist for auto-connect at start (separate from the HR device file)
try { await window.__TAURI__.core.invoke("ble_save_baro_device", { address: addr }); } catch {}
try { if (window.BARO) { BARO.reset(); BARO.start(); } } catch {}
} else {
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 (HR auto-connect)
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;
}
// re-render settings so the Connect/Unbind buttons appear and the baro readout resumes
await renderSettings(container);
} catch (e) {
item.querySelector(".settings-item-desc").textContent = "Ошибка: " + e;
}
};
});
}
} catch (e) {
bleResults.innerHTML = `
${e}
`;
}
bleScanBtn.textContent = "Поиск";
bleScanBtn.disabled = false;
};
}
// Sync handlers
let syncNowBtn = container.querySelector("#s-sync-now");
if (syncNowBtn) {
syncNowBtn.onclick = async () => {
if (!Sync.userId) {
// Show setup
container.querySelector("#s-sync-setup").style.display = "";
return;
}
syncNowBtn.textContent = "...";
syncNowBtn.disabled = true;
let r = await Sync.now();
container.querySelector("#s-sync-desc").textContent = `Отправлено: ${r.pushed}, получено: ${r.pulled}`;
syncNowBtn.textContent = "Синхр.";
syncNowBtn.disabled = false;
};
}
container.querySelector("#s-sync-register")?.addEventListener("click", async () => {
try {
let code = await Sync.register();
container.querySelector("#s-sync-status").textContent = "Подключено";
container.querySelector("#s-sync-setup").style.display = "none";
container.querySelector("#s-sync-info").style.display = "";
container.querySelector("#s-sync-code-display").style.display = "";
container.querySelector("#s-sync-code-display").textContent = code;
syncNowBtn.textContent = "Синхр.";
showToast("Аккаунт создан. Код: " + code);
} catch (e) { showToast("Ошибка: " + e.message); }
});
container.querySelector("#s-sync-pair")?.addEventListener("click", async () => {
let code = container.querySelector("#s-sync-code").value.trim().toUpperCase();
if (code.length !== 6) { showToast("Введите 6-символьный код"); return; }
try {
await Sync.pair(code);
container.querySelector("#s-sync-status").textContent = "Подключено";
container.querySelector("#s-sync-setup").style.display = "none";
container.querySelector("#s-sync-info").style.display = "";
syncNowBtn.textContent = "Синхр.";
showToast("Устройство привязано!");
await Sync.now();
} catch (e) { showToast("Ошибка: " + e.message); }
});
container.querySelector("#s-sync-show-code")?.addEventListener("click", async () => {
try {
let code = await Sync.newCode();
let el = container.querySelector("#s-sync-code-display");
el.style.display = "";
el.textContent = code;
} catch (e) { showToast("Ошибка: " + e.message); }
});
// App version info + update check
try {
let info = await window.__TAURI__.core.invoke("get_app_info");
container.querySelector("#s-version").textContent = `v${info.version} (${info.git_hash}) ${info.build_date}`;
} catch {
container.querySelector("#s-version").textContent = "dev";
}
// Check for updates: trigger Kotlin re-check, poll until result changes
let updateArea = container.querySelector("#s-update-area");
if (updateArea) {
updateArea.innerHTML = `проверка...`;
let oldTs = 0;
try {
let old = JSON.parse(await window.__TAURI__.core.invoke("get_update_info"));
oldTs = old.ts || 0;
} catch {}
try {
await window.__TAURI__.core.invoke("gps_service_command", { cmd: "check_update" });
} catch {}
// Poll until Kotlin writes new result (ts changes)
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 200));
try {
let raw = await window.__TAURI__.core.invoke("get_update_info");
let info = JSON.parse(raw);
if (info.ts && info.ts !== oldTs) {
await checkForUpdate();
break;
}
} catch {}
}
if (state.updateAvailable) {
showUpdateButton(updateArea);
} else {
updateArea.innerHTML = "";
}
}
}
function showUpdateButton(container) {
container.innerHTML = ``;
container.querySelector("#s-update-btn").onclick = () => startUpdate();
}
function showProfileModal() {
let p = state.profile;
let selectedSex = p.sex ?? "m";
showModal(`
Профиль
...
Часть съеденного тратится на переваривание. Расход масштабируется от еды; на поддержке баланс не меняется.
Во время заезда/VR копится потребность в воде (пот), а выпитое берётся из того, что ты логируешь в баланс (жидкость по water_pct). Молчит, пока успеваешь пить; громкий сигнал (канал будильника, пробивает громкость) - только когда отстаёшь сверх порога.
Пока включено, во время записи заезда телефон отправляет текущую точку на сервер раз в 10 секунд. Видно только тем, кому дан доступ, и только после входа. Выключается здесь же.
Во время записи появится кнопка «СМС»: по нажатию спросит подтверждение и отправит на этот номер текст с координатами и ссылкой на Яндекс.Карты. Номер можно вписать вручную или выбрать из контактов. Работает без интернета (по сотовой сети).
`,
async overlay => {
let weight = parseFloat(overlay.querySelector("#m-weight").value) || null;
let height = parseInt(overlay.querySelector("#m-height").value);
let year = parseInt(overlay.querySelector("#m-year").value);
if (isNaN(height) || isNaN(year)) return false;
if (weight) await DB.setProfileValue("weight_kg", weight);
await DB.setProfileValue("height_cm", height);
await DB.setProfileValue("birth_year", year);
await DB.setProfileValue("sex", selectedSex);
let activityLevel = overlay.querySelector("#m-activity-level").value;
await DB.setProfileValue("activity_level", activityLevel);
let tefEl = overlay.querySelector("#m-tef");
if (tefEl) { GPS.config.tefFrac = tefEl.checked ? 0.10 : 0; GPS.saveConfig(); }
let waterNorm = parseInt(overlay.querySelector("#m-water-norm").value) || 2000;
await DB.setProfileValue("daily_water_ml", waterNorm);
let hydrOn = !!overlay.querySelector("#m-hydr-on")?.checked;
let hydrAlert = Math.min(3000, Math.max(100, parseInt(overlay.querySelector("#m-hydr-alert")?.value) || 600));
localStorage.setItem("baldav_hydr_on", hydrOn ? "1" : "0");
localStorage.setItem("baldav_hydr_alert", String(hydrAlert));
try { await window.__TAURI__.core.invoke("set_hydration_config", { enabled: hydrOn, alertMl: hydrAlert, sweatMlPerHour: 600, reAlertMin: 15, startDeficitMl: 0 }); } catch (e) { console.error("hydration cfg", e); }
let liveOn = !!overlay.querySelector("#m-live-on")?.checked;
let liveUrl = (overlay.querySelector("#m-live-url")?.value || "").trim();
let liveToken = (overlay.querySelector("#m-live-token")?.value || "").trim();
localStorage.setItem("baldav_live_on", liveOn ? "1" : "0");
localStorage.setItem("baldav_live_url", liveUrl);
localStorage.setItem("baldav_live_token", liveToken);
try { await window.__TAURI__.core.invoke("set_live_config", { enabled: liveOn, url: liveUrl, token: liveToken }); } catch (e) { console.error("live cfg", e); }
let smsNumber = (overlay.querySelector("#m-sms-number")?.value || "").trim();
localStorage.setItem("baldav_sms_number", smsNumber);
state.profile = { ...state.profile,
weight_kg: weight ? weight : state.profile.weight_kg,
height_cm: String(height), birth_year: String(year), sex: selectedSex,
activity_level: activityLevel };
});
// Initialize modal controls
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
// Sex chips
overlay.querySelectorAll("[data-sex]").forEach(btn => {
btn.onclick = () => {
selectedSex = btn.dataset.sex;
overlay.querySelectorAll("[data-sex]").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected");
updateBmrDisplay();
};
});
// Activity level dropdown
let actSelect = overlay.querySelector("#m-activity-level");
if (actSelect) actSelect.value = p.activity_level || "1.2";
// BMR display
function updateBmrDisplay() {
let w = parseFloat(overlay.querySelector("#m-weight").value) || parseFloat(p.weight_kg) || 80;
let h = parseInt(overlay.querySelector("#m-height").value) || parseInt(p.height_cm) || 178;
let y = parseInt(overlay.querySelector("#m-year").value) || parseInt(p.birth_year) || 1988;
let bmrVal = BMR.calculateBmr(w, h, BMR.getAge(y), selectedSex);
let el = overlay.querySelector("#m-bmr-display");
if (el) el.textContent = bmrVal + " ккал/день";
}
updateBmrDisplay();
// Recalculate BMR when weight/height/year change
for (let id of ["#m-weight", "#m-height", "#m-year"]) {
let el = overlay.querySelector(id);
if (el) el.oninput = updateBmrDisplay;
}
// Pick a phone number from the system contacts (fills the SMS number field)
let pickBtn = overlay.querySelector("#m-sms-pick");
if (pickBtn) pickBtn.onclick = async () => {
let field = overlay.querySelector("#m-sms-number");
pickBtn.disabled = true;
try {
await window.__TAURI__.core.invoke("pick_contact");
// The picker is an Activity; poll for its result for a few seconds.
let got = null;
for (let i = 0; i < 40 && !got; i++) {
await new Promise(r => setTimeout(r, 300));
let raw = await window.__TAURI__.core.invoke("get_contact_result");
if (raw) got = JSON.parse(raw);
}
if (got && got.number) {
field.value = got.number;
}
} catch (e) {
appAlert("Не удалось выбрать контакт: " + e);
} finally {
pickBtn.disabled = false;
}
};
}, 100);
}
// ---------------------------------------------------------------------------
// Bike profile modal
// ---------------------------------------------------------------------------
async function showBikeModal(bikeId) {
let isNew = bikeId == null;
let bike = isNew ? {} : await DB.getBike(bikeId);
if (!isNew && !bike) return;
let surfaceLabels = { asphalt: "Асфальт", mixed: "Смешанный", gravel: "Грунт" };
let surfaceVal = bike.surface_type ?? "mixed";
showModal(`
${isNew ? "Новый велосипед" : "Редактирование"}
${!isNew ? `
Обслуживание
` : ""}
${!isNew ? `` : ""}`,
async overlay => {
let name = overlay.querySelector("#m-bike-name").value.trim();
if (!name) return false;
let data = {
name,
weight_kg: parseFloat(overlay.querySelector("#m-bike-weight").value) || null,
wheel_diameter_inch: parseFloat(overlay.querySelector("#m-bike-wheel").value) || 26,
chainring_teeth: parseInt(overlay.querySelector("#m-bike-chainring").value) || null,
cog_teeth: parseInt(overlay.querySelector("#m-bike-cog").value) || null,
surface_type: overlay.querySelector("#m-bike-surface").value,
tire_pressure_bar: parseFloat(overlay.querySelector("#m-bike-pressure").value) || null,
is_active: overlay.querySelector("#m-bike-active-yes").classList.contains("selected"),
};
if (isNew) {
await DB.saveBike(data);
} else {
await DB.updateBike(bikeId, data);
}
// Refresh the settings screen to show updated list
renderScreen("settings");
});
// Active toggle chips
setTimeout(() => {
let overlay = [...document.querySelectorAll(".modal-overlay")].pop();
if (!overlay) return;
overlay.querySelectorAll("#m-bike-active-yes, #m-bike-active-no").forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
overlay.querySelector("#m-bike-active-yes").classList.toggle("selected", btn.dataset.active === "1");
overlay.querySelector("#m-bike-active-no").classList.toggle("selected", btn.dataset.active === "0");
};
});
}, 50);
if (!isNew) {
setTimeout(() => {
let delBtn = document.querySelector("#m-del-bike");
if (delBtn) {
delBtn.onclick = async (e) => {
e.stopPropagation();
await DB.deleteBike(bikeId);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
renderScreen("settings");
};
}
}, 100);
// Bike maintenance section
setTimeout(async () => {
let mileageEl = document.querySelector("#m-bike-mileage");
let listEl = document.querySelector("#m-bike-maint-list");
let addBtn = document.querySelector("#m-bike-maint-add");
let formEl = document.querySelector("#m-bike-maint-form");
if (!listEl) return;
let totalKm = await DB.getBikeTotalMileage(bikeId);
if (mileageEl) mileageEl.textContent = `Общий пробег: ${totalKm} км`;
async function renderMaintList() {
let entries = await DB.getBikeMaintenanceLog(bikeId);
if (entries.length === 0) {
listEl.innerHTML = '
Нет записей
';
return;
}
listEl.innerHTML = entries.map(e => {
let overdue = e.next_service_km != null && totalKm >= e.next_service_km;
let nextInfo = e.next_service_km != null ? `, след. при ${e.next_service_km} км` : "";
let dateShort = e.date.slice(5).replace("-", ".");
return `