文章目录8 节
把 KeyStats 导出的 JSON 变成可交互的 3D 键盘热力图,难点不在 3D,而在数据清洗、组合键拆分、色阶的感知均匀性与键盘布局的坐标建模。本文逐层拆解这几个函数。
KeyStats 键盘热力图 的目标很朴素:把 KeyStats 导出的按键统计 JSON,渲染成一张可交互的三维键盘热力图——颜色深浅和键帽高度同时表示频次。它只有两三千行代码,但每一层都藏着被低估的细节。
一、输入清洗:三种结构,一个出口
真实世界的"导出文件"从来不止一种形状。src/lib/keystats.ts(392 行)一口气接受三种:
- 完整导出:
{version?, scope?, exportedAt?, currentStats, history}; - 单日记录:根节点直接带
keyPressCounts(没有history/currentStats),自动包装成{version: 1, scope: "currentDevice"}; - 历史映射:根节点是
日期 → 单日记录的映射(每个值必须是带keyPressCounts的对象),取最新日期作为currentStats。
清洗规则比"能读进来"更重要:
- 只保留
date、keyPresses、keyPressCounts,应用统计(appStats)、鼠标距离这类与键盘无关的字段直接丢弃——这是隐私边界,不是优化; date统一规约成YYYY-MM-DD;计数出现非数字、负数、非有限值时整天拒绝,而不是当成 0;history里的 key 与记录内date不一致直接抛错;- 错误信息是中文且面向用户(
无效的 JSON:根节点必须是对象),因为它们的终点是一个错误提示,而不是日志; - 派生函数有明确语义:
preferActiveDate取"最新且有按键"的一天;totalKeyPresses在keyPresses显式为 0 时以 0 为准(显式 0 是权威值,不能被求和覆盖)。
二、组合键:Ctrl+C 要同时计到 Ctrl 和 C
这是整个项目最容易做错的一步。normalizeHeatmapKey 先把原始键名规范化:去空格转大写、+ 归一成 =、单字符直接通过、F\d+ 识别功能键、NUM* 映射到 Num1..Num9 / Num. / Num+ / Num- / Num/ / Num* / NumEnter,再走约 90 条别名表(CTRL/CONTROL → Ctrl、OPTION/ALT → Option、WIN/CMD → Cmd、MENU → Apps、RETURN → Return、OEM_3 → \``、OEM_5 → ` 等);无法识别就返回 null,不猜。
然后是组合键拆分的两个坑:
Num+本身含+,所以拆分前先用占位符__KEYSTATS_NUM_PLUS__保护,拆分后再还原;连续++会追加一个字面+;- 拆分后每个 token 都记满分:
Ctrl+C同时给 Ctrl 和 C 各加一次——这样"某个组合键用得多"和"某个修饰键用得多"都能在图上体现。
布局层还有一层归并:resolveKeyCount 把通用修饰键折到物理左侧键上(Shift ← LShift + Shift、LCtrl ← LCtrl + Ctrl、LAlt ← LAlt + Option + Alt、LWin ← LWin + Cmd + Win、Return ← Return + Enter、Apps ← Apps + Menu),而右侧变体只吸收自己。因为热力图是按物理键位画的(全尺寸 ANSI 104 键),左侧那份数据必须有个落点。
三、色阶:为什么是 CIELAB + 对数 + 95 分位
src/lib/heatmap-color.ts(139 行)做了三层处理:
1 | // 1) 定义域取 95 分位,避免一个"神键"把其他键全压暗 |
- 95 分位定义域:如果按最大值归一化,一个被按住不放的宏键会让其余键全部趋近于零,图就废了;分位数让"常态高频键"占满色带;
log1p+ 1.45 次幂:键盘频次天然是长尾分布,纯线性会把 90% 的键压成同一档灰;对数压缩把动态范围拉回来,再用幂次微调对比度;- CIE 色彩空间插值:色带是
#d6966d → #c6683b → #a33f19 → #6d250e,停点[0, 0.36, 0.7, 1],段内用d3-interpolate的interpolateLab在 CIELAB 里插值。在 sRGB 里线性插值会让中段发灰发脏,Lab 里插值得到的过渡更接近"同一家族的深浅"; - 对比度检测:
contrastText按 WCAG 相对亮度与对比度,在白/深两色之间选文字颜色; - 未使用的键有专门的中性色
#d6dadc,与"用过但极少"(暖色最浅一档)区分开——没有数据和数据很低是两件事。
四、布局与 3D 舞台
布局模型 keyboard-layout.ts(167 行)用统一单位描述全尺寸 ANSI 104 键:KEYBOARD_UNIT = 58、KEY_GAP = 7,整盘 22.5 × 6.25 单位 → 1305 × 362.5 px;小键盘/方向键区从 x ≥ 15.25 开始,Num+ 与 NumEnter 高度为 2 单位。
3D 部分(keyboard-heatmap.tsx,543 行)用纯 CSS 3D,没有 WebGL:
- 键帽总厚度 =
baseDepth 12 + normalized * 58 * 0.24(数据贡献约 0–25.9 px),所以"高度"是除颜色之外的第二重编码; - 每个键帽四个面分别做不同的加深处理(顶面用
color-mix82% 白 → 主色 → 加深 0.08,前面rotateX(-90deg)加深 0.21,右侧rotateY(90deg)加深 0.31,左侧加深 0.11),靠明暗差异把立体感做出来,而不是靠贴图; zIndex = y * 100 + x手动排序,底盘translateZ(-24px);- 交互的坑:CSS 3D 变换后浏览器的命中测试不可靠,所以 hover 是用
document.elementsFromPoint找[data-key-id]并校验是否在盘面内,再配 48 ms 容错定时器处理"划过缝隙"的抖动;点击可钉住(pin),在外侧pointerdown或Escape取消;提示框用createPortal渲染避免被 3D 上下文裁剪。
五、可访问性与移动端
尊重 prefers-reduced-motion 与强制颜色模式,支持鼠标、键盘焦点与触控提示;桌面是三维舞台,移动端切换为横向浏览模式。对"只靠颜色表达量级"的可视化来说,高度编码 + 焦点提示是必须的补偿。
六、隐私与工程
- 演示数据
public/keystats-public.json只保留date、keyPresses、keyPressCounts;.gitignore覆盖KeyStats-Export-*.json、daily_stats.json、history.json; - 文件在浏览器本地读取与标准化,不上传服务器;
- 项目里还留了一条诚实提醒:过去提交过的文件不会因为普通删除而从 Git 历史消失,清理历史需要单独评估——这类话一般没人愿意写在 README 里;
- 质量门是
lint → typecheck → vitest → build,另有 Playwright 浏览器用例,CI 拆成quality与browser两个 job。
七、限制
输入结构只覆盖 KeyStats 的三种导出形态;组合键依赖别名表,遇到未收录的键名会归为未知而不计入;跨设备合并统计需要上游支持。
小结
这个项目的可复用经验是三条曲线:数据入口要容错但赋值要严格;归一化先处理异常值(分位数),再处理分布(对数),最后处理感知(CIELAB);物理布局与统计口径必须在同一层对齐(组合键拆分、修饰键折左)。