—
diff --git a/src/main.js b/src/main.js
index 0448ba7..ecad020 100644
--- a/src/main.js
+++ b/src/main.js
@@ -125,6 +125,7 @@ async function connect(deviceId, btn, category, card) {
startBtn.addEventListener("click", async () => {
connectScreen.hidden = true;
workoutScreen.hidden = false;
+ sizeGraph();
await loadWorkouts();
});
@@ -155,12 +156,16 @@ listen("telemetry", (event) => {
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();
}
});
@@ -254,6 +259,158 @@ function clearTargets() {
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;
diff --git a/src/styles.css b/src/styles.css
index 8dd92f2..36f1a3c 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -356,6 +356,17 @@ body {
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 {