TypeWell全攻略(二):热力图渲染引擎,让键盘发光

写在前面:数据有了,然后呢?

上一篇我们搞定了数据采集------TypeWell 已经能"听见"每一次敲击,把按键次数攒在内存里,定时刷进数据库。

但用户看不到数据库。用户看到的是键盘上花花绿绿的颜色。

这一篇,咱们就讲怎么让键盘发光


开源仓库地址:Gitee TypeWell 雪豹同志
如果对你有帮助,欢迎 Star ⭐️


一、先看效果:热力图长什么样

打开 index.html,渲染出来的键盘长这样:

  • 每个按键都是一个 <div class="key">
  • 颜色随使用频率变化:
    • 🔵 蓝色:低频(较少使用)
    • 🟡 黄色:中频(正常使用)
    • 🔴 红色:高频(过度使用)

而且颜色是实时变化的------你打字的时候,能亲眼看着按键从蓝变黄、从黄变红。


二、整体架构:数据怎么跑到键盘上?

先看数据流动的完整链路:

复制代码
数据库(key_usage 表)
    ↓
Python 后端读取数据
    ↓
json.dumps() 转成 JSON 字符串
    ↓
runJavaScript(f"window.updateHeatmap({data_json})")
    ↓
前端 updateHeatmap() 函数
    ↓
遍历所有按键,更新颜色

核心在 main.pyupdate_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 shiftminus)。这样后面更新颜色时,就能通过 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 shiftright 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

写在最后

这一篇的核心也是三句话:

  1. 颜色计算:蓝→黄→红渐变,强度归一化
  2. 增量更新:只刷新变化的按键,性能拉满
  3. 左右保留:为了手指分析,左右键名不能统一

代码都在 index.htmlstyles.cssscript.js 里。加上上一篇的数据采集,TypeWell 已经能听见+看见每一次敲击了。

相关推荐
coding随想1 小时前
TypeScript 高级类型全攻略:从“可表达性”到“类型体操”的实践之路
前端·javascript·typescript
小白菜又菜1 小时前
Leetcode 234. Palindrome Linked List
python·算法·leetcode
恒云客2 小时前
python uv debug launch.json
数据库·python·json
大时光2 小时前
gsap -滚动插件 ScrollTrigger 简单demo
前端
Katecat996632 小时前
YOLO11-SEG-AFPN-P345改进采血装置检测与识别系统
python
tangbin5830852 小时前
iOS Swift:蓝牙 BLE 连接外设CoreBluetooth
前端
WWWWW先生2 小时前
02 登录功能实现
前端·javascript
嚴寒2 小时前
我用 AI 画了个设计稿,然后让它自己写成了代码
前端·ai编程
李广坤2 小时前
Spring Boot Validation 使用手册
后端