KeyStats 3D 键盘热力图:CIELAB 色阶、对数归一化与 CSS-3D 键帽

约 7 分钟阅读Web 与可视化

文章目录8 节

把 KeyStats 导出的 JSON 变成可交互的 3D 键盘热力图,难点不在 3D,而在数据清洗、组合键拆分、色阶的感知均匀性与键盘布局的坐标建模。本文逐层拆解这几个函数。

KeyStats 键盘热力图 的目标很朴素:把 KeyStats 导出的按键统计 JSON,渲染成一张可交互的三维键盘热力图——颜色深浅和键帽高度同时表示频次。它只有两三千行代码,但每一层都藏着被低估的细节。

一、输入清洗:三种结构,一个出口

真实世界的"导出文件"从来不止一种形状。src/lib/keystats.ts(392 行)一口气接受三种:

  1. 完整导出:{version?, scope?, exportedAt?, currentStats, history};
  2. 单日记录:根节点直接带 keyPressCounts(没有 history / currentStats),自动包装成 {version: 1, scope: "currentDevice"};
  3. 历史映射:根节点是 日期 → 单日记录 的映射(每个值必须是带 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
2
3
4
5
6
7
8
9
// 1) 定义域取 95 分位,避免一个"神键"把其他键全压暗
export function heatDomain(counts: number[], percentile = 0.95): number {
// 排序后取 ceil(0.95 * n) - 1,下限为 1
}

// 2) 对数压缩 + 幂次提升,兼顾长尾与对比
export function normalizeHeatCount(count: number, domain: number): number {
return Math.min(1, Math.log1p(count) / Math.log1p(domain)) ** 1.45;
}
  • 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-mix 82% 白 → 主色 → 加深 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);物理布局与统计口径必须在同一层对齐(组合键拆分、修饰键折左)。

仓库:sakuradairong/keystats-heatmap

搜索文章

按 / 打开搜索,Esc 关闭;多个关键词用空格分隔,搜索在本地完成。