diff --git a/src/index.html b/src/index.html index 2124f1d..8c28b29 100644 --- a/src/index.html +++ b/src/index.html @@ -73,6 +73,10 @@ +
+ +
+
— 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 {