You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ridein/src/main.js

561 lines
16 KiB

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);
}
}