Day 67 · 大屏视觉工程(下)— 入场动效、数字滚动与氛围渲染:克制的生命感
昨天的大屏"能看"了,但像一张静态海报。今天注入生命感——入场动效编排、KPI 数字滚动、卡片扫描线氛围。但工业大屏的动效哲学和营销页完全相反:营销页动效越炫越好,大屏动效的第一美德是克制——监控人员盯着它 8 小时,任何持续闪烁都是视觉暴力。今天的一半内容是"怎么动",另一半是同样重要的"什么时候不许动"。
目录
- 一、大屏动效哲学:克制是第一美德
- 二、入场动效:两秒讲完开场故事
- 三、数字滚动:从旧值到新值的仪式感
- 四、氛围渲染:扫描线与微光呼吸
- 五、告警的动效语言
- 六、动效性能预算
- 七、prefers-reduced-motion:无障碍底线
- 八、常见坑点
- 九、自测挑战
- 十、总结
一、大屏动效哲学:克制是第一美德
1.1 动效分类与预算
| 类型 | 生命周期 | 预算 |
|---|---|---|
| 入场动效 | 首次加载/切屏后,播一次 | 总时长 ≤ 2s,错峰编排 |
| 数据动效 | 数据变化时触发 | 单次 ≤ 800ms,可被新变化打断 |
| 氛围动效 | 常驻 | 全屏同时最多 2 个,且必须低频/低对比 |
| 告警动效 | 告警存续期间 | 允许醒目,但同类告警只动一个 |
1.2 三条纪律
1. 入场动效结束后的稳态页面,动画属性数量 = 氛围动效数量(≤2)
2. 常驻动效只用 transform / opacity(合成层动画,不触发 layout/paint)
3. 数据 1 秒一变的东西不做变化动画(永远是半路状态)——只给慢变量做
🎯 第 3 条最反直觉:总产量每秒 +3,你给翻牌器做 600ms 滚动动画,结果动画永远播不完就重开——变成持续闪烁。慢变量(OEE 每 5 秒更新)才配拥有动画。
二、入场动效:两秒讲完开场故事
2.1 编排原则
时间轴(错峰 = 编排的灵魂):
0.0s 标题栏淡入 + 下移就位
0.2s 第一批卡片(屏内主要卡片)淡入 + 轻微上浮
0.4s 第二批卡片(次要卡片)
0.6s KPI 数字从 0 滚动到初值
1.2s 图表入场(ECharts 自带动画接管)
1.5s ~ 全部落定,进入稳态
2.2 CSS 实现:入场编排
/**
* 入场动效:卡片错峰进场
* --enter-delay 由 JS 按卡片序号注入(index × 0.2s)
*/
.tech-card {
opacity: 0;
transform: translateY(16px);
animation: card-enter 0.5s ease forwards;
animation-delay: var(--enter-delay, 0.2s);
}
@keyframes card-enter {
to { opacity: 1; transform: translateY(0); }
}
/* 轮播切屏时重播入场(切屏=新的开场故事) */
.screen.screen-hidden .tech-card { animation: none; opacity: 0; }
/**
* 入场编排器:给卡片注入错峰 delay
*/
export function orchestrateEnter(screenEl: HTMLElement): void {
screenEl.querySelectorAll<HTMLElement>(".tech-card").forEach((card, i) => {
// 两列以上的屏按"行"错峰比按"个"更自然:delay = 行号 × 0.2
const row = Math.floor(i / 3);
card.style.setProperty("--enter-delay", `${0.2 + row * 0.2}s`);
});
}
2.3 只在首次和切屏播
稳态数据更新不重播入场动画:CSS 选择器绑在 screen-hidden 移除后的状态上(切屏时加回 hidden → 移除时动画重新触发),数据更新不碰这些 class。
三、数字滚动:从旧值到新值的仪式感
Day 56 写过 animateValue,今天升级为带千分位格式化 + 等宽数字的翻牌器组件:
/**
* KPI 数字翻牌器:插值滚动 + 千分位 + 等宽渲染
* 性能细节:动画中每帧只改 textContent(无 layout 抖动,等宽字体保证不移位)
*/
export function createRollingNumber(
el: HTMLElement,
opts = { duration: 800, decimals: 0 }
): { update(value: number): void } {
let current = 0;
let rafId = 0;
/** 千分位格式化:125800 → "125,800" */
const fmt = (v: number): string =>
v.toLocaleString("zh-CN", {
minimumFractionDigits: opts.decimals,
maximumFractionDigits: opts.decimals,
});
return {
update(value: number): void {
cancelAnimationFrame(rafId);
const from = current;
const start = performance.now();
const frame = (now: number): void => {
const t = Math.min((now - start) / opts.duration, 1);
// easeOutCubic(Day 50 同款缓动,全项目统一)
const eased = 1 - Math.pow(1 - t, 3);
current = from + (value - from) * eased;
el.textContent = fmt(current);
if (t < 1) rafId = requestAnimationFrame(frame);
};
rafId = requestAnimationFrame(frame);
},
};
}
使用纪律(第一节第 3 条的落地):
// ✅ 慢变量:动画有意义
kpiOee.update(snap.kpi.oee); // 5s 更新一次(Store 分频)
// ❌ 快变量:禁用动画,直接赋值
kpiOutput.el.textContent = fmt(snap.kpi.totalOutput); // 1s 一变,只做直赋值
四、氛围渲染:扫描线与微光呼吸
常驻动效仅两个,都在"几乎察觉不到"的强度上:
4.1 标题栏扫描光
/**
* 标题栏流动光带:10s 一轮的横向扫光
* 强度刻意压低(opacity 0.15 峰值)——氛围不是主角
*/
.top-bar__title {
position: relative;
overflow: hidden;
}
.top-bar__title::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(
105deg,
transparent 40%,
rgba(0, 198, 255, 0.15) 50%, /* 峰值只有 15% 透明度 */
transparent 60%
);
background-size: 300% 100%;
animation: sweep 10s linear infinite;
}
@keyframes sweep {
from { background-position: 150% 0; }
to { background-position: -150% 0; }
}
4.2 告警卡呼吸边框(有告警时才激活)
/**
* 告警卡片呼吸:仅在存在 danger 级告警时挂上 is-alarming class
*/
.tech-card.is-alarming {
border-color: rgba(255, 77, 79, 0.6);
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
50% { box-shadow: 0 0 16px rgba(255, 77, 79, 0.25); } /* 呼吸幅度克制 */
}
4.3 厂区俯视的雷达扫描(占位图的高级感)
/**
* 厂区俯视占位图的雷达扫描线(Canvas 版,练习复用)
* 一条从中心扫出的扇形渐变,6 秒一圈
* 顺便复习:conic gradient 也能做,但 Canvas 版以后能叠加真实点位(阶段 3 预留)
*/
export function startRadarSweep(canvas: HTMLCanvasElement): () => void {
const ctx = canvas.getContext("2d")!;
const dpr = window.devicePixelRatio || 1;
const resize = (): void => {
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
let rafId = 0;
const TAU = Math.PI * 2;
const frame = (now: number): void => {
const w = canvas.clientWidth, h = canvas.clientHeight;
const angle = (now / 6000 % 1) * TAU; // 6s 一圈
ctx.clearRect(0, 0, w, h);
// 扫描扇形(30° 渐变尾迹)
const grad = ctx.createConicGradient?.(angle, w / 2, h / 2);
if (grad) {
grad.addColorStop(0, "rgba(0,198,255,0.25)");
grad.addColorStop(0.08, "rgba(0,198,255,0)");
grad.addColorStop(1, "rgba(0,198,255,0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(w / 2, h / 2, Math.min(w, h) / 2, 0, TAU);
ctx.fill();
}
rafId = requestAnimationFrame(frame);
};
rafId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(rafId);
}
⚠️ 氛围动效的总闸门:
startRadarSweep这类常驻 rAF 循环,页面 hidden 时必须停(visibilitychange),否则后台空烧 GPU。三屏轮播下还有第二层闸门:非激活屏的 Canvas 循环也要停(Day 65 的 activate/deactivate 钩子里挂)。
五、告警的动效语言
告警是唯一被允许"醒目"的动效,但要有完整的状态机:
新告警到达(danger)
→ 告警列表行滑入(translateX + 0.3s)
→ 对应卡片挂 is-alarming(呼吸边框)
→ 顶部状态灯短暂闪烁 3 次后恢复稳态
→ 屏 C 拓扑对应节点红边脉冲(Day 68 实现)
告警确认/超时(服务器推送 resolve)
→ 列表行淡出移除
→ 所有关联动效解除
/**
* 告警行动效:滑入 / 淡出
*/
.alarm-row {
animation: alarm-in 0.3s ease;
}
@keyframes alarm-in {
from { opacity: 0; transform: translateX(24px); }
}
.alarm-row.is-resolved {
animation: alarm-out 0.4s ease forwards;
}
@keyframes alarm-out {
to { opacity: 0; transform: translateX(-24px); height: 0; }
}
六、动效性能预算
6.1 合成层纪律
| 属性 | 是否触发重排/重绘 | 大屏可用性 |
|---|---|---|
| transform / opacity | 否(GPU 合成) | ✅ 常驻动效唯一允许 |
| background-position | 重绘 | ⚠️ 仅低频氛围(标题扫光 10s 一轮可接受) |
| width / height / top / left | 重排 | ❌ 禁止做动画 |
| box-shadow | 重绘 | ⚠️ 呼吸边框 2s 周期、低幅度可接受 |
6.2 预算验收
稳态(所有氛围动效运行中)Performance 录制 30s:
- 帧率 ≥ 55fps(氛围动效的税必须交得起)
- GPU 轨道无持续高占用
- 页面 hidden 后 CPU 归零(后台闸门验证)
七、prefers-reduced-motion:无障碍底线
/**
* 系统级"减少动态"偏好:一键关停所有装饰性动效
* 监控室大屏可能被投影在会诊环境/被光敏人群使用——这是专业素养的一部分
*/
@media (prefers-reduced-motion: reduce) {
.tech-card, .alarm-row { animation: none; opacity: 1; transform: none; }
.top-bar__title::after { animation: none; }
.tech-card.is-alarming { animation: none; box-shadow: 0 0 12px rgba(255,77,79,0.3); }
}
JS 侧的常驻循环(雷达扫描)同样检查:
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) stopRadar = startRadarSweep(canvas);
八、常见坑点
坑 1:快变量做滚动动画变成持续闪烁
总产量 1s +3 还做 800ms 滚动 → 动画永远播不完。修复:快变量直赋值(第三节纪律)。
坑 2:入场动画每次数据更新重播
动画 class 挂在常驻状态上,任何重渲染都触发。修复:入场动画只由"切屏/首载"路径控制(2.3)。
坑 3:后台 GPU 空烧
雷达扫描在 Tab hidden 后继续跑。修复:visibilitychange + 屏幕激活双层闸门(4.3)。
坑 4:动画打断不留垃圾帧
数字滚动中来了新值,直接从"当前插值位置"滚向新目标(3 的实现已处理:cancel + 从 current 出发)。反例:每帧都从旧终点重算 → 视觉跳变。
坑 5:clip-path 元素加 box-shadow 动画
Day 66 坑 4 的延续:切角卡片的 shadow 被裁。告警呼吸改用 outline 或内层元素实现。
坑 6:动效堆到掉帧还嘴硬"氛围需要"
稳态帧率 < 55fps 时按预算砍:先砍扫光、再砍呼吸、雷达最后(它有阶段 3 的功能价值)。帧率优先级高于一切视觉。
九、自测挑战
T1 · 动效全套装(60 分钟)
入场编排 + 数字滚动(含快慢变量区分)+ 扫光 + 告警呼吸/滑入,按第六节预算验收帧率。今天核心作业。
T2 · 切屏重播验证(15 分钟)
轮播三屏轮一圈,确认每屏激活时入场动效重播、稳态数据更新不重播。
T3 · 双闸门测试(15 分钟)
后台 Tab 5 分钟回来:CPU 已归零过(任务管理器/Performance Manager 曲线)、回来雷达无跳变。
T4 · 动效预算报告(20 分钟)
Performance 录制"全动效开"与"全动效关"各 30s,对比帧率与 GPU 占用,数据表进博客——用数据支撑你的克制,而不是凭感觉。
十、总结
| 主题 | 纪律 |
|---|---|
| 入场编排 | ≤2s、错峰、只在首载/切屏播 |
| 数字滚动 | 慢变量专属,快变量直赋值 |
| 氛围动效 | ≤2 个、低频低幅、合成层属性 |
| 告警动效 | 唯一允许醒目 + 完整状态机 |
| 性能预算 | 稳态 ≥55fps,交不起税就砍 |
| 无障碍 | prefers-reduced-motion 一键关停 |
视觉工程完工。明天开始搬旧作品:第 7 周的流程图编辑器降级为屏 C 的产线拓扑页——编辑能力收进配置,渲染能力接上实时数据,节点颜色随温度心跳。