news 2026/8/29 17:29:04

健身重量进度计算器开发实战:1RM估算与渐进超负荷可视化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
健身重量进度计算器开发实战:1RM估算与渐进超负荷可视化

在实际的训练场景里,很多人记录了每次卧推、深蹲用了多少重量、做了多少次,却很难说清自己的训练到底有没有进步。只看杠铃片重量并不等于训练强度,因为 60kg 做 5 次和 40kg 做 15 次,对力量的评估完全不同。gym weight progress calculator这个项目要解决的,就是这类问题:它根据输入的动作、重量、次数和组数,自动计算杠铃配重组合,估算出对应的 1RM,并且把历史记录用折线图展示出来,让渐进超负荷的变化趋势变得可见。

这篇文章会带你从零实现一个可运行的健身重量进度计算器。最终可以得到一个浏览器中直接使用的单页工具,能完成三类关键任务:一是把目标总重量拆成杠铃两侧的配重片组合;二是用经验公式估算 1RM;三是把每次训练记录持久化到本地,并按日期绘制趋势图。适合正在学习前端工程化、想做一个小而完整的实战项目,或者希望给自己训练加一个数据工具的开发者阅读。

1. 先理解一个健身计算器真正要算的是什么

不少初学开发者拿到这个题目,第一反应是“做一个加减乘除表单”。但实际动手后会发现,真正的难点不在界面,而在三个需要想清楚的业务规则:杠铃配重如何拆片、1RM 如何估算、历史记录如何组织。这三个规则对应着工具的核心价值,也是本文实现的主线。

1.1 核心问题一:怎么把目标重量拆成杠铃片组合

真实健身房里,杠铃片通常有 1.25kg、2.5kg、5kg、10kg、15kg、20kg、25kg 等规格。训练者说“今天深蹲 60kg 做 5 次”,但走到杠铃前,他需要知道左右两边各放多少片。计算逻辑并不复杂:

总重量 = 空杆重量 + 2 × 单边配重总重量

例如标准空杆为 20kg,目标 60kg 时,单边需要承担 20kg。如果健身房有 20kg 片,单边放一片即可;如果只有 15kg 和 5kg,则单边是 15 + 5;如果只有 10kg,那就单边两片 10kg。实际项目中,还会遇到“目标重量无法被现有片规格整除”的情况,比如想用 62kg,但最小片是 2.5kg,单边 21kg 无法凑齐。这时工具需要明确提示用户调整目标重量。

这本质上是一个“用有限面值组合出目标数值”的问题。最直接的做法是贪心算法:从最大规格的片开始,能放多少放多少,剩余重量继续用次大规格片匹配。贪心在这里不一定总得到最少数量的解,但足够贴近真实场景,因为杠铃片规格本身具备倍数关系,实际项目可以直接使用并保持可读性。

1.2 核心问题二:1RM 是什么,以及为什么要估算

1RM(One Repetition Maximum)指一个动作能完成一次的最大重量。但普通人不会在日常训练中反复做单次极限测试,因为风险高、对神经系统消耗大。所以健身领域普遍使用“次最大重量推测最大重量”的经验公式,用“某个重量能完成多少次”来反推 1RM。

常见公式有两个:

Brzycki 公式:
1RM = 重量 × 36 ÷ (37 - 次数)

Epley 公式:
1RM = 重量 × (1 + 次数 ÷ 30)

Brzycki 在 1 到 10 次的区间内比较常用,对高次数训练的误差会增大。Epley 对偏高次数的适应性和稳定性要好一些,但它在 1 次时等于输入重量,2 次时是 1.0667 倍,增长速度比 Brzycki 更温和。实际项目不应只保留一个公式,而应在界面上允许用户切换,并且在结果旁边标注使用的是哪个公式。这样用户查看自己历史记录时,不会被“为什么今天和上周的 1RM 估算方法不同”迷惑。

1.3 核心问题三:记录不只是一行数据,要能形成趋势

只记录“2025-01-15 深蹲 60kg 5 次”没有太大意义,因为单次数据无法回答“我是否在变强”。工具需要把多次记录串成一条曲线,让用户看到重量和估算 1RM 的走向。这也是为什么数据结构里必须有日期、动作名称、重量、次数、组数和派生出来的 1RM 字段。

渐进超负荷的核心是“在相同动作上,让训练强度或容量随时间逐步提升”。所以可视化时,至少应该按动作分别展示两条趋势:原始重量的变化,以及估算 1RM 的变化。后者比前者更能排除“这次做了 3 次,上次做了 5 次”这种次数变化带来的干扰。

2. 技术选型与项目结构要先想清楚,否则越写越乱

这个项目涉及计算、持久化、图表绘制和页面交互。选择技术栈时,不一定要引入重型框架,关键是让每个职责边界清晰。下面的方案使用原生 JavaScript ES Module、localStorage 和 Canvas 绘图,不依赖构建工具,也能在本地静态服务器上直接运行。

2.1 为什么用原生 JavaScript 而不是 React 或 Vue

对于这种规模的工具,原生 JavaScript 完全足够。核心计算是纯函数,不依赖 DOM;页面只有表单、配重结果区、记录列表和折线图四个区域,状态量不多。引入框架反而会增加依赖安装和构建成本。

这里有一个更重要的工程依据:把计算逻辑写成纯函数后,无论将来迁移到 Vue、React 还是小程序,都可以复用。实际开发中最怕的不是功能复杂,而是业务逻辑和页面渲染耦合在一起。用原生 JavaScript 先把计算模块、存储模块、图表模块分离出来,本身就是一个很好的架构训练。

2.2 目录结构与文件职责

推荐按下面这种方式组织文件:

gym-progress-calculator/ ├── index.html ├── styles.css ├── src/ │ ├── main.js │ ├── calculations.js │ ├── storage.js │ └── chart.js └── test/ └── calculations.test.mjs

每个文件只做一件事:

文件职责
index.html页面结构、表单、展示区域
styles.css布局和基础样式
src/calculations.js配重计算、1RM 估算等纯函数
src/storage.jslocalStorage 读写、数据迁移兜底
src/chart.jsCanvas 折线图绘制
src/main.js事件绑定、组装模块、渲染逻辑
test/calculations.test.mjs计算函数的 Node 断言测试

2.3 数据结构与 localStorage 存储设计

localStorage 只能保存字符串,所以需要把记录数组序列化成 JSON。记录对象的结构设计如下:

{ "version": 1, "records": [ { "id": "20250115-153000-abc", "date": "2025-01-15", "movement": "Squat", "weight": 60, "reps": 5, "sets": 3, "oneRepMax": 67.5, "note": "今天状态正常" } ] }

存储 key 建议固定为gym-progress-records-v1。带上版本号的原因很简单:将来如果字段结构变化,比如增加 RPE 或单位字段,旧数据仍然可以识别并做迁移,而不是直接解析失败。

日期字段统一使用YYYY-MM-DD格式,理由有两个。第一,它是 ISO 8601 的子集,字符串排序结果等价于日期排序;第二,在图表绘制时可以直接按字符串排序,不需要额外解析时间戳。需要注意:表单输入框建议直接使用<input type="date">,避免用户在不同浏览器里输入不同格式。

3. 实现核心计算模块,先让数字正确

计算模块是整个工具的地基。配重组合算错,后面的 1RM 和图表都没有意义。所以这一章先实现两个核心纯函数,并配套测试。

3.1 杠铃配重计算:贪心算法实现

先定义默认配置:

// src/calculations.js export const BAR_WEIGHT = 20; export const PLATE_OPTIONS = [ { weight: 25, count: 4 }, { weight: 20, count: 4 }, { weight: 15, count: 4 }, { weight: 10, count: 4 }, { weight: 5, count: 4 }, { weight: 2.5, count: 4 }, { weight: 1.25, count: 4 } ]; function roundTo2(value) { return Math.round((value + Number.EPSILON) * 100) / 100; } export function calcPlates(targetWeight, barWeight = BAR_WEIGHT, plateOptions = PLATE_OPTIONS) { if (!Number.isFinite(targetWeight) || targetWeight <= 0) { return { ok: false, reason: '目标重量必须是大于 0 的数字' }; } const perSide = (targetWeight - barWeight) / 2; if (perSide < 0) { return { ok: false, reason: `目标重量不能小于空杆重量 ${barWeight}kg` }; } let remaining = perSide; const selected = []; for (const option of plateOptions) { if (option.count <= 0) continue; const maxUse = Math.min(Math.floor(remaining / option.weight), option.count); for (let i = 0; i < maxUse; i++) { selected.push(option.weight); } remaining = roundTo2(remaining - maxUse * option.weight); if (remaining < 0.001) break; } if (remaining > 0.001) { return { ok: false, reason: `无法用现有配重片精确组合,剩余 ${remaining}kg(请调整目标重量到最小片步进)` }; } const perSideTotal = selected.reduce((sum, w) => sum + w, 0); return { ok: true, perSide: selected, perSideTotal: roundTo2(perSideTotal), total: roundTo2(barWeight + 2 * perSideTotal) }; }

这段代码有几个关键细节:

第一,先计算每边需要多少重量。(targetWeight - barWeight) / 2这一步容易漏掉除以 2,结果会导致配对片数量翻倍。

第二,从大到小遍历配重片。Math.floor(remaining / option.weight)计算整片数量,Math.min限制不能超过配置中可用的片数。默认每种片有 4 片,足够覆盖常见重量。

第三,为什么要roundTo2。JavaScript 的浮点数在计算 2.5 的倍数时,会因为二进制表示产生类似0.004999999的误差。不收敛的话,最后判断remaining > 0.001会误判为无法组合。这里的 0.001 是容忍阈值,因为所有重量精度最多到 0.01kg。

第四,返回结构同时包含perSidetotal。前者用于界面展示“左侧:20 + 2.5,右侧:20 + 2.5”,后者用于让用户确认输入的目标重量确实被组装出来了。

3.2 1RM 估算:公式选择与边界处理

同样放在 calculations.js 中:

export function estimateOneRepMax(weight, reps, formula = 'brzycki') { if (!Number.isFinite(weight) || weight <= 0) { return null; } if (!Number.isInteger(reps) || reps < 1 || reps > 35) { return null; } if (formula === 'epley') { return roundTo2(weight * (1 + reps / 30)); } if (reps >= 37) { return null; } return roundTo2((weight * 36) / (37 - reps)); }

这里的边界处理很重要。Brzycki 公式的分母是37 - reps,当 reps 等于 37 时分母为 0,超过 37 时会变成负数,计算出的 1RM 数值没有意义。所以在函数入口直接限制 reps 最大为 35,可以避免界面上出现 Infinity 或负值。

Epley 公式没有分母风险,但超过 30 次以后,它对 1RM 的推演已经高度失真,所以统一限制在 1 到 35 次区间。实际肌肉耐力训练很少需要使用超过 30 次的组次。如果用户确实需要记录高次数耐力训练,也应该在 UI 上提示公式误差范围,而不是默默输出一个看似精确的数。

3.3 编写 Node 自测脚本,验证计算正确性

在 test 目录下创建一个不依赖测试框架的脚本:

// test/calculations.test.mjs import { calcPlates, estimateOneRepMax, BAR_WEIGHT } from '../src/calculations.js'; let passed = 0; let failed = 0; function assertEqual(actual, expected, message) { if (actual === expected) { passed++; } else { failed++; console.error(`FAIL: ${message}`); console.error(` expected: ${expected}`); console.error(` actual: ${actual}`); } } function assertApprox(actual, expected, gap, message) { if (Math.abs(actual - expected) <= gap) { passed++; } else { failed++; console.error(`FAIL: ${message}`); console.error(` expected ~${expected}, actual ${actual}`); } } // 配重测试 const r1 = calcPlates(60); assertEqual(r1.ok, true, '60kg 应该可以配重'); assertEqual(r1.total, 60, '60kg 配重总重量应为 60kg'); assertEqual(r1.perSide.includes(20), true, '单边应包含 20kg 片'); const r2 = calcPlates(62.5); assertEqual(r2.ok, true, '62.5kg 应该可以配重'); assertEqual(r2.total, 62.5, '62.5kg 配重总重量应为 62.5kg'); const r3 = calcPlates(19); assertEqual(r3.ok, false, '小于空杆重量时应返回失败'); const r4 = calcPlates(73.3); assertEqual(r4.ok, false, '73.3kg 无法用 1.25 步进精确组合'); // 1RM 测试 assertApprox(estimateOneRepMax(60, 5), 67.5, 0.01, 'Brzycki: 60kg 5次约 67.5kg'); assertApprox(estimateOneRepMax(60, 5, 'epley'), 70, 0.01, 'Epley: 60kg 5次约 70kg'); assertEqual(estimateOneRepMax(60, 0), null, '0 次应返回 null'); assertEqual(estimateOneRepMax(60, 40), null, '40 次应返回 null'); console.log(`通过 ${passed} 项,失败 ${failed} 项`); if (failed > 0) process.exit(1);

运行命令:

node test/calculations.test.mjs

预期输出:

通过 11 项,失败 0 项

这里要注意:estimateOneRepMax(60, 5)用 Brzycki 计算是60 * 36 / 32 = 67.5,用 Epley 计算是60 * 1.1667 = 70。两者差异不小,恰好说明为什么界面必须标注公式来源。测试脚本的价值在于,以后如果有人修改公式或配重逻辑,可以立刻发现数值回归。

4. 完成页面交互与记录持久化

计算模块就绪后,接下来做 HTML 表单、存储模块和主逻辑组装。这一章会给出一个可运行的完整页面骨架。

4.1 表单设计与数据校验

<!-- index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>健身重量进度计算器</title> <link rel="stylesheet" href="styles.css" /> </head> <body> <main class="container"> <h1>健身重量进度计算器</h1> <section class="record-form"> <h2>添加训练记录</h2> <form id="training-form"> <div class="form-row"> <label for="movement">动作名称</label> <input type="text" id="movement" name="movement" required placeholder="例如 Squat" /> </div> <div class="form-row"> <label for="date">日期</label> <input type="date" id="date" name="date" required /> </div> <div class="form-row"> <label for="weight">总重量(kg)</label> <input type="number" id="weight" name="weight" required min="1" step="0.5" placeholder="60" /> </div> <div class="form-row"> <label for="reps">次数</label> <input type="number" id="reps" name="reps" required min="1" max="35" step="1" placeholder="5" /> </div> <div class="form-row"> <label for="sets">组数</label> <input type="number" id="sets" name="sets" required min="1" max="20" step="1" value="3" /> </div> <div class="form-row"> <label for="formula">1RM 估算公式</label> <select id="formula" name="formula"> <option value="brzycki">Brzycki</option> <option value="epley">Epley</option> </select> </div> <div class="form-row"> <label for="note">备注(可选)</label> <input type="text" id="note" name="note" placeholder="状态、辅助重量等" /> </div> <button type="submit">计算并保存</button> </form> </section> <section class="result-area"> <h2>当前结果</h2> <div id="plate-result"></div> <div id="one-rm-result"></div> </section> <section class="chart-area"> <h2>1RM 趋势</h2> <canvas id="progress-chart" width="800" height="300"></canvas> </section> <section class="record-list"> <h2>历史记录</h2> <button id="export-btn" type="button">导出 JSON</button> <ul id="record-list"></ul> </section> </main> <script type="module" src="src/main.js"></script> </body> </html>

表单里有两个容易被忽视的设计。

第一,重量输入框step="0.5"允许半公斤输入,但实际配重最小步进可能受杠铃片限制。表单层放宽输入范围,计算层负责校验,这种“入口宽松、核心严格”的思路适合工具类项目。如果用户在输入框里限制得太死,比如必须输入 2.5 的倍数,会阻止用户先输入一个计划重量再查看误差提示,体验反而差。

第二,公式选择使用下拉框,而不是同时显示两个数值。这样设计是为了让界面保持简洁。如果用户想看两种公式的对比,可以在当前结果区之外再加一个展开面板。真实训练记录里,一旦用户选好公式,后续应该保持一致,否则 1RM 趋势会因公式切换产生不必要的波动。

4.2 存储模块:带版本保护和异常兜底

// src/storage.js const STORAGE_KEY = 'gym-progress-records-v1'; export function loadRecords() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!parsed || !Array.isArray(parsed.records)) { return []; } return parsed.records.filter(validateRecord); } catch (error) { console.error('加载本地记录失败:', error); return []; } } export function saveRecords(records) { try { const payload = JSON.stringify({ version: 1, records }); localStorage.setItem(STORAGE_KEY, payload); return true; } catch (error) { console.error('保存本地记录失败:', error); return false; } } export function buildRecord({ date, movement, weight, reps, sets, oneRepMax, note }) { return { id: `${date}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, date, movement: movement.trim(), weight: Number(weight), reps: Number(reps), sets: Number(sets), oneRepMax: Number(oneRepMax), note: note ? note.trim() : '' }; } function validateRecord(record) { return ( record && typeof record.date === 'string' && typeof record.movement === 'string' && Number.isFinite(record.weight) && Number.isFinite(record.reps) && Number.isFinite(record.oneRepMax) ); }

这段代码把持久化细节封装在 storage.js 中,页面主逻辑不需要关心 key 名称和 JSON 结构。try/catch是必须的,因为 localStorage 在不同场景下可能抛异常,例如 Safari 隐私模式或存储空间已满。加载失败时返回空数组,而不是让整个页面崩溃。

id 的生成方式虽然简单,但在单机场景足够唯一。如果后续要同步到后端,需要替换成服务端生成的 UUID。

4.3 主逻辑:提交表单、计算、保存、渲染

// src/main.js import { calcPlates, estimateOneRepMax } from './calculations.js'; import { loadRecords, saveRecords, buildRecord } from './storage.js'; import { drawProgressChart } from './chart.js'; const form = document.getElementById('training-form'); const plateResult = document.getElementById('plate-result'); const oneRmResult = document.getElementById('one-rm-result'); const recordList = document.getElementById('record-list'); const exportBtn = document.getElementById('export-btn'); const chartCanvas = document.getElementById('progress-chart'); let records = loadRecords(); function formatDate(dateStr) { return dateStr; } function renderRecords() { if (records.length === 0) { recordList.innerHTML = '<li>暂无记录</li>'; return; } recordList.innerHTML = ''; const sorted = [...records].sort((a, b) => b.date.localeCompare(a.date)); for (const record of sorted) { const li = document.createElement('li'); li.innerHTML = [ `<strong>${formatDate(record.date)}</strong>`, `${record.movement}`, `${record.weight}kg × ${record.reps} 次 × ${record.sets} 组`, `1RM≈${record.oneRepMax}kg` ].join(' · '); recordList.appendChild(li); } } function renderCurrentResult(formData) { const { movement, weight, reps } = formData; const plate = calcPlates(weight); if (plate.ok) { plateResult.textContent = `配重方案:单边 ${plate.perSide.join(' + ')},总重量 ${plate.total}kg`; } else { plateResult.textContent = `配重提示:${plate.reason}`; } const oneRepMax = estimateOneRepMax(weight, reps, formData.formula); if (oneRepMax) { oneRmResult.textContent = `${movement} 的估算 1RM 为 ${oneRepMax}kg(${formData.formula} 公式)`; } else { oneRmResult.textContent = '1RM 估算失败,请检查重量和次数范围'; } } form.addEventListener('submit', (event) => { event.preventDefault(); const formData = new FormData(form); const movement = formData.get('movement').trim(); const date = formData.get('date'); const weight = Number(formData.get('weight')); const reps = Number(formData.get('reps')); const sets = Number(formData.get('sets')); const formula = formData.get('formula'); const note = formData.get('note'); if (!movement || !date || !Number.isFinite(weight) || !Number.isFinite(reps)) { alert('请填写动作、日期、重量和次数'); return; } const oneRepMax = estimateOneRepMax(weight, reps, formula); renderCurrentResult({ movement, weight, reps, formula }); if (!oneRepMax) { return; } const record = buildRecord({ date, movement, weight, reps, sets, oneRepMax, note }); records.push(record); saveRecords(records); renderRecords(); drawProgressChart(chartCanvas, records); form.reset(); }); exportBtn.addEventListener('click', () => { const blob = new Blob([JSON.stringify({ version: 1, records }, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'gym-progress-backup.json'; a.click(); URL.revokeObjectURL(url); }); records.sort((a, b) => a.date.localeCompare(b.date)); renderRecords(); drawProgressChart(chartCanvas, records);

注意这里的排序方向差异。历史列表希望最新记录在最上面,所以使用b.date.localeCompare(a.date)。绘制图表时希望时间从左到右递增,所以主流程里先按日期升序排序,再把排序后的 records 传给图表模块。列表和图表使用不同的排序方向是合理的,但代码里要写清楚,避免复制粘贴时弄反。

4.4 表单重置和日期默认值

form.reset()会把表单恢复成 HTML 中的默认值。这一步结束后,日期输入框会清空,对用户不够友好。建议在页面加载时把日期设置为今天:

document.getElementById('date').value = new Date().toISOString().slice(0, 10);

需要注意时区问题。toISOString()返回的是 UTC 时间,在 UTC+8 时区下,如果本地时间是凌晨,日期可能会比本地日期早一天。对工具类项目可以接受,但如果要严格按本地日期,可以手动拼接:

function getTodayString() { const now = new Date(); const offset = now.getTimezoneOffset() * 60000; return new Date(now.getTime() - offset).toISOString().slice(0, 10); }

5. 用 Canvas 绘制 1RM 趋势图

图表是这个工具最有说服力的部分。它能直接回答“我的训练重量是否在上升”这个问题。这里选择 Canvas 而不是 SVG 或第三方图表库,是为了减少依赖,同时也可以展示原生绘图的基本思路。

5.1 图表绘制的基本思路

折线图的本质是“数据点坐标映射”。真实数据有日期和 1RM 两个维度,画布上每个点需要换算成 x 和 y:

// src/chart.js export function drawProgressChart(canvas, records) { const ctx = canvas.getContext('2d'); const width = canvas.width; const height = canvas.height; const padding = { top: 20, right: 20, bottom: 40, left: 50 }; ctx.clearRect(0, 0, width, height); const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date)); if (sorted.length === 0) { ctx.fillStyle = '#666'; ctx.fillText('暂无数据', width / 2 - 20, height / 2); return; } if (sorted.length === 1) { ctx.fillStyle = '#666'; ctx.fillText('至少需要两条记录才能绘制趋势图', width / 2 - 100, height / 2); return; } const values = sorted.map((r) => r.oneRepMax); const minValue = Math.floor(Math.min(...values) * 0.9); const maxValue = Math.ceil(Math.max(...values) * 1.1); const plotWidth = width - padding.left - padding.right; const plotHeight = height - padding.top - padding.bottom; const xStep = plotWidth / (sorted.length - 1); const getX = (index) => padding.left + index * xStep; const getY = (value) => padding.top + ((maxValue - value) / (maxValue - minValue)) * plotHeight; // 绘制坐标轴 ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padding.left, padding.top); ctx.lineTo(padding.left, height - padding.bottom); ctx.lineTo(width - padding.right, height - padding.bottom); ctx.stroke(); // 绘制网格线和 Y 轴刻度 ctx.fillStyle = '#666'; ctx.font = '12px sans-serif'; const yTicks = 4; for (let i = 0; i <= yTicks; i++) { const ratio = i / yTicks; const tickValue = maxValue - ratio * (maxValue - minValue); const y = padding.top + ratio * plotHeight; ctx.beginPath(); ctx.moveTo(padding.left - 4, y); ctx.lineTo(padding.left, y); ctx.stroke(); ctx.fillText(Math.round(tickValue), 10, y + 4); } // 绘制折线 ctx.strokeStyle = '#2d7dd2'; ctx.lineWidth = 2; ctx.beginPath(); sorted.forEach((record, index) => { const x = getX(index); const y = getY(record.oneRepMax); if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // 绘制数据点 ctx.fillStyle = '#2d7dd2'; sorted.forEach((record, index) => { const x = getX(index); const y = getY(record.oneRepMax); ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); }); }

图表中的 min 和 max 不是直接用原始最大值和最小值,而是各扩展 10%。这样折线不会贴到画布边缘,视觉上更舒服。如果所有记录的 1RM 都是 60kg,minValue 会是 54,maxValue 是 66,图表仍然可以展示一个水平的线。

5.2 区分动作名称过滤

上面代码把所有记录画在一个图里。如果用户同时记录深蹲、卧推、硬拉,会导致三条不同量级的数据混在一起,趋势图失去意义。一个简单的改进是:在 main.js 中获取当前选中的动作名称,按名称过滤图表数据。

// 可以结合一个动作筛选下拉框 const selectedMovement = document.getElementById('movement-filter').value; const filtered = selectedMovement ? records.filter((r) => r.movement === selectedMovement) : records; drawProgressChart(chartCanvas, filtered);

实际产品里,动作筛选通常做成下拉框,选项来自历史记录中出现的动作名称。这样用户每切换一个动作,看到的都是独立的重量变化曲线。

5.3 Canvas 在高 DPI 屏幕下的清晰度问题

默认 Canvas 在高分屏上会显得模糊,因为 Canvas 的 CSS 尺寸和实际像素尺寸不一致。简单处理方式是读取设备像素比,然后缩放画布:

function setupCanvas(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const width = rect.width; const height = rect.height; canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return { width, height }; }

注意这个函数会修改 canvas 的真实尺寸,绘制逻辑里应该使用返回的 CSS 逻辑尺寸,而不是canvas.width。这一个细节在移动端很常见,不做处理时图表边缘会明显发虚。

6. 运行验证与常见问题排查

代码写完后,需要验证的不只是“页面能不能打开”,还要验证计算是否正确、数据是否持久化、图表是否按预期渲染。这一章给出具体的验证步骤和常见问题排查路径。

6.1 本地运行方式

因为使用了 ES Module,不能直接双击 index.html 打开,必须通过 HTTP 服务访问。推荐使用 Node 自带的静态服务或 VS Code 的 Live Server。最简单的做法:

npx serve .

或者用 Python:

python3 -m http.server 8000

浏览器访问http://localhost:8000,打开控制台确认没有报错,然后输入一次 60kg、5 次、Brzycki 公式,预期看到配重方案:单边 20,1RM 约 67.5kg。

6.2 手工验证用例

推荐用下面几个用例核对计算逻辑:

目标重量预期单边配重说明
20kg空杆,不配重目标等于空杆重量
40kg10kg 一片单边 10kg
60kg20kg 一片单边 20kg
62.5kg20kg + 1.25kg单边 21.25kg
65kg20kg + 2.5kg单边 22.5kg
100kg25kg + 15kg(优先大规格)单边 40kg

验证时注意,贪心算法会选择“尽量用大规格片”的组合。因此 100kg 会输出25 + 15,而不是20 + 10 + 10。从减少装卸片次数的角度看,这是更贴近实际的选择。

6.3 检查 localStorage 中的数据

打开浏览器 DevTools,进入 Application 面板,在 Local Storage 下找到当前域名,查看gym-progress-records-v1的值。预期是包含 records 数组的 JSON 字符串。如果看不到数据,说明 saveRecords 执行失败,或者页面运行在隐私模式下。

6.4 针对常见现象,按这条链路排查

现象可能原因检查方式处理建议
1RM 显示 NaN次数超出公式范围或输入非法在 DevTools 中检查 weight、reps 是否为有效数字表单已限制 1 到 35,代码里再加一次兜底校验
配重提示“无法组合”目标重量不是最小片步进的倍数打印 remaining 值检查 1.25kg 片是否被包含在配置中;提示用户调整到 2.5 的倍数
刷新后记录消失localStorage 未写成功或读取失败Application 面板查看 key 是否存在检查 saveRecords 的 try/catch;排除隐私模式
折线图连线乱数据没有按日期排序在 main.js 中打印 sorted 数组确保绘制前先按 date.localeCompare 升序排序
高 DPI 下图表模糊未处理 devicePixelRatio用 setupCanvas 缩放画布使用逻辑尺寸绘制,调用 ctx.scale(dpr, dpr)
同一动作被画成多序列没有按动作过滤检查图表数据源是否过滤了 movement增加动作筛选下拉框

6.5 数据导入导出的价值

localStorage 本质上是浏览器域下的本地存储,用户清除浏览器数据、换电脑、换浏览器都会丢失记录。这也解释了为什么工具需要提供 JSON 导出按钮。更进一步,可以做一个导入按钮,让用户把导出的 JSON 文件再次读回来。注意导入时需要校验 JSON 结构,而不是直接records = parsed.records

7. 生产环境扩展方向与工程实践建议

这个工具如果只在本地跑,localStorage 已经够用。但如果想做成一个长期使用的训练数据工具,或者发布给其他健身者使用,还需要在工程上补齐几个关键能力。

能力本地工具状态生产环境建议
数据存储localStorage后端数据库 + 账号体系,或至少接入 IndexedDB 支持更大数据量
数据同步多设备同步、冲突处理策略
单位制式只有 kg增加 kg / lb 切换,存储时统一为 kg 或保留单位字段
多用户认证、权限、个人数据隔离
错误监控console日志上报、异常监控、版本灰度
数据迁移固定 key 带版本设计 migration 函数,按 version 逐级升级
离线支持依赖静态服务器增加 Service Worker,做成 PWA

7.1 从 localStorage 迁移到后端时的数据模型

如果要把记录同步到服务器,数据结构不宜直接沿用当前版本。建议服务端按用户维度拆分:

{ "userId": "user-uuid", "movement": "Squat", "records": [ { "date": "2025-01-15", "weightKg": 60, "reps": 5, "sets": 3, "oneRmKg": 67.5, "rpe": 8, "note": "" } ] }

数据库表设计时,最好把动作、记录分成两张表。动作表保存动作名称和用户 ID,记录表保存日期、重量、次数、组数和关联动作 ID。一个动作对应多条训练记录,这是典型的一对多关系。查询某个动作的趋势时,使用WHERE movement_id = ? ORDER BY date ASC

7.2 加入 RPE 反馈

1RM 公式只依赖重量和次数,但训练者当天状态差异很大。比如同样 60kg 做 5 次,状态好时感觉留有余力,状态差时接近力竭,两者的真实 1RM 可能不同。更精细的做法是让用户填写 RPE(自觉疲劳程度,通常 1 到 10 或 6 到 10 范围内)。有了 RPE,可以用 e1RM(estimated One Rep Max)公式计算:

e1RM = 重量 × (1 + (0.0333 × 实际重复次数)) × (10 - RPE 对应的次数换算)

实际生产中,RPE 的引入会让数据模型更复杂,但对长期跟踪力量变化有显著帮助。推荐作为第二期功能。

7.3 给初学者最好的三个练习建议

第一,把计算模块用其他语言重写。比如用 Python 实现同样的calcPlatesestimateOneRepMax,体会浮点精度在不同语言中的差异。第二,给图表模块增加“动作筛选下拉框”,并测试三个不同动作的曲线是否正确分离。第三,自己实现一个轻量 JSON 导入功能,格式不限,重点是理解“解析外部数据时必须做结构校验”这个原则。

如果只记住一个工程判断,就是:计算逻辑和页面渲染分离。纯函数放在独立模块里,用命令能测试,用浏览器能复用,将来无论 UI 怎么换,核心逻辑都能稳如磐石。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/29 17:27:39

从猜数字与掷骰子理解算法核心:二分查找、蒙特卡洛与工程思维

1. 项目概述&#xff1a;从“玩具”到“基石”的算法实践最近在整理过去的代码仓库&#xff0c;翻出了两个我早期写的“小玩意儿”&#xff1a;一个猜数字游戏和一个掷骰子模拟器。乍一看&#xff0c;这不过是编程入门课上的课后作业&#xff0c;用来熟悉循环和随机数。但当我以…

作者头像 李华
网站建设 2026/8/29 17:24:56

洛谷原创 P1445 樱花

P1445 [Violet] 樱花 题目 求关于 x,yx,yx,y 的方程 1x1y1n!\dfrac{1}{x} \dfrac{1}{y} \dfrac{1}{n!}x1​y1​n!1​ 有多少个正整数解。 1≤n≤1061 \le n \le 10^61≤n≤106。 思路 由于式子 1x1y1n!\dfrac{1}{x} \dfrac{1}{y} \dfrac{1}{n!}x1​y1​n!1​是分式&…

作者头像 李华
网站建设 2026/8/29 17:22:31

MySQL事务底层原理:redo log、undo log与MVCC的完整解析

1. 事务到底解决了什么问题&#xff1a;从"背概念"到"看本质"先问一句&#xff1a;你背了那么久的ACID&#xff0c;有没有想过一个问题——为什么MySQL的InnoDB引擎偏偏要用一套这么复杂的日志体系、锁体系、版本链体系&#xff0c;只为换一个"要么全…

作者头像 李华
网站建设 2026/8/29 17:17:13

移动App发版避坑指南:签名、版本号与自动化检查全攻略

做移动开发这几年&#xff0c;如果说哪个环节最让我焦虑&#xff0c;那一定是发版这件事。平时写代码、做需求、修Bug&#xff0c;反馈链路都很短&#xff0c;代码有问题跑一次就能发现。但发版不一样&#xff0c;它是一场跨开发、测试、产品、运营的联合行为&#xff0c;任何一…

作者头像 李华
网站建设 2026/8/29 17:14:35

QT按钮交互与信号槽机制实战:从基础控件到多线程通信

1. 项目概述&#xff1a;从按钮到交互的灵魂在桌面应用开发的世界里&#xff0c;QT框架以其强大的跨平台能力和优雅的C封装&#xff0c;一直是许多开发者的心头好。但一个应用如果只有静态的界面&#xff0c;那无异于一具没有灵魂的躯壳。真正让应用“活”起来的&#xff0c;是…

作者头像 李华