跳到主要内容

可视化动画与交互

动画基础

缓动函数(Easing)

缓动函数控制动画的速度变化曲线:

// 常用缓动函数
const easing = {
linear: (t: number) => t,
easeInQuad: (t: number) => t * t,
easeOutQuad: (t: number) => t * (2 - t),
easeInOutQuad: (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
easeOutCubic: (t: number) => (--t) * t * t + 1,
easeInOutCubic: (t: number) =>
t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
easeOutElastic: (t: number) =>
Math.pow(2, -10 * t) * Math.sin(((t - 0.075) * (2 * Math.PI)) / 0.3) + 1,
easeOutBounce: (t: number) => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
},
};

通用动画引擎

interface AnimationConfig {
from: Record<string, number>;
to: Record<string, number>;
duration: number;
easeFn?: (t: number) => number;
onUpdate: (values: Record<string, number>) => void;
onComplete?: () => void;
}

function animate(config: AnimationConfig): void {
const { from, to, duration, easeFn = easing.easeOutCubic, onUpdate, onComplete } = config;
const startTime = performance.now();
const keys = Object.keys(from);

function tick(now: number): void {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeFn(progress);

const current: Record<string, number> = {};
keys.forEach((key) => {
current[key] = from[key] + (to[key] - from[key]) * easedProgress;
});
onUpdate(current);

if (progress < 1) {
requestAnimationFrame(tick);
} else {
onComplete?.();
}
}

requestAnimationFrame(tick);
}

// 使用示例:柱状图入场动画
animate({
from: { height: 0, opacity: 0 },
to: { height: 200, opacity: 1 },
duration: 600,
onUpdate: ({ height, opacity }) => {
ctx.globalAlpha = opacity;
ctx.fillRect(x, canvasHeight - height, barWidth, height);
},
});

过渡动画

数据更新过渡

数据变化时平滑过渡,而非跳变:

// 插值器:生成从 A 状态到 B 状态的中间值
function interpolateValues(
prev: number[],
next: number[],
duration: number,
onFrame: (values: number[]) => void
): void {
const startTime = performance.now();

function tick(now: number): void {
const t = Math.min((now - startTime) / duration, 1);
const eased = easing.easeOutCubic(t);

const current = prev.map((v, i) => v + (next[i] - v) * eased);
onFrame(current);

if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}

入场/退场动画

type AnimationType = 'fadeIn' | 'slideUp' | 'scaleIn' | 'wipeRight';

function getEntryAnimation(type: AnimationType, progress: number): { opacity: number; transform: string } {
const t = easing.easeOutCubic(progress);
switch (type) {
case 'fadeIn':
return { opacity: t, transform: '' };
case 'slideUp':
return { opacity: t, transform: `translateY(${(1 - t) * 30}px)` };
case 'scaleIn':
return { opacity: t, transform: `scale(${t})` };
case 'wipeRight':
return { opacity: 1, transform: `scaleX(${t})` };
}
}

交互设计模式

Tooltip

class ChartTooltip {
private el: HTMLDivElement;

constructor() {
this.el = document.createElement('div');
this.el.className = 'chart-tooltip';
Object.assign(this.el.style, {
position: 'absolute',
pointerEvents: 'none',
background: 'rgba(0,0,0,0.8)',
color: '#fff',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '13px',
opacity: '0',
transition: 'opacity 150ms',
zIndex: '1000',
});
document.body.appendChild(this.el);
}

show(x: number, y: number, content: string): void {
this.el.innerHTML = content;
this.el.style.opacity = '1';

// 防止超出视口
const rect = this.el.getBoundingClientRect();
const left = Math.min(x + 12, window.innerWidth - rect.width - 8);
const top = y - rect.height - 8 < 0 ? y + 12 : y - rect.height - 8;
this.el.style.left = `${left}px`;
this.el.style.top = `${top}px`;
}

hide(): void {
this.el.style.opacity = '0';
}

destroy(): void {
this.el.remove();
}
}

缩放与平移(Zoom & Pan)

interface ViewTransform {
x: number; // 平移 X
y: number; // 平移 Y
k: number; // 缩放倍率
}

class ZoomPan {
private transform: ViewTransform = { x: 0, y: 0, k: 1 };
private isPanning = false;
private startX = 0;
private startY = 0;

constructor(private canvas: HTMLCanvasElement, private onUpdate: (t: ViewTransform) => void) {
canvas.addEventListener('wheel', this.handleWheel, { passive: false });
canvas.addEventListener('pointerdown', this.handleDown);
canvas.addEventListener('pointermove', this.handleMove);
canvas.addEventListener('pointerup', this.handleUp);
}

// 鼠标坐标 → 数据坐标
screenToData(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - this.transform.x) / this.transform.k,
y: (sy - this.transform.y) / this.transform.k,
};
}

private handleWheel = (e: WheelEvent): void => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newK = Math.max(0.1, Math.min(10, this.transform.k * factor));

// 以鼠标位置为中心缩放
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;

this.transform.x = mx - (mx - this.transform.x) * (newK / this.transform.k);
this.transform.y = my - (my - this.transform.y) * (newK / this.transform.k);
this.transform.k = newK;

this.onUpdate(this.transform);
};

private handleDown = (e: PointerEvent): void => {
this.isPanning = true;
this.startX = e.clientX - this.transform.x;
this.startY = e.clientY - this.transform.y;
this.canvas.setPointerCapture(e.pointerId);
};

private handleMove = (e: PointerEvent): void => {
if (!this.isPanning) return;
this.transform.x = e.clientX - this.startX;
this.transform.y = e.clientY - this.startY;
this.onUpdate(this.transform);
};

private handleUp = (): void => {
this.isPanning = false;
};
}

Brush 选择

// 框选数据点
class BrushSelector {
private startX = 0;
private startY = 0;
private isSelecting = false;

constructor(
private canvas: HTMLCanvasElement,
private onSelect: (rect: { x: number; y: number; w: number; h: number }) => void
) {
canvas.addEventListener('pointerdown', (e) => {
if (e.shiftKey) { // Shift + 拖拽触发框选
this.isSelecting = true;
this.startX = e.offsetX;
this.startY = e.offsetY;
}
});

canvas.addEventListener('pointerup', (e) => {
if (!this.isSelecting) return;
this.isSelecting = false;
const rect = {
x: Math.min(this.startX, e.offsetX),
y: Math.min(this.startY, e.offsetY),
w: Math.abs(e.offsetX - this.startX),
h: Math.abs(e.offsetY - this.startY),
};
this.onSelect(rect);
});
}
}

常见面试问题

Q1: 什么是缓动函数?常用的有哪些?

答案

缓动函数将线性时间进度 t[0,1]t \in [0,1] 映射为动画进度,控制速度曲线。常用类型:linear(匀速)、easeOut(先快后慢,最常用)、easeIn(先慢后快)、easeInOut(两端慢中间快)、elastic(弹性)、bounce(弹跳)。CSS 中的 cubic-bezier() 就是三次贝塞尔缓动。

Q2: 可视化中的 Zoom & Pan 如何实现?

答案

维护一个变换状态(translateX、translateY、scale)。滚轮控制缩放(以鼠标位置为中心)、拖拽控制平移。渲染前将变换应用到 Canvas context(setTransformtranslate + scale)。鼠标坐标需通过逆变换映射到数据坐标。详见 Transform 变换与矩阵

Q3: 数据更新时如何平滑过渡?

答案

保存前后两组数据的状态,用插值函数在两个状态之间生成中间帧。对数值用线性插值(prev + (next - prev) * t),对颜色用 RGB/HSL 插值。配合缓动函数控制速度曲线,通过 requestAnimationFrame 驱动每帧渲染。

Q4: 如何实现 Canvas 图表的 Tooltip?

答案

监听 mousemove 事件,将鼠标坐标通过逆变换映射到数据坐标,查找最近数据点(可用空间索引加速),然后定位一个绝对定位的 DOM 元素作为 Tooltip。注意防止 Tooltip 超出视口边界。

相关链接