写在前面:数据有了,然后呢?
上一篇我们搞定了数据采集------TypeWell 已经能"听见"每一次敲击,把按键次数攒在内存里,定时刷进数据库。
但用户看不到数据库。用户看到的是键盘上花花绿绿的颜色。
这一篇,咱们就讲怎么让键盘发光。
开源仓库地址:Gitee TypeWell 雪豹同志
如果对你有帮助,欢迎 Star ⭐️
一、先看效果:热力图长什么样
打开 index.html,渲染出来的键盘长这样:
- 每个按键都是一个
<div class="key"> - 颜色随使用频率变化:
- 🔵 蓝色:低频(较少使用)
- 🟡 黄色:中频(正常使用)
- 🔴 红色:高频(过度使用)
而且颜色是实时变化的------你打字的时候,能亲眼看着按键从蓝变黄、从黄变红。

二、整体架构:数据怎么跑到键盘上?
先看数据流动的完整链路:
数据库(key_usage 表)
↓
Python 后端读取数据
↓
json.dumps() 转成 JSON 字符串
↓
runJavaScript(f"window.updateHeatmap({data_json})")
↓
前端 updateHeatmap() 函数
↓
遍历所有按键,更新颜色
核心在 main.py 的 update_frontend 函数里:
python
def update_frontend(self):
# ...(上一章的批量写库代码)
# 读取最新数据
c.execute("SELECT key, count FROM key_usage")
current_data = dict(c.fetchall())
# 转成 JSON 推给前端
data_json = json.dumps(current_data)
self.webview.page().runJavaScript(f"window.updateHeatmap({data_json})")
runJavaScript 是 PyQt 的魔法------它让 Python 可以直接调用前端定义的 JavaScript 函数。
前端 script.js 里对应的函数是:
javascript
window.updateHeatmap = function(data) {
heatMap.clear();
Object.entries(data).forEach(([key, count]) => {
heatMap.set(key, count);
});
updateAllHeat(); // 更新颜色
updateStats(); // 更新统计面板
};
三、键盘渲染:键盘是怎么画出来的?
3.1 键盘布局数据
打开 script.js,找到 keyRows 数组:
javascript
const keyRows = [
// 第一行: 功能键
['Esc', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'],
// 第二行: 数字键
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 'Backspace'],
// 第三行: Tab 区
['Tab', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
// 第四行: Caps
['Caps', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', 'Enter'],
// 第五行: Shift
['Shift', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 'Shift'],
// 第六行: Ctrl, Alt, Space, Alt, Ctrl
['Ctrl', 'Alt', 'Space', 'Alt', 'Ctrl']
];
就是一个二维数组,每个元素代表一个按键的显示文本。
3.2 渲染函数
javascript
function renderKeyboard() {
const container = document.getElementById('keyboard');
container.innerHTML = '';
keyRows.forEach(row => {
const rowDiv = document.createElement('div');
rowDiv.className = 'row';
row.forEach(keyLabel => {
const keyDiv = createKey(keyLabel);
rowDiv.appendChild(keyDiv);
});
container.appendChild(rowDiv);
});
}
createKey 负责创建单个按键的 DOM 元素:
javascript
function createKey(keyLabel) {
const keyDiv = document.createElement('div');
keyDiv.className = 'key';
// 添加特殊宽度类(比如空格键要更宽)
if (specialWidth[keyLabel]) {
keyDiv.classList.add(specialWidth[keyLabel]);
}
// 显示文本
const displayChar = keyLabel.length === 1 && /[a-zA-Z]/.test(keyLabel)
? keyLabel.toUpperCase()
: keyLabel;
const span = document.createElement('span');
span.textContent = displayChar;
keyDiv.appendChild(span);
// 存储标准化后的键名(用于热力图)
const keyName = normalizeKey(keyLabel);
keyDiv.dataset.key = keyName;
return keyDiv;
}
每个按键都有一个 data-key 属性,存的是标准化后的键名(比如 left shift、minus)。这样后面更新颜色时,就能通过 dataset.key 找到对应的数据。

四、颜色计算:从数字到颜色
4.1 核心算法
javascript
function updateSingleKey(keyDiv, heat, maxHeat, minHeat) {
// 1. 归一化到 0.1 ~ 1.0 之间
let intensity = minHeat;
if (maxHeat > minHeat) {
intensity = ((heat - minHeat) / (maxHeat - minHeat)) * 0.9 + 0.1;
} else {
intensity = 0.2;
}
intensity = Math.min(1.0, Math.max(0.1, intensity));
// 2. 根据强度计算 RGB 颜色(蓝→黄→红渐变)
let r, g, b;
if (intensity < 0.4) {
// 蓝区
const t = intensity / 0.4;
r = 70 + t * 30;
g = 130 + t * 60;
b = 255;
} else if (intensity < 0.7) {
// 黄区
const t = (intensity - 0.4) / 0.3;
r = 255;
g = 200 + t * 55;
b = 100 - t * 70;
} else {
// 红区
const t = (intensity - 0.7) / 0.3;
r = 255;
g = 255 - t * 150;
b = 30 - t * 20;
}
// 3. 设置颜色
keyDiv.style.color = `rgb(${Math.min(255,r)}, ${Math.min(255,g)}, ${Math.min(255,b)})`;
keyDiv.style.setProperty('--heat', intensity);
}
4.2 颜色分段的意图
| 区间 | 颜色 | 含义 | 代码逻辑 |
|---|---|---|---|
| 0.1 - 0.4 | 蓝色系 | 低频键 | 蓝色通道固定255,红绿逐渐增加 |
| 0.4 - 0.7 | 黄色系 | 中频键 | 红色固定255,绿色增加,蓝色减少 |
| 0.7 - 1.0 | 红色系 | 高频键 | 红绿都高,蓝色极低 |
为什么这么分?因为人眼对暖色 更敏感。用
里插入图片描述](https://i-blog.csdnimg.cn/direct/ccc186c6d120416a91d7b9a4d94e2ff7.png)
蓝→黄→红的渐变,能让高频键一眼就被注意到。
4.3 CSS 变量的妙用
你可能注意到了这行代码:
javascript
keyDiv.style.setProperty('--heat', intensity);
对应的 CSS 是:
css
.key::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 14px;
background: currentColor;
opacity: var(--heat, 0.1);
mix-blend-mode: screen;
pointer-events: none;
transition: opacity 0.25s;
box-shadow: 0 0 25px currentColor;
}
这样设计的好处:
- 颜色逻辑全在 JS :计算完 RGB 直接赋给
style.color - 透明度由 CSS 变量控制 :
opacity: var(--heat, 0.1)让热力层半透明 currentColor自动继承:热力层的颜色自动等于文字颜色
五、性能优化:只刷新变化的按键
5.1 问题
最直接的做法是:每次更新,遍历所有按键重新计算颜色。
javascript
// ❌ 低效做法
function updateAllHeat() {
keyElements.forEach(keyDiv => {
const heat = heatMap.get(keyDiv.dataset.key) || 0;
updateSingleKey(keyDiv, heat);
});
}
但如果键盘上有 100 个键,每次只有几个键的数据变了,其他 90 多个键也要重新计算一遍------浪费。
5.2 TypeWell 的增量更新
javascript
// 缓存上一次的数据
let lastHeatMap = new Map();
let lastMaxHeat = 0;
function updateAllHeat() {
// 计算新的最大热度
let maxHeat = 0;
heatMap.forEach(v => { if (v > maxHeat) maxHeat = v; });
// 判断最大热度变化是否显著
const heatDiff = Math.abs(maxHeat - lastMaxHeat);
if (heatDiff < 3 && lastMaxHeat > 0) {
// 变化不大,只更新有变化的按键
keyElements.forEach(keyDiv => {
const keyName = keyDiv.dataset.key;
const oldHeat = lastHeatMap.get(keyName) || 0;
const newHeat = heatMap.get(keyName) || 0;
if (oldHeat !== newHeat) {
updateSingleKey(keyDiv, newHeat, maxHeat, minHeat);
}
});
} else {
// 变化较大,更新所有按键
lastMaxHeat = maxHeat;
keyElements.forEach(keyDiv => {
const heat = heatMap.get(keyDiv.dataset.key) || 0;
updateSingleKey(keyDiv, heat, maxHeat, minHeat);
});
}
// 更新缓存
lastHeatMap = new Map(heatMap);
}
这样设计的好处:
- 大部分时候:只有几个按键变化,只更新那几
- 偶尔大变化:比如清空数据重新开始,才全量更新
六、统计面板:从数据到可读信息
6.1 统计什么
javascript
const domCache = {
totalHits: document.getElementById('totalHits'),
hottestKey: document.getElementById('hottestKey'),
handBalance: document.getElementById('handBalance'),
tiredFinger: document.getElementById('tiredFinger'),
tiredFingerCount: document.getElementById('tiredFingerCount')
};
- 总敲击:所有按键次数之和
- 最热按键:次数最多的键
- 左右平衡:左手次数占比
- 最累手指:负荷最大的手指
- 使用次数:该手指的敲击次数
6.2 左右手分区
javascript
const leftKeys = ['q','w','e','r','t','a','s','d','f','g','z','x','c','v','b',
'backquote','1','2','3','4','5','tab','caps','left shift',
'printscreen','scrolllock','pause','insert','home','pageup'];
const rightKeys = ['y','u','i','o','p','h','j','k','l','n','m',
'6','7','8','9','0','minus','equal','lbracket','rbracket',
'backslash','semicolon','quote','enter','backspace',
'delete','end','pagedown','up','down','left','right'];
注意这里用的是 left shift 和 right shift,而不是统一的 shift------这就是上一篇保留左右键的意义。
6.3 手指映射
javascript
const fingerMap = {
'left_pinky': ['q', 'a', 'z', '1', 'tab', 'caps', 'left shift', 'left ctrl'],
'left_ring': ['w', 's', 'x', '2', 'backquote'],
'left_middle': ['e', 'd', 'c', '3'],
'left_index': ['r', 'f', 'v', '4', 't', 'g', 'b', '5'],
'left_thumb': ['space'],
'right_pinky': ['p', ';', '/', '0', '-', '=', 'backspace', 'enter', 'right shift', 'right alt'],
'right_ring': ['o', 'l', '.', '9'],
'right_middle': ['i', 'k', ',', '8'],
'right_index': ['u', 'j', 'm', '7', 'y', 'h', 'n', '6'],
'right_thumb': ['space'],
'special': ['esc', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12']
};
每个手指负责哪些键,都清清楚楚。
6.4 更新统计的优化
javascript
function updateStats() {
// ...计算逻辑...
// 只在统计信息真的变化时更新 DOM
const statsChanged = (
total !== lastStats.total ||
maxKey !== lastStats.maxKey ||
leftTotal !== lastStats.leftTotal ||
rightTotal !== lastStats.rightTotal
);
if (statsChanged) {
lastStats = { total, maxKey, leftTotal, rightTotal };
// 使用缓存的 DOM 元素更新
domCache.totalHits.innerText = total;
domCache.hottestKey.innerText = maxKey.substring(0,6).toUpperCase() || '---';
domCache.handBalance.innerText = balance + '%';
domCache.tiredFinger.innerText = tiredFingerName;
domCache.tiredFingerCount.innerText = maxFingerVal;
}
}
又是增量更新------数据没变就不刷新 DOM,减少重绘。
七、排行榜弹窗:让数据更有趣
7.1 创建弹窗
javascript
function createRankingModal() {
// 创建模态框容器
const modal = document.createElement('div');
modal.id = 'rankingModal';
modal.className = 'modal';
// 创建内容
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
// 标题
const modalTitle = document.createElement('h3');
modalTitle.className = 'modal-title';
modalTitle.textContent = '🔥 手指使用排行榜';
// 列表
const rankingList = document.createElement('ul');
rankingList.id = 'rankingList';
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.className = 'btn';
closeBtn.textContent = '✕ 关闭';
// 组装
modalContent.appendChild(modalTitle);
modalContent.appendChild(rankingList);
modalContent.appendChild(closeBtn);
modal.appendChild(modalContent);
document.body.appendChild(modal);
}
7.2 显示排行榜
javascript
function showRanking() {
// 统计各手指使用次数
const fingerUsage = {...}; // 初始化
heatMap.forEach((val, key) => {
for (const [finger, keys] of Object.entries(fingerMap)) {
if (keys.includes(key)) {
fingerUsage[finger] += val;
break;
}
}
});
// 排序
const sorted = Object.entries(fingerUsage)
.map(([finger, usage]) => ({
name: fingerNames[finger] || finger,
usage
}))
.sort((a, b) => b.usage - a.usage);
// 渲染列表(使用文档片段优化性能)
const fragment = document.createDocumentFragment();
sorted.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'ranking-item';
li.innerHTML = `
<span class="rank-number">${index + 1}</span>
<span class="finger-name">${item.name}</span>
<span class="finger-count">${item.usage}</span>
`;
fragment.appendChild(li);
});
rankingList.innerHTML = '';
rankingList.appendChild(fragment);
// 显示弹窗
document.getElementById('rankingModal').style.display = 'flex';
}

八、完整流程串联
现在把整个渲染层串起来看:
Python 推来新数据 (window.updateHeatmap)
↓
更新 heatMap 全局变量
↓
updateAllHeat()
↓
计算最大热度 maxHeat
↓
判断变化幅度:
├─ 变化小 → 只更新有变化的按键
└─ 变化大 → 更新所有按键
↓
updateSingleKey() 计算颜色
↓
设置 keyDiv.style.color 和 CSS 变量 --heat
↓
updateStats()
↓
判断统计数据是否变化:
├─ 变化 → 更新 DOM 显示
└─ 不变 → 跳过
九、踩坑总结
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 颜色突变 | 按键颜色跳变,不平滑 | 归一化时用线性插值 |
| 性能卡顿 | 打字时界面掉帧 | 增量更新 + DOM 缓存 |
| 左右手分不清 | shift 不知道哪只手按的 | 保留左右键名,不统一 |
| 弹窗卡顿 | 打开排行榜时界面卡 | 用文档片段批量操作 |
| 统计闪烁 | 数字频繁变化晃眼 | 数据没变时不更新 DOM |
写在最后
这一篇的核心也是三句话:
- 颜色计算:蓝→黄→红渐变,强度归一化
- 增量更新:只刷新变化的按键,性能拉满
- 左右保留:为了手指分析,左右键名不能统一
代码都在 index.html、styles.css 和 script.js 里。加上上一篇的数据采集,TypeWell 已经能听见+看见每一次敲击了。