385 行单文件 HTML 实现的触控识别 Demo
这是个交互概念 Demo(2026 华为终端 BG 创新大赛·手机创新赛道):在"手机背面"滑动手势,正面应用就做出对应动作。仓库只有四个文件,其中 index.html 一个文件就装了整台"手机"和整套"识别引擎"。
为什么选单文件:先想清楚 Demo 的约束
写之前我先问自己一个问题:这个项目的交付场景是什么?答案是给评委/用户现场演示。演示场景有两个硬约束:
- 不能在关键时刻翻车——依赖安装失败、框架版本冲突、构建失败都会毁掉演示;
- 必须立刻跑起来——评审环境不会给你配置 Node 的时间。
所以单文件不是"偷懒",是把这两条约束直接写进架构:没有 <link>、没有 <script src>、没有任何构建步骤,双击打开就是成品。384 行的结构非常清晰,三段式:
| 段落 | 行范围 | 行数 | 职责 |
|---|---|---|---|
<style> |
7–79 | 73 | 全部样式,含 CSS 3D 翻转、触控区、映射表 |
<body> |
81–122 | 42 | 一个 <canvas> + 按钮 + 映射表 |
<script> |
123–382 | 260 | 状态、数据、渲染、识别算法、动画 |
260 行的 JavaScript 里也没有一团乱麻,而是按"数据层 → 识别层 → 反馈层"分节注释组织(// ===== State =====、// ===== Gesture Recognition Algorithm =====)。单文件会削弱模块边界,那就用注释分区把边界画回来——这是单文件原型的可读性补偿手段。
"热区"的真相:不是物理分区,是手势 × 应用映射表
看 Demo 之前我以为"背面触控区"会切成几个物理格子,各管各的。读代码发现完全是另一套设计:
- 背面是一整块 240×320px 的虚线圆角区域(窄屏 200×280),居中偏上避开顶部的摄像头/闪光灯组件。底纹用两行
repeating-linear-gradient画网格线,视觉暗示"这里可以画"。 - "热区划分"发生在语义层:7 类手势(左右上下滑、双击、画圈、长按)× 5 个 App(浏览器/音乐/阅读/地图/桌面)= 35 个语义结果,渲染成页面上的
mapping-table。
这正是"上下文感知"的核心:同一手势在不同 App 里语义不同。mapping 就是一个普通对象:
const mapping = {
swipe_right: { browser: '前进下一页', music: '下一首', ... },
swipe_left: { browser: '后退', music: '上一首', ... },
// ... 7 手势 × 5 应用
};
为什么不用"背面切格子 + 每格固定动作"?因为那只是"多了几个按钮",没有体现"盲操"的价值——盲操的前提是你不知道手在哪,但你知道自己在哪个 App。把上下文放在 App 维度,用户只需记住"右滑在音乐里是下一首,在浏览器里是前进",记忆负担远小于记住几十个固定格子的位置。
识别算法轻量化:四个特征代替模型
这部分是全文件最有"算法感"的地方。手势识别没有用任何模型,而是手写特征 + 阈值规则,每帧只记录两个维度:
// 采样点:坐标 + 时间戳
points.push({ x: e.clientX, y: e.clientY, t: Date.now() });
时间戳这一个字段决定了后面"双击"判断能不能做——双击需要两次点击的时间差,没有 t 就无从谈起。多加一个字段,识别能力立刻多一个维度。
以"画圈"为例,这是最有技巧的规则:
if (dist < totalLen * 0.35 && totalLen > 80) {
let angle = 0;
for (let i = 1; i < pts.length; i++) {
const a1 = Math.atan2(pts[i-1].y - cy, pts[i-1].x - cx);
const a2 = Math.atan2(pts[i].y - cy, pts[i].x - cx);
let da = a2 - a1;
if (da > Math.PI) da -= 2 * Math.PI; // 归一化到 [-π, π]
if (da < -Math.PI) da += 2 * Math.PI; // 防止角度从 179° 跳到 -179° 被误判成"转了一圈"
angle += da;
}
if (Math.abs(angle) > Math.PI * 1.3) {
return { gesture: 'circle', confidence: Math.min(0.95, Math.abs(angle) / (2 * Math.PI)) };
}
}
这里有几层"为什么":
- 判闭环:
dist < totalLen * 0.35——起点和终点的直线距离远小于路径总长,说明画了个大概闭合的形状; - 判方向量:对每一个采样点,算它相对圆心的极角,再对相邻两帧的极角差求和。如果用户真的在画圈,这个和会累积超过一圈(
> π*1.3); - ±π 归一化是关键细节:
atan2返回[-π, π],如果前一帧角度是179°、后一帧是-179°,原始差值-358°会被误判成"反向转了一圈"。归一化后变成+2°,方向一致。缺了这两行,画圈识别在跨越 ±π 边界时必错。
滑动则用"主轴占比"判断:路径总长 > 30,且横向位移占比 > 0.6 判定为左右滑。规则朴素但有效。
判定顺序也有讲究:先双击(条件最严,和位移冲突)→ 再画圈(闭环)→ 再滑动(直线)→ 兜底 unknown。顺序错了,一个画圈手势可能在滑动分支就被吞掉。
所有特征计算都是 O(n) 单遍扫描,没有任何矩阵运算、没有分类器、没有模型文件——伪代码级实现,任何前端都能复刻。
置信度:统一裁决,而不是每个规则自己说了算
值得单独说的一点:每个规则返回的不只是手势类型,还有置信度:
if (result && result.confidence >= 0.5) {
handleGesture(result.gesture, result.confidence);
}
识别层和反馈层的边界由此清晰起来——规则层只负责"我多确定",裁决层统一用 0.5 阈值决定"信不信"。以后想收紧灵敏度,改一个常量就行,不用动任何一条规则。
交互闭环:三层反馈 + 3D 翻转
识别完成后,反馈是三层同时给的,这是 Demo 的"说服力"所在:
背面 canvas 画手势(mouse/touch 双通道)
→ 识别(recognizeGesture)
→ 背面浮层显示「← 左滑 (87%)」识别标签 ← 因
→ 正面 toast 弹出动作文案("下一首") ← 果
→ 右下角日志记一条时间戳记录
→ 400ms 后自动 3D 翻转回正面展示效果
→ 800ms 后清空 canvas 准备下一次
手机本体的翻转用 CSS 3D 实现:.phone { transform-style: preserve-3d },正反两个 .phone-face 互为背面(backface-visibility: hidden),反面转 180°,翻转就是 toggle 一个 .flipped class。用 CSS 状态切换代替 JS 动画逻辑,这是前端原型里经常被低估的写法——渲染由浏览器合成器处理,代码里只剩一个布尔值。
关于"AI"的诚实说明
我全文核查过:fetch / XMLHttpRequest / axios / token / api_key 全部零匹配——这个 Demo 没有任何外部 AI 调用。所谓"AI 意图识别",落地机制是 mapping[gesture][currentApp] 的静态查表。
这值得写进文章里,因为它代表一种清醒的取舍:Demo 阶段用规则 + 查表模拟意图层,成本为零、可复现、永不联网翻车;如果做真机版,再把意图层替换成语义模型。识别精度追求的也是"演示效果"而非"工程鲁棒性"——没有防抖、没有尺度归一化、没有路径重采样。这是原型和产品的分界线,写清楚反而比含混的"AI"更有分量。
总结:单文件原型的方法论
回看这 384 行,能提炼出几条通用的单文件原型方法:
- 约束决定架构:演示场景要求零依赖、打开即用,单文件就是最优解;
- Canvas 即屏幕:把触控区/绘制/反馈全部绘在 Canvas 上,省掉 DOM 组件层;
- 规则引擎的性价比:识别范围有限时(7 类手势、固定设备),手写特征 + 阈值比模型更轻、更可控、更好讲;
- 上下文比坐标更聪明:把"语义上下文"放在 App 维度,一小张查表就实现了"同一个手势在不同场景不同含义"。
它证明了:交互原型真正稀缺的往往不是技术栈的复杂度,而是对场景的拆解能力。
- 点赞
- 收藏
- 关注作者
评论(0)