【阶段1收官】day67-visual-motion

作者:mario 发布时间: 2026-09-03 阅读量:3 评论数:0

Day 67 · 大屏视觉工程(下)— 入场动效、数字滚动与氛围渲染:克制的生命感

昨天的大屏"能看"了,但像一张静态海报。今天注入生命感——入场动效编排、KPI 数字滚动、卡片扫描线氛围。但工业大屏的动效哲学和营销页完全相反:营销页动效越炫越好,大屏动效的第一美德是克制——监控人员盯着它 8 小时,任何持续闪烁都是视觉暴力。今天的一半内容是"怎么动",另一半是同样重要的"什么时候不许动"。


目录


一、大屏动效哲学:克制是第一美德

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 的产线拓扑页——编辑能力收进配置,渲染能力接上实时数据,节点颜色随温度心跳。

评论