const { once, listen } = window.__TAURI__.event; const { invoke } = window.__TAURI__.core; const connectScreen = document.querySelector("#connect"); const workoutScreen = document.querySelector("#workout"); const startBtn = document.querySelector("#start-btn"); const groups = { hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" }, cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" }, trainers: { list: document.querySelector("#trainer-list"), category: "trainer" }, }; const deviceCards = new Map(); // device_id -> li const selected = {}; // category -> { btn, card } подключённого датчика const pendingStatus = {}; // category -> status (пришёл до установки selected) function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; } // --- Батарея --- function batteryIcon(percent) { const low = percent < 20; const wrap = el("span", "battery" + (low ? " battery--low" : "")); wrap.title = `${percent}%`; const ns = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(ns, "svg"); svg.setAttribute("viewBox", "0 0 24 12"); svg.classList.add("battery__icon"); const rect = (x, y, w, h, cls) => { const r = document.createElementNS(ns, "rect"); r.setAttribute("x", x); r.setAttribute("y", y); r.setAttribute("width", w); r.setAttribute("height", h); if (cls) r.classList.add(cls); return r; }; svg.append( rect(0, 0, 20, 12, "battery__body"), rect(21, 3, 2, 6, "battery__terminal"), rect(1, 1, low ? 0 : (18 * percent) / 100, 10, "battery__fill"), ); if (low) { const warn = document.createElementNS(ns, "text"); warn.setAttribute("x", "10"); warn.setAttribute("y", "10"); warn.setAttribute("text-anchor", "middle"); warn.classList.add("battery__warn"); warn.textContent = "!"; svg.append(warn); } wrap.append(svg); return wrap; } // --- Датчики --- function renderDevice(device, category) { const li = el("li", "device"); deviceCards.set(device.id, li); const head = el("div", "device__head"); head.append(el("span", "device__name", device.display_name || "без имени")); head.append(el("span", "device__rssi", device.rssi != null ? `${device.rssi} dBm` : "")); const connectBtn = el("button", "device__connect", "подключить"); connectBtn.addEventListener("click", () => connect(device.id, connectBtn, category, li)); li.append(head, el("span", "device__meta", device.address), connectBtn); return li; } function renderGroup(group, devices) { group.list.replaceChildren(); if (!devices.length) { group.list.appendChild(el("li", "device device--empty", "нет")); return; } for (const d of devices) group.list.appendChild(renderDevice(d, group.category)); } // --- Подключение (режим радио-кнопки: один на категорию) --- function resetSelected(category) { const prev = selected[category]; if (!prev) return; prev.btn.textContent = "подключить"; prev.btn.classList.remove("device__connect--done"); prev.btn.disabled = false; const battery = prev.card.querySelector(".battery"); if (battery) battery.remove(); selected[category] = null; } async function connect(deviceId, btn, category, card) { btn.disabled = true; btn.textContent = "…"; try { await invoke("connect", { deviceId, category }); resetSelected(category); btn.textContent = "✓"; btn.classList.add("device__connect--done"); selected[category] = { btn, card }; if (pendingStatus[category]) { applyStatus(selected[category], category, pendingStatus[category]); delete pendingStatus[category]; } if (category === "trainer") startBtn.disabled = false; } catch (e) { console.error(e); btn.disabled = false; btn.textContent = "ошибка"; } } // --- Переход на экран упражнения --- startBtn.addEventListener("click", async () => { connectScreen.hidden = true; workoutScreen.hidden = false; sizeGraph(); await loadWorkouts(); }); // --- Телеметрия --- function setMetric(id, value) { document.querySelector(`#${id}`).textContent = value; } once("devices-loaded", (event) => { const repo = event.payload; renderGroup(groups.hr_sensors, repo.hr_sensors ?? []); renderGroup(groups.cad_sensors, repo.cad_sensors ?? []); renderGroup(groups.trainers, repo.trainers ?? []); }); // Непрерывный скан: новые датчики добавляются в список по мере появления. listen("device-found", (event) => { const { device, categories } = event.payload; for (const cat of categories) { const group = Object.values(groups).find((g) => g.category === cat); if (!group) continue; const empty = group.list.querySelector(".device--empty"); if (empty) empty.remove(); group.list.appendChild(renderDevice(device, cat)); } }); listen("battery", (event) => { const { device_id, percent } = event.payload; const card = deviceCards.get(device_id); if (!card) return; const head = card.querySelector(".device__head"); head.querySelector(".battery")?.remove(); head.append(batteryIcon(percent)); }); listen("telemetry", (event) => { const t = event.payload.telemetry; if (t.Power) { actualWatts = t.Power.watts; setMetric("power-value", `${actualWatts} W`); updatePowerDeviation(); samplePower(actualWatts); scheduleDraw(); } else if (t.Cadence) { actualCadence = Math.round(t.Cadence.rpm); setMetric("cadence-value", `${actualCadence} rpm`); updateCadenceDeviation(); } else if (t.HeartRate) { actualHr = t.HeartRate.bpm; setMetric("hr-value", t.HeartRate.bpm); sampleHr(t.HeartRate.bpm); scheduleDraw(); } }); function applyStatus(sel, category, status) { const { btn } = sel; if (status === "reconnecting") { btn.textContent = "переподключение…"; btn.disabled = true; } else if (status === "reconnected") { btn.textContent = "✓"; btn.classList.add("device__connect--done"); btn.disabled = true; } else if (status === "gave_up") { btn.textContent = "подключить"; btn.classList.remove("device__connect--done"); btn.disabled = false; selected[category] = null; } } listen("connection", (event) => { const { category, status } = event.payload; const sel = selected[category]; if (!sel) { pendingStatus[category] = status; return; } applyStatus(sel, category, status); }); // --- Воркаут --- const timerEl = document.querySelector("#timer"); const workoutSelect = document.querySelector("#workout-select"); const ftpInput = document.querySelector("#ftp-input"); const workoutMeta = document.querySelector("#workout-meta"); const workoutName = document.querySelector("#workout-name"); const workoutStep = document.querySelector("#workout-step"); const workoutBar = document.querySelector("#workout-bar"); const workoutStartBtn = document.querySelector("#workout-start"); const workoutPauseBtn = document.querySelector("#workout-pause"); const workoutStopBtn = document.querySelector("#workout-stop"); // --- Отклонение от цели --- const powerMetric = document.querySelector("#power-metric"); const powerTarget = document.querySelector("#power-target"); const cadenceMetric = document.querySelector("#cadence-metric"); const cadenceTarget = document.querySelector("#cadence-target"); const POWER_TOL_PCT = 0.1; const POWER_TOL_ABS = 15; // Вт const CADENCE_TOL_ABS = 5; // rpm let targetWatts = null; let targetCadence = null; let actualWatts = null; let actualCadence = null; let actualHr = null; function powerTolerance(target) { return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT)); } function updateDeviation(metricEl, targetEl, actual, target, tolerance, unit) { targetEl.textContent = target != null ? `${target} ${unit}` : "—"; if (actual == null || target == null) { metricEl.classList.remove("metric--off"); return; } const diff = actual - target; if (Math.abs(diff) > tolerance) { metricEl.classList.add("metric--off"); } else { metricEl.classList.remove("metric--off"); } } function updatePowerDeviation() { updateDeviation(powerMetric, powerTarget, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W"); } function updateCadenceDeviation() { updateDeviation(cadenceMetric, cadenceTarget, actualCadence, targetCadence, CADENCE_TOL_ABS, "rpm"); } function clearTargets() { targetWatts = null; targetCadence = null; updatePowerDeviation(); updateCadenceDeviation(); } // --- График мощности и пульса --- const graph = document.querySelector("#graph"); const ZONE_COLORS = [ "#b0bec5", // Z1 — восстановление "#42a5f5", // Z2 — выносливость "#66bb6a", // Z3 — темп "#ffca28", // Z4 — порог "#ff9800", // Z5 — VO2max "#ef5350", // Z6 — анаэробная "#ab47bc", // Z7 — нейромышечная ]; const GRAPH_WINDOW_MS = 30 * 1000; const powerHistory = []; const hrHistory = []; let graphDirty = false; function powerZone(watts, ftp) { const pct = (watts / ftp) * 100; if (pct < 55) return 0; if (pct < 76) return 1; if (pct < 91) return 2; if (pct < 106) return 3; if (pct < 121) return 4; if (pct < 151) return 5; return 6; } function currentFtp() { const v = Number(ftpInput.value); return Number.isFinite(v) && v > 0 ? v : 250; } function sizeGraph() { const dpr = window.devicePixelRatio || 1; const w = graph.clientWidth; const h = graph.clientHeight; if (w === 0 || h === 0) return; graph.width = Math.round(w * dpr); graph.height = Math.round(h * dpr); } function samplePower(watts) { const now = performance.now(); powerHistory.push({ t: now, watts }); while (powerHistory.length && powerHistory[0].t < now - GRAPH_WINDOW_MS) powerHistory.shift(); } function sampleHr(bpm) { const now = performance.now(); hrHistory.push({ t: now, bpm }); while (hrHistory.length && hrHistory[0].t < now - GRAPH_WINDOW_MS) hrHistory.shift(); } function scheduleDraw() { if (graphDirty) return; graphDirty = true; requestAnimationFrame(() => { graphDirty = false; drawGraph(); }); } function drawGraph() { const ctx = graph.getContext("2d"); const dpr = window.devicePixelRatio || 1; const w = graph.clientWidth; const h = graph.clientHeight; if (w === 0 || h === 0) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); const now = performance.now(); const cutoff = now - GRAPH_WINDOW_MS; const power = powerHistory.filter((s) => s.t >= cutoff); const hr = hrHistory.filter((s) => s.t >= cutoff); const maxPower = Math.max(300, Math.ceil(Math.max(...power.map((s) => s.watts), 0) / 50) * 50); const maxHr = Math.max(160, Math.ceil(Math.max(...hr.map((s) => s.bpm), 0) / 10) * 10); const xFor = (t) => w - ((now - t) / GRAPH_WINDOW_MS) * w; const yPower = (v) => h - (v / maxPower) * h; const yHr = (v) => h - (v / maxHr) * h; // сетка мощности ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.fillStyle = "rgba(255,255,255,0.35)"; ctx.font = "10px Inter, system-ui, sans-serif"; ctx.lineWidth = 1; for (let g = 0; g <= maxPower; g += 50) { const y = yPower(g); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); ctx.fillText(String(g), 4, y - 2); } // пульс — пунктирная линия if (hr.length >= 2) { ctx.strokeStyle = "#ef5350"; ctx.lineWidth = 1.5; ctx.setLineDash([4, 3]); ctx.beginPath(); hr.forEach((s, i) => { const x = xFor(s.t); const y = yHr(s.bpm); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.stroke(); ctx.setLineDash([]); } // мощность — заливка по зонам if (power.length >= 2) { const ftp = currentFtp(); for (let i = 0; i < power.length - 1; i++) { const a = power[i]; const b = power[i + 1]; const color = ZONE_COLORS[powerZone(a.watts, ftp)]; const xa = xFor(a.t); const ya = yPower(a.watts); const xb = xFor(b.t); const yb = yPower(b.watts); ctx.fillStyle = color; ctx.globalAlpha = 0.3; ctx.beginPath(); ctx.moveTo(xa, h); ctx.lineTo(xa, ya); ctx.lineTo(xb, yb); ctx.lineTo(xb, h); ctx.closePath(); ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(xa, ya); ctx.lineTo(xb, yb); ctx.stroke(); } } } window.addEventListener("resize", () => { sizeGraph(); scheduleDraw(); }); let workouts = []; let workoutPaused = false; let workoutSamples = []; let workoutStartMs = 0; let workoutSaveName = "workout"; let lastSampleSecs = -1; function formatClock(sec) { const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } async function loadWorkouts() { try { workouts = await invoke("list_workouts"); workoutSelect.replaceChildren(); const empty = el("option", null, "— выбери воркаут —"); empty.value = ""; workoutSelect.append(empty); for (const w of workouts) { const opt = el("option", null, `${w.date.slice(0, 10)} · ${w.name} · ${w.duration_min} мин`); opt.value = w.path; workoutSelect.append(opt); } ftpInput.value = await invoke("get_ftp"); } catch (e) { console.error(e); } } function selectedWorkout() { return workouts.find((w) => w.path === workoutSelect.value); } workoutSelect.addEventListener("change", () => { const w = selectedWorkout(); workoutName.textContent = w ? w.name : "—"; workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : ""; }); async function setRunning(active) { workoutStartBtn.disabled = active; workoutPauseBtn.disabled = !active; workoutStopBtn.disabled = !active; workoutSelect.disabled = active; ftpInput.disabled = active; if (!active) { workoutPaused = false; workoutPauseBtn.textContent = "пауза"; workoutStep.textContent = "—"; workoutBar.style.width = "0%"; timerEl.textContent = "00:00"; clearTargets(); } } workoutStartBtn.addEventListener("click", async () => { const w = selectedWorkout(); const ftp = Number(ftpInput.value); if (!w || !Number.isFinite(ftp) || ftp <= 0) return; workoutSamples = []; workoutStartMs = Date.now(); workoutSaveName = w.name; actualWatts = null; actualCadence = null; actualHr = null; lastSampleSecs = -1; try { await invoke("set_ftp", { ftp }); await invoke("start_workout", { path: w.path }); await setRunning(true); } catch (e) { console.error(e); } }); workoutPauseBtn.addEventListener("click", async () => { workoutPaused = !workoutPaused; workoutPauseBtn.textContent = workoutPaused ? "продолжить" : "пауза"; try { await invoke("pause_workout", { paused: workoutPaused }); } catch (e) { console.error(e); } }); workoutStopBtn.addEventListener("click", async () => { try { await invoke("stop_workout"); } catch (e) { console.error(e); } await setRunning(false); }); listen("workout-progress", (event) => { const p = event.payload; workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`; targetWatts = p.target_watts; targetCadence = p.target_cadence; updatePowerDeviation(); updateCadenceDeviation(); timerEl.textContent = formatClock(p.elapsed_secs); const pct = p.total_secs > 0 ? (p.elapsed_secs / p.total_secs) * 100 : 0; workoutBar.style.width = `${Math.min(100, pct)}%`; if (p.elapsed_secs > lastSampleSecs) { lastSampleSecs = p.elapsed_secs; workoutSamples.push({ time: new Date(workoutStartMs + p.elapsed_secs * 1000).toISOString(), watts: actualWatts, cadence: actualCadence, hr: actualHr, }); } }); listen("workout-finished", (event) => { setRunning(false); if (event.payload?.ok && workoutSamples.length) { saveWorkout(); } }); async function saveWorkout() { try { const path = await invoke("save_workout", { name: workoutSaveName, samples: workoutSamples, }); console.log("saved workout:", path); workoutMeta.textContent = `сохранено: ${path}`; } catch (e) { console.error(e); } }