feat: real-time power and heart rate graph with power zones

pull/4/head
lambda 6 days ago
parent 8f90af6cf2
commit 0888056268
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -73,6 +73,10 @@
</div> </div>
</section> </section>
<section class="graph">
<canvas id="graph"></canvas>
</section>
<section class="workout-progress"> <section class="workout-progress">
<div class="workout-progress__head"> <div class="workout-progress__head">
<span class="workout-progress__step" id="workout-step">—</span> <span class="workout-progress__step" id="workout-step">—</span>

@ -125,6 +125,7 @@ async function connect(deviceId, btn, category, card) {
startBtn.addEventListener("click", async () => { startBtn.addEventListener("click", async () => {
connectScreen.hidden = true; connectScreen.hidden = true;
workoutScreen.hidden = false; workoutScreen.hidden = false;
sizeGraph();
await loadWorkouts(); await loadWorkouts();
}); });
@ -155,12 +156,16 @@ listen("telemetry", (event) => {
actualWatts = t.Power.watts; actualWatts = t.Power.watts;
setMetric("power-value", `${actualWatts} W`); setMetric("power-value", `${actualWatts} W`);
updatePowerDeviation(); updatePowerDeviation();
samplePower(actualWatts);
scheduleDraw();
} else if (t.Cadence) { } else if (t.Cadence) {
actualCadence = Math.round(t.Cadence.rpm); actualCadence = Math.round(t.Cadence.rpm);
setMetric("cadence-value", `${actualCadence} rpm`); setMetric("cadence-value", `${actualCadence} rpm`);
updateCadenceDeviation(); updateCadenceDeviation();
} else if (t.HeartRate) { } else if (t.HeartRate) {
setMetric("hr-value", t.HeartRate.bpm); setMetric("hr-value", t.HeartRate.bpm);
sampleHr(t.HeartRate.bpm);
scheduleDraw();
} }
}); });
@ -254,6 +259,158 @@ function clearTargets() {
updateCadenceDeviation(); 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 workouts = [];
let workoutPaused = false; let workoutPaused = false;

@ -356,6 +356,17 @@ body {
line-height: 1.2; line-height: 1.2;
} }
/* --- График мощности/пульса --- */
.graph canvas {
display: block;
width: 100%;
height: 140px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
}
/* --- Управление --- */ /* --- Управление --- */
.controls { .controls {

Loading…
Cancel
Save