> heu-keep-keep

构建 HEU-keep:Keep风格锻炼卡生成器
// ─────────────────────────────────────────────────────────────
OUTPUT 005 SEED 1133785527 DATE 2026-02-20 CHARS 12,654 TAGS javascript canvas html2canvas indexeddb keep heu algorithms

什么是 HEU-keep

HEU-keep 是一个完全在浏览器里运行的生成器。它的输出是一张锻炼摘要卡片——视觉上和 Keep 健身 App 在你跑完步之后自动生成的那张卡片几乎一模一样。区别在于:这张卡片上的跑步轨迹不是 Keep 用你的手机 GPS 记录的真实轨迹,而是用数学算法生成的一条逼真的模拟轨迹——而且这条轨迹被叠加在哈尔滨工程大学的实际校园地图上。HEU 有三个主要的田径场——南体育场、军工操场、北体育场——每一个都有自己独特的几何形状和卫星底图。你在左边面板填入跑步的参数——距离、配速、日期、温度、湿度、风速——右边实时预览卡片在各个参数变化下的效果。满意之后,点击一个按钮,浏览器就吐出一张高分辨率的 PNG 图片,你可以直接保存或者分享到任何地方。

我为什么做这个

2025 年冬天我在校园里规律跑步。哈尔滨的冬天——你需要非常强的自我驱动力才能在零下二十几度的天气里出门跑上五公里。跑完之后 Keep App 会给你生成一张总结卡片:一张小地图,上面用彩色的线条画出了你的 GPS 轨迹,旁边标注了配速、总时长、消耗的卡路里、当时的天气数据。这张卡片在社交媒体上很好看——它是一种"我完成了这件事"的证明。

但我想要能在没有真的去跑步的情况下也能生成这些卡片。不是弄虚作假——是因为有些跑步我是手动记录的(没用 Keep,只戴了一块手表),有些跑步我想在发朋友圈之前调整一下展示参数(比如把配速的单位换一下,或者把地图换成校园里更好看的那张),有些纯粹就是想做出来看看。三个田径场的形状各不相同——南体育场是标准的 400 米跑道,军工操场是一个稍微不规则的椭圆形,北体育场更小一些。用 Keep 的通用地图模板来做的话,轨迹画在哪个场地上看起来都不对劲——圆的半径不对,直道的长度不对,整个形状的比例不对。我需要轨迹看起来像是属于那片具体的场地的。

这个项目最初只是一个单独的 HTML 文件——所有的 CSS 内联写在 <style> 标签里,所有的 JavaScript 写在一个 <script> 标签里,Canvas 元素直接放在 body 里面。后来在晚间断断续续地做了两个多月——它长成了 11 个独立的 JS 模块文件、两种可以切换的视觉主题(Classic 和 Liquid)、还有一个可选的 Python Flask 后端用于在服务器端生成轨迹坐标。

技术栈

  • 前端:纯 HTML + CSS + JavaScript。没有 React,没有 Vue,没有 npm,没有 webpack。11 个 JS 文件按照依赖顺序通过 <script> 标签逐个加载——onload.js 最先加载(它负责初始化 IndexedDB 和事件监听),然后是 init.js(表单初始化和事件绑定),然后是各个功能模块按需引入。
  • Canvas API:所有的轨迹绘制——不论是自动生成的多圈跑步轨迹,还是用户用手指在触摸屏上手动画的笔画——全部通过 HTML5 Canvas 的 2D 渲染上下文来完成。没有使用 SVG。Canvas 在处理大量线段(一整条轨迹包含几千个坐标点)时的性能远好于 SVG。
  • 导出:html2canvas 1.4.1 库负责把预览区域的那一块 DOM 渲染成一张可下载的 PNG 图片。用户可以调整导出分辨率(默认是预览尺寸的 2 倍,保证 Retina 屏幕上的清晰度)。
  • 持久化:浏览器原生的 IndexedDB。数据库名叫 MyDatabase,里面有三个 object store(可以理解为三张表)——user_info(用户的所有配置项)、user_portrait(用户上传的头像图片,以 base64 字符串存储)、user_bgimg(用户上传的自定义背景地图图片)。
  • 后端(可选):Flask + NumPy。一个 Python 脚本 Json2Png.py 暴露了一个 /generate-track 端点,用 NumPy 在服务端生成轨迹坐标数组,以 JSON 格式返回。这个后端是纯可选的——前端自己就能在浏览器里生成轨迹。后端的存在是为了在某些性能较差的手机上提供更快的轨迹生成速度。
  • 字体:DINCond-Bold.otf 是 Keep App 实际使用的品牌字体——卡片上的数字(配速、时长、卡路里)就是用这个字体渲染的,不用它的话卡片看起来就"不太像 Keep"。STKAITI.TTF 是华文楷体,用于卡片上中文文字的渲染。

1. 轨迹生成:椭圆分解

整个项目中最核心的技术挑战——生成一条看起来像是真实跑者跑出来的轨迹,而不是一个完美的几何椭圆。drawMine.js 这个文件实现了完整的轨迹生成算法。它的基本思路是:先生成一个标准的体育场形状的路径(一个矩形加两个半圆的"田径场"形状,英文叫 stadium shape),然后用三层随机噪声来逐点破坏这条完美路径——随机游走漂移(模拟跑者逐渐偏离理想路线)、GPS 量化抖动(模拟手机 GPS 的定位误差)、以及圈与圈之间的参数微调(确保每一圈看起来都不完全一样)。

1.1 四段式单圈构造

田径场的一圈被分解为四个连续的几何段,按照一个顺时针跑者经过的顺序依次绘制:

  1. 右半圆:从角度 -π/2(田径场顶部正中间)开始,顺时针走半个圆到 π/2(底部正中间),每一步的弧长为 STEP / radius 弧度。这个 STEP 是一个控制轨迹密度的参数——STEP 越小,产生的坐标点越多,轨迹越平滑但也越耗费 CPU
  2. 底部直道:从右到左沿田径场底部直道横向移动,每一步的步长是一个固定的像素距离。直道的两个端点分别是右半圆的结束点和左半圆的起始点
  3. 左半圆:从 π/2 走到 1.5π(即 3π/2),完成左边半个圆的弧线
  4. 顶部直道:从左到右沿顶部直道横向移动,闭合回到起点,完成完整的一圈

体育场轨迹由五个参数定义:中心点坐标 (cx, cy)、直道段的长度、半圆的半径、以及角度步长(STEP)。不同的田径场——南体育场、军工操场、北体育场——使用不同的参数组合。南体育场是标准 400 米跑道,半径大约 36.5 米,直道大约 42.5 米。军工操场略小,半径约 32 米,直道约 38 米。这些参数都是从 Google Maps 的卫星图上手动测量并换算得到的——没有官方数据,纯粹是拿尺子在屏幕上量的。

在生成多圈轨迹时,每一圈的参数不会完全相同——半径会随机偏移 ±3 像素,中心点会小幅漂移 ±1-2 像素。这意味着第二圈不会完美地覆盖在第一圈上面——你会看到相邻两圈之间有微小的偏移,这恰恰是真实跑者会产生的那种"每一圈都差不多但又不完全重叠"的效果。

1.2 随机游走漂移

在按照几何公式算出了每一圈的"理想"坐标之后,算法对每一个坐标点应用一个随机游走漂移。这个漂移模拟的是真实跑者不会严格按照理想弧线跑步——他会在直道上稍微左右摆动,在弯道上可能会切内圈或者跑到外圈去:

wanderX += (Math.random() - 0.5) * 1.5;
wanderY += (Math.random() - 0.5) * 1.5;
wanderX *= 0.95;  // 衰减因子——把路径慢慢地拉回到中心线
wanderY *= 0.95;

这段代码做了三件事:每一步在 X 和 Y 方向上加上一个范围在 [-0.75, 0.75] 之间的微小随机偏移(Math.random() - 0.5 产生 [-0.5, 0.5] 的均匀分布,乘以 1.5 后变成 [-0.75, 0.75]);然后最关键的一步——每一步都把当前的累计漂移值乘以 0.95。这个 0.95 的衰减因子是整个漂移模型的灵魂。如果去掉它(即乘以 1.0),漂移量会无限累积——跑了五圈之后轨迹就漂到场外去了,跑了十圈之后轨迹已经漂到隔壁教学楼了。乘以 0.95 意味着每一步漂移的 5% 被"拉回来"——大约经过 40 个采样点(对应大约 200 米的实际跑程),累计漂移就回归到接近于零。在数学上,这是一个离散版本的 Ornstein-Uhlenbeck 均值回归随机过程——它描述了在随机扰动和向均值回归的拉力共同作用下系统的演化轨迹。

1.3 GPS 量化抖动

在随机游走漂移之后,每个坐标点还被额外加上了一个 ±1 像素的均匀随机抖动(在 X 和 Y 两个方向上独立施加)。这个抖动模拟的是消费级 GPS 芯片的量化噪声——手机的 GPS 定位精度大约是 3 到 5 米,在 1:200 比例尺的地图上对应大约 15 到 25 像素,但持续性的偏差已经在随机游走中体现了,这里只模拟单次定位的随机量化误差。三层效果的叠加——多圈几何结构 + 均值回归随机游走 + 均匀量化抖动——渲染出来的轨迹人眼看过去会认为"这就是 Keep 记录的那条真实跑步轨迹"。

1.4 入场 stub 和整体旋转

真实的跑步轨迹不是从田径场正中间开始的。跑者通常从场外的某个点——比如田径场入口——走到跑道上,然后才开始跑。HEU-keep 模拟了这个细节:从场外随机的一个起点(在圈起点的右侧 20 到 50 像素、上方 20 到 60 像素处),生成一条 7 个控制点的 Bézier 曲线,平滑地插值连接到第一个圈起点。这条曲线还叠加了一条正弦弧线(sin(t * π/2) * 10)来增加微小的弯曲——模拟跑者不是严格走直线进场,而是稍微绕了一个弧线。

最后,整个坐标集合——包括所有的圈点、漂移、抖动、入场 stub——被一个 2D 旋转矩阵整体旋转 -4 度。这个旋转角度的依据是:哈工程的三个田径场在卫星图上都大约偏离正北方向 4 度(顺时针方向)。如果不做这个旋转,轨迹画在地图上是完全水平的,但下面的卫星底图显示跑道是微微倾斜的——轨迹和跑道会对不齐。

1.5 圈数和部分圈的截断

每条轨迹生成 5 到 8 个完整的圈(圈数随机,避免所有卡片看起来圈数一样)。在最后一个完整圈之后,追加一个部分圈——长度随机在完整圈的 10% 到 40% 之间。这个部分圈的存在确保轨迹的终点不会精确地落在起点上——如果每次轨迹都完美闭合回起点,生成的卡片之间会有一个明显的"这也是假的"的视觉破绽。部分圈让终点随机地落在田径场的某个位置,看起来像是在跑完最后一圈之后又往前走了一段。

2. 沿路径的颜色渐变系统

轨迹的颜色不是单调的一条绿线。当用户开启了"渐变颜色"选项时,线条的颜色在路径上不断地变化——从深绿渐变到浅绿,再到黄色,再到橙色,再到红色。这种颜色变化模拟的是 Keep 的配速颜色编码——绿色代表快速的段(配速较快),红色代表慢速的段(配速较慢)。

2.1 颜色状态机

渐变系统使用一个概率驱动的有限状态机。路径上的每一点,系统投掷一次随机骰子——投中概率由用户配置(默认 0.5,意味着平均每两个点触发一次颜色切换的判断)。如果投中了,就从当前颜色开始向一个新的目标颜色过渡。过渡不是瞬间完成的——它跨越用户可配置的步数范围("范围",通过最小步数和最大步数来控制),在这段步数内逐步完成 RGB 三个通道的线性插值。过渡使用二次 ease-in/ease-out 曲线来实现平滑的视觉变化:

var t = bs_now / bs_range;           // 过渡进度 0→1
var ease = 4 * t * (1 - t);         // 二次 ease 曲线,在 t=0.5 处达到峰值
var r = base_r + ease * target_r;   // 红色通道:在基值和目标值之间按 ease 比例混合
var g = base_g + ease * target_g;
var b = base_b + ease * target_b;

这条 ease 曲线的形状是一个开口向下的抛物线——在过渡的两端(接近 0 和接近 1)变化速度慢,在中段(接近 0.5)变化速度快。这让颜色过渡看起来是流畅的,而不是机械的线性渐变。目标颜色从两个预设的方向调色板中随机选取——正向调色板偏向暖黄绿色(模拟逐渐加速),负向调色板偏向冷蓝绿色(模拟逐渐减速)。方向由随机符号决定。

2.2 Canvas 线性渐变

在 Canvas 上实际绘制的时候,不是给每个坐标点赋一个单独的颜色然后画点——那样轨迹会看起来像一串离散的点,而不是一条连续的线。代码使用 Canvas 的 createLinearGradient() 方法,在前一个坐标点和当前坐标点之间创建一个线性渐变对象。渐变的两端分别设置为前一个点的颜色和当前点的颜色,然后用 lineTo() 把这两个点连起来。Canvas 的渐变渲染引擎会自动处理两点之间的颜色过渡——结果是一段一段平滑的颜色流动,沿着整条轨迹从绿色漂移到黄色再漂移到红色。

3. 离屏导出管线

用户点击"保存高清图片"之后发生的事情比看起来复杂得多。html2canvas 库的工作方式是取一个 DOM 节点,把它渲染成一张 Canvas 位图,然后导出为 PNG。但这张预览卡片是用大量 CSS 特效搭建起来的——毛玻璃模糊(backdrop-filter: blur())、环境光的背景渐变、噪声纹理叠加层、圆角、盒阴影。html2canvas 对大部分这些 CSS 特效的支持是不完整的——遇到它不认识的东西,它不报错,但输出结果会有各种奇怪的伪影。

3.1 幽灵 DOM 克隆

导出的第一步是把整个预览卡片区域进行深度克隆——originalNode.cloneNode(true)——然后把克隆出来的节点插入到一个用户看不见的离屏容器中,这个容器的位置被 CSS 设为 left: -9999px; top: -9999px。克隆节点的宽度被锁定为原始节点的 offsetWidth 值(像素级精确),防止克隆节点因为脱离正常文档流而发生意外的重排(reflow)。

3.2 手动 Canvas 像素复制

这里有一个经典的坑:cloneNode(true) 不会复制 Canvas 元素的像素数据。克隆体中的所有 <canvas> 元素都是空白的——它们的 bitmap 缓冲区是零初始化的。代码必须遍历克隆体中的所有 Canvas 元素,找到它们在原始节点中的对应 Canvas,然后用 destCtx.drawImage(originalCanvas, 0, 0) 把像素一个个拷贝过来。这包括轨迹叠加层的 Canvas 和任何其他动态生成的 Canvas 元素。

3.3 剥离干扰 CSS 属性

在所有东西都准备就绪之后、调用 html2canvas 之前,代码遍历克隆体中的所有元素,把它们身上会干扰 html2canvas 布局计算的 CSS 属性全部移除:

clonedNode.style.transform = 'none';    // 旋转变换会错位
clonedNode.style.boxShadow = 'none';    // 阴影渲染出来是一个模糊的黑块
clonedNode.style.transition = 'none';   // 过渡动画的中间状态不可预测

剥离之后的 DOM 树是一棵干净的、静态的、没有动画、没有变换、没有阴影的纯结构树。html2canvas 可以毫无问题地把这棵树栅格化成一张干净的 PNG。

3.4 三种下载回退机制

导出的 PNG 图像通过三种依次回退的方式提供给用户:首选方式是通过一个 <a> 标签的 HTML5 download 属性触发浏览器下载;如果浏览器不支持 download 属性或者阻止了自动下载(某些移动端浏览器会这么做),回退到在新标签页中打开 base64 data URL,用户手动长按保存;如果连新标签页都打不开(Safari 对 data URL 的长度有限制),最终回退到通过 Blob 对象创建一个 blob: URL 来触发下载。文件名从卡片的锻炼日期自动生成——"2025-12-15-晨跑-5km.png"这种格式。

4. 实时预览和派生指标

左侧的控制面板上大约有二十多个输入控件——文本输入框、数字输入框、下拉选择框、颜色选择器、开关按钮。每一个控件的 onchange 或者 oninput 事件都绑定了同一个核心处理链路:setData() 函数把表单中所有控件的当前值读取到一个配置对象中,然后调用 render() 函数,把配置对象映射为预览 DOM 的更新操作。你在左边改动任何一个参数——包括打字输入配速数字——右边预览卡片都会在几十毫秒内更新(只更新变化的部分,不重新渲染整个 DOM)。

两个数据指标是在客户端实时计算出来的,不是从 Keep 导入的:

  • 运动时长:配速(用户以 mm'ss" 格式输入,比如 5'30" 表示每公里 5 分 30 秒)首先被转换为十进制分钟数(5 + 30/60 = 5.5 分钟/公里),然后乘以距离(公里数),得到的运动时长再被拆分为小时、分钟、秒。举个例子:5.5 分钟/公里 × 8 公里 = 44 分钟 = 0 小时 44 分 0 秒
  • 卡路里:使用简化公式 69 × 距离(公里) × 1.036。这个公式的来源是 MET(代谢当量)法——以 8 km/h 的速度跑步的 MET 值约为 8.3,一个 70kg 的人每小时消耗约 8.3 × 70 = 581 千卡,即每公里约 72.6 千卡。公式中的 69 是这个值的一个近似和降权(因为普通大学生的平均体重低于 70kg),1.036 是环境温度校正系数

5. IndexedDB 持久化

浏览器关掉之后表单里填的数据就全没了。为了解决这个问题,HEU-keep 使用 IndexedDB 来持久化所有用户数据。数据库 MyDatabase 包含三个 object store:

  • user_info:存储用户的所有配置——用户名(显示在卡片上)、标题、配速和距离的默认范围值、背景图片的选择(用内置的南体育场/军工操场/北体育场之一、还是用自己上传的图片)、颜色渐变设置(是否开启渐变、触发概率、过渡范围)、导出图片的宽度、是否开启自动绘制
  • user_portrait:用户上传的头像照片,以 base64 编码的 data URL 字符串存储。头像在卡片上显示为一个圆形裁剪的缩略图——就像 Keep 里你的个人头像一样
  • user_bgimg:用户上传的自定义背景地图图片。如果你觉得内置的三种田径场地图都不够好(比如你想用自己拍的照片),你可以上传任意图片作为背景

IndexedDB 的 API 是一个回调地狱——每个操作(打开数据库、创建事务、读写数据)都需要注册 onsuccessonerror 回调。为了简化代码,所有的 IndexedDB 操作被包装成了 Promise,在上层使用 async/await 语法。页面加载时,首先打开数据库连接,连接成功后触发一个自定义 DOM 事件 dbReady——onload.js 监听这个事件,事件触发后才开始读取数据库中保存的配置并回写到表单中。页面在加载时还会调用 navigator.storage.persist() 请求浏览器将这个网站的存储标记为"持久化"——这可以降低浏览器在存储空间紧张时自动清理这个网站的 IndexedDB 数据的概率。

6. 移动端 CSS 适配

左侧控制面板的布局在手机屏幕上需要极高的空间利用率——大约 20 个输入控件挤在宽度只有 375px 的区域里,还要保证用户可以舒服地用手指点选。几个为了移动端而设计的 CSS 技巧:

  • 自适应宽度的输入框:固定的 width: 120px 输入框在当你只需要输入"18"(温度)或者"65"(湿度百分比)的时候会浪费大量水平空间。代码使用 Canvas 2D 的 measureText() 方法,在用户输入文字的瞬间精确测量这段文字在当前字体下的像素宽度,然后通过 requestAnimationFrame 批量更新输入框的 CSS width——对于"18"这两个字符,宽度可能只需要 40px;对于"5'30""这种配速写法,宽度需要大约 80px。这保证了每个输入框只占用它实际需要的宽度,不浪费任何空间
  • 拦截原生 value setter:温度和湿度输入框的值可能是由程序而不是用户写入的(比如从天气 API 获取数据后自动填入)。但 input 事件只在用户交互时触发,程序写入不会触发。代码使用 Object.defineProperty 来钩入输入框 DOM 元素的 value 属性的 setter——任何时候 value 被修改(不管是用户输入还是程序写入),都会触发宽度重新计算的逻辑
  • 下拉选择框的尺寸约束:使用 width: fit-content 让下拉框自动适配内容宽度,同时设置 min-width: 60px(太窄了不好点)和 max-width: 65%(在极窄屏幕上防止下拉框溢出到屏幕外面去)

7. 手动轨迹绘制

除了自动随机生成轨迹之外,用户还可以自己用手指在背景地图上手动画出轨迹。draw_personalization.js 模块在背景地图的上方覆盖了一个透明 Canvas,监听三个指针事件——pointerdown(手指按下开始画线)、pointermove(手指移动时追加线段)、pointerup(手指抬起结束这条笔画)。每一条笔画被记录为一个数组——数组中每个元素是一个 {action: "move"|"up"|"down", x: number, y: number} 对象。这个数组可以被 JSON 序列化保存到 drawingActions.json 中,之后可以重新加载并重放——你画了一次,下次可以直接加载上次的绘制记录复用。

颜色渐变系统在手动模式下完全一样运作——相同的概率驱动颜色切换逻辑,在 pointermove 事件触发时沿线段做 RGB 插值。自动生成和手动绘制两套系统共用同一个渲染管线,唯一的区别是坐标点来源不同。

8. Flask 后端(完全可选)

Json2Png.py 这个 Python 脚本做的事情和前端 drawMine.js 几乎一模一样——用 NumPy 在服务端生成轨迹坐标数组。它暴露了一个 HTTP POST 端点 /generate-track,接受和前端一样的参数(中心点坐标、半径、直道长度、圈数、随机种子),返回一个 JSON 数组——数组中每个元素是 {action, x, y} 格式的坐标对象。前端收到这个 JSON 后,把它交给 Json2Draw() 函数,就像重放手动绘制的笔画一样把这些坐标绘制到 Canvas 上。

为什么要有这个后端?因为在一部中低端 Android 手机上,主线程 JavaScript 生成 8 圈 × 每圈约 300 个坐标点 = 共约 2,400 个坐标点,加上随机游走和 GPS 抖动的逐点计算大约需要 200 到 400 毫秒——这在用户感知上是一个可以注意到的延迟。如果把这些计算卸载到服务器端的 NumPy 上(NumPy 的浮点运算在原生 C 代码中执行),响应时间可以降到 50 毫秒以内。不过如果你的手机上这个延迟对你来说无所谓,后端是完全不需要的。

9. 两种视觉主题

项目包含两个入口 HTML 文件——index.html(Classic 主题)和 liquid.html(Liquid 主题)。两个文件共享所有的 JavaScript 逻辑(11 个模块文件通过相同的 <script> 依赖链加载),唯一的区别在于 CSS:

  • Classic(index.html):毛玻璃卡片风格。预览卡片使用 backdrop-filter: blur(12px) 实现磨砂玻璃的半透明效果,卡片下方的背景是一层低多边形的环境光渐变叠加了一层噪声纹理。表单控件使用圆角边框——这给了控制面板一种"iOS 设置页面"的感觉。这是默认主题,在项目早期就是唯一的主题
  • Liquid(liquid.html):不同的卡片布局——卡片更宽,留白更多,配色方案偏冷色调。控制面板的布局也重新组织了。它更像是一个实验性的替代视觉方案,不是 Classic 的改进版

两个主题的功能完全一致——你可以在它们之间切换而不会丢失任何数据(因为数据存在 IndexedDB 中,两个页面共享同一个数据库)。

部署

HEU-keep 是一个纯静态站点,托管在 Cloudflare Pages 上。部署流程是把代码 push 到 GitHub 仓库的 master 分支,Cloudflare Pages 自动检测到变更并触发构建和部署。没有构建步骤——Pages 只是把仓库里的 HTML、CSS、JS 文件原样复制到边缘节点上。演示站点在 master.heu-keep-demo.pages.dev。Flask 后端如果需要的话单独部署在一台 VPS 上。

Star on GitHub

参见:HEU-keep 操作文档——涵盖工作流程、轨迹生成、导出功能和故障排查的分步用户指南。

← cd ../首页