|
|
|
|
@ -122,9 +122,11 @@ async function connect(deviceId, btn, category, card) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Переход на экран упражнения ---
|
|
|
|
|
startBtn.addEventListener("click", () => {
|
|
|
|
|
startBtn.addEventListener("click", async () => {
|
|
|
|
|
connectScreen.hidden = true;
|
|
|
|
|
workoutScreen.hidden = false;
|
|
|
|
|
sizeGraph();
|
|
|
|
|
await loadWorkouts();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Телеметрия ---
|
|
|
|
|
@ -150,9 +152,21 @@ listen("battery", (event) => {
|
|
|
|
|
|
|
|
|
|
listen("telemetry", (event) => {
|
|
|
|
|
const t = event.payload.telemetry;
|
|
|
|
|
if (t.Power) setMetric("power-value", t.Power.watts);
|
|
|
|
|
else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm));
|
|
|
|
|
else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm);
|
|
|
|
|
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) {
|
|
|
|
|
setMetric("hr-value", t.HeartRate.bpm);
|
|
|
|
|
sampleHr(t.HeartRate.bpm);
|
|
|
|
|
scheduleDraw();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function applyStatus(sel, category, status) {
|
|
|
|
|
@ -182,62 +196,318 @@ listen("connection", (event) => {
|
|
|
|
|
applyStatus(sel, category, status);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Таймер ---
|
|
|
|
|
// --- Воркаут ---
|
|
|
|
|
const timerEl = document.querySelector("#timer");
|
|
|
|
|
const timerBtn = document.querySelector("#timer-btn");
|
|
|
|
|
let running = false;
|
|
|
|
|
let startTs = 0;
|
|
|
|
|
let elapsed = 0;
|
|
|
|
|
let raf = 0;
|
|
|
|
|
|
|
|
|
|
function formatTime(ms) {
|
|
|
|
|
const sec = Math.floor(ms / 1000);
|
|
|
|
|
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 powerWarn = document.querySelector("#power-warn");
|
|
|
|
|
const cadenceMetric = document.querySelector("#cadence-metric");
|
|
|
|
|
const cadenceTarget = document.querySelector("#cadence-target");
|
|
|
|
|
const cadenceWarn = document.querySelector("#cadence-warn");
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
function powerTolerance(target) {
|
|
|
|
|
return Math.max(POWER_TOL_ABS, Math.round(target * POWER_TOL_PCT));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateDeviation(metricEl, targetEl, warnEl, actual, target, tolerance, unit) {
|
|
|
|
|
targetEl.textContent = target != null ? `${target} ${unit}` : "—";
|
|
|
|
|
if (actual == null || target == null) {
|
|
|
|
|
metricEl.classList.remove("metric--off");
|
|
|
|
|
warnEl.textContent = "";
|
|
|
|
|
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, powerWarn, actualWatts, targetWatts, powerTolerance(targetWatts ?? 0), "W");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateCadenceDeviation() {
|
|
|
|
|
updateDeviation(cadenceMetric, cadenceTarget, cadenceWarn, 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;
|
|
|
|
|
|
|
|
|
|
function formatClock(sec) {
|
|
|
|
|
const m = Math.floor(sec / 60);
|
|
|
|
|
const s = sec % 60;
|
|
|
|
|
const s = Math.floor(sec % 60);
|
|
|
|
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tick() {
|
|
|
|
|
elapsed = performance.now() - startTs;
|
|
|
|
|
timerEl.textContent = formatTime(elapsed);
|
|
|
|
|
if (running) raf = requestAnimationFrame(tick);
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
timerBtn.addEventListener("click", async () => {
|
|
|
|
|
running = !running;
|
|
|
|
|
if (running) {
|
|
|
|
|
startTs = performance.now() - elapsed;
|
|
|
|
|
timerBtn.textContent = "стоп";
|
|
|
|
|
tick();
|
|
|
|
|
workoutStartBtn.addEventListener("click", async () => {
|
|
|
|
|
const w = selectedWorkout();
|
|
|
|
|
const ftp = Number(ftpInput.value);
|
|
|
|
|
if (!w || !Number.isFinite(ftp) || ftp <= 0) return;
|
|
|
|
|
try {
|
|
|
|
|
await invoke("trainer_start");
|
|
|
|
|
await invoke("set_ftp", { ftp });
|
|
|
|
|
await invoke("start_workout", { path: w.path });
|
|
|
|
|
await setRunning(true);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
cancelAnimationFrame(raf);
|
|
|
|
|
timerBtn.textContent = "старт";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
workoutPauseBtn.addEventListener("click", async () => {
|
|
|
|
|
workoutPaused = !workoutPaused;
|
|
|
|
|
workoutPauseBtn.textContent = workoutPaused ? "продолжить" : "пауза";
|
|
|
|
|
try {
|
|
|
|
|
await invoke("trainer_stop");
|
|
|
|
|
await invoke("pause_workout", { paused: workoutPaused });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// --- Целевая мощность/каденс ---
|
|
|
|
|
document.querySelector("#apply-btn").addEventListener("click", async () => {
|
|
|
|
|
const powerRaw = document.querySelector("#power-input").value.trim();
|
|
|
|
|
const cadenceRaw = document.querySelector("#cadence-input").value.trim();
|
|
|
|
|
if (powerRaw === "" || cadenceRaw === "") return;
|
|
|
|
|
const watts = Number(powerRaw);
|
|
|
|
|
const rpm = Number(cadenceRaw);
|
|
|
|
|
if (!Number.isFinite(watts) || !Number.isFinite(rpm)) return;
|
|
|
|
|
if (watts < 0 || rpm < 0 || !Number.isInteger(watts) || !Number.isInteger(rpm)) return;
|
|
|
|
|
workoutStopBtn.addEventListener("click", async () => {
|
|
|
|
|
try {
|
|
|
|
|
await invoke("set_target_power", { watts });
|
|
|
|
|
await invoke("set_target_cadence", { rpm });
|
|
|
|
|
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)}%`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
listen("workout-finished", () => {
|
|
|
|
|
setRunning(false);
|
|
|
|
|
});
|
|
|
|
|
|