|
|
|
|
@ -4,11 +4,13 @@ const { invoke } = window.__TAURI__.core;
|
|
|
|
|
const connectScreen = document.querySelector("#connect");
|
|
|
|
|
const workoutScreen = document.querySelector("#workout");
|
|
|
|
|
const startBtn = document.querySelector("#start-btn");
|
|
|
|
|
const powerSourceCheck = document.querySelector("#power-source-check");
|
|
|
|
|
|
|
|
|
|
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" },
|
|
|
|
|
power_meters: { list: document.querySelector("#power-list"), category: "power" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deviceCards = new Map(); // device_id -> li
|
|
|
|
|
@ -108,7 +110,7 @@ async function connect(deviceId, btn, category, card) {
|
|
|
|
|
resetSelected(category);
|
|
|
|
|
btn.textContent = "✓";
|
|
|
|
|
btn.classList.add("device__connect--done");
|
|
|
|
|
selected[category] = { btn, card };
|
|
|
|
|
selected[category] = { btn, card, deviceId };
|
|
|
|
|
if (pendingStatus[category]) {
|
|
|
|
|
applyStatus(selected[category], category, pendingStatus[category]);
|
|
|
|
|
delete pendingStatus[category];
|
|
|
|
|
@ -139,6 +141,7 @@ once("devices-loaded", (event) => {
|
|
|
|
|
renderGroup(groups.hr_sensors, repo.hr_sensors ?? []);
|
|
|
|
|
renderGroup(groups.cad_sensors, repo.cad_sensors ?? []);
|
|
|
|
|
renderGroup(groups.trainers, repo.trainers ?? []);
|
|
|
|
|
renderGroup(groups.power_meters, repo.power_meters ?? []);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Непрерывный скан: новые датчики добавляются в список по мере появления.
|
|
|
|
|
@ -164,12 +167,19 @@ listen("battery", (event) => {
|
|
|
|
|
|
|
|
|
|
listen("telemetry", (event) => {
|
|
|
|
|
const t = event.payload.telemetry;
|
|
|
|
|
const devId = event.payload.device_id;
|
|
|
|
|
if (t.Power) {
|
|
|
|
|
actualWatts = t.Power.watts;
|
|
|
|
|
setMetric("power-value", `${actualWatts} W`);
|
|
|
|
|
updatePowerDeviation();
|
|
|
|
|
samplePower(actualWatts);
|
|
|
|
|
scheduleDraw();
|
|
|
|
|
// источник мощности: пауэрметр (чекбокс + он подключён) или станок
|
|
|
|
|
const pmId = selected.power?.deviceId;
|
|
|
|
|
const trainerId = selected.trainer?.deviceId;
|
|
|
|
|
const expectedId = powerSourceCheck.checked && pmId ? pmId : trainerId;
|
|
|
|
|
if (!expectedId || devId === expectedId) {
|
|
|
|
|
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`);
|
|
|
|
|
@ -217,6 +227,7 @@ 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 workoutProfile = document.querySelector("#workout-profile");
|
|
|
|
|
const workoutStartBtn = document.querySelector("#workout-start");
|
|
|
|
|
const workoutPauseBtn = document.querySelector("#workout-pause");
|
|
|
|
|
const workoutStopBtn = document.querySelector("#workout-stop");
|
|
|
|
|
@ -288,8 +299,7 @@ const powerHistory = [];
|
|
|
|
|
const hrHistory = [];
|
|
|
|
|
let graphDirty = false;
|
|
|
|
|
|
|
|
|
|
function powerZone(watts, ftp) {
|
|
|
|
|
const pct = (watts / ftp) * 100;
|
|
|
|
|
function percentZone(pct) {
|
|
|
|
|
if (pct < 55) return 0;
|
|
|
|
|
if (pct < 76) return 1;
|
|
|
|
|
if (pct < 91) return 2;
|
|
|
|
|
@ -299,6 +309,10 @@ function powerZone(watts, ftp) {
|
|
|
|
|
return 6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function powerZone(watts, ftp) {
|
|
|
|
|
return percentZone((watts / ftp) * 100);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function currentFtp() {
|
|
|
|
|
const v = Number(ftpInput.value);
|
|
|
|
|
return Number.isFinite(v) && v > 0 ? v : 250;
|
|
|
|
|
@ -429,6 +443,60 @@ let workoutStartMs = 0;
|
|
|
|
|
let workoutSaveName = "workout";
|
|
|
|
|
let lastSampleSecs = -1;
|
|
|
|
|
|
|
|
|
|
// --- Профиль воркаута (сегменты) ---
|
|
|
|
|
|
|
|
|
|
function segmentPowerPct(seg) {
|
|
|
|
|
const p = seg.power;
|
|
|
|
|
if (!p) return null;
|
|
|
|
|
if (p.type === "fixed") return p.percent;
|
|
|
|
|
if (p.type === "ramp") return (p.from + p.to) / 2;
|
|
|
|
|
if (p.type === "range") return (p.low + p.high) / 2;
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function segmentColor(seg) {
|
|
|
|
|
const pct = segmentPowerPct(seg);
|
|
|
|
|
return pct == null ? "var(--c-surface-2)" : ZONE_COLORS[percentZone(pct)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function segmentLabel(seg) {
|
|
|
|
|
const name = seg.text || "без имени";
|
|
|
|
|
const dur = formatClock(seg.duration_secs);
|
|
|
|
|
let power = "";
|
|
|
|
|
if (seg.power) {
|
|
|
|
|
const p = seg.power;
|
|
|
|
|
if (p.type === "fixed") power = `${p.percent}%`;
|
|
|
|
|
else if (p.type === "ramp") power = `${p.from}→${p.to}%`;
|
|
|
|
|
else if (p.type === "range") power = `${p.low}–${p.high}%`;
|
|
|
|
|
}
|
|
|
|
|
const cad = seg.cadence_rpm ? ` · ${seg.cadence_rpm} rpm` : "";
|
|
|
|
|
return `${name} · ${dur}${power ? ` · ${power}` : ""}${cad}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function renderWorkoutProfile(path) {
|
|
|
|
|
try {
|
|
|
|
|
const segments = await invoke("get_segments", { path });
|
|
|
|
|
workoutProfile.replaceChildren();
|
|
|
|
|
if (!segments.length) return;
|
|
|
|
|
for (const seg of segments) {
|
|
|
|
|
const block = el("div", "workout-profile__seg");
|
|
|
|
|
block.style.flexGrow = String(Math.max(seg.duration_secs, 1));
|
|
|
|
|
block.style.background = segmentColor(seg);
|
|
|
|
|
block.title = segmentLabel(seg);
|
|
|
|
|
workoutProfile.append(block);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function highlightSegment(idx) {
|
|
|
|
|
const blocks = workoutProfile.children;
|
|
|
|
|
for (let i = 0; i < blocks.length; i++) {
|
|
|
|
|
blocks[i].classList.toggle("workout-profile__seg--current", i === idx);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatClock(sec) {
|
|
|
|
|
const m = Math.floor(sec / 60);
|
|
|
|
|
const s = Math.floor(sec % 60);
|
|
|
|
|
@ -461,6 +529,11 @@ workoutSelect.addEventListener("change", () => {
|
|
|
|
|
const w = selectedWorkout();
|
|
|
|
|
workoutName.textContent = w ? w.name : "—";
|
|
|
|
|
workoutMeta.textContent = w ? `${w.duration_min} мин · load ${w.load ?? "—"}` : "";
|
|
|
|
|
if (w) {
|
|
|
|
|
renderWorkoutProfile(w.path);
|
|
|
|
|
} else {
|
|
|
|
|
workoutProfile.replaceChildren();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function setRunning(active) {
|
|
|
|
|
@ -476,6 +549,7 @@ async function setRunning(active) {
|
|
|
|
|
workoutBar.style.width = "0%";
|
|
|
|
|
timerEl.textContent = "00:00";
|
|
|
|
|
clearTargets();
|
|
|
|
|
highlightSegment(-1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -521,6 +595,7 @@ workoutStopBtn.addEventListener("click", async () => {
|
|
|
|
|
listen("workout-progress", (event) => {
|
|
|
|
|
const p = event.payload;
|
|
|
|
|
workoutStep.textContent = p.step_name || `шаг ${p.step_index + 1}`;
|
|
|
|
|
highlightSegment(p.step_index);
|
|
|
|
|
targetWatts = p.target_watts;
|
|
|
|
|
targetCadence = p.target_cadence;
|
|
|
|
|
updatePowerDeviation();
|
|
|
|
|
|