好的,我们开始 悬停事件 (Hover Events) 的实现。
这是交互体验中质的飞跃。目前的点击是"瞬间"的,而悬停是"连续"的状态管理。
核心逻辑分析
要实现 mouseover (移入) 和 mouseout (移出),Handler 需要记忆上一帧鼠标在哪一个图形上。
我们定义:
target: 当前鼠标下的图形。_hovered: 上一次鼠标所在的图形(缓存状态)。
逻辑如下:
- 监听 DOM 的
mousemove。 - 计算当前鼠标下的
target。 - 对比
target和_hovered:- 如果
target !== _hovered,说明发生了状态切换:- 如果有
_hovered,对它触发mouseout。 - 如果有
target,对它触发mouseover。 - 更新
_hovered = target。
- 如果有
- 如果
- 为了更好的体验,当有
target时,我们将鼠标指针设为手型 (pointer),否则设为默认 (default)。
1. 修改 Handler (src/handler/Handler.ts)
我们需要给 Handler 类添加状态属性,并增加 mousemove 的监听逻辑。
为了代码整洁,我提取了一个 _getEventPoint 方法来复用坐标计算。
typescript
// src/handler/Handler.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Displayable } from '../graphic/Displayable';
export class Handler {
storage: Storage;
painter: Painter;
dom: HTMLElement;
// 状态缓存:记录当前正悬停的元素
private _hovered: Displayable | null = null;
constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
this.storage = storage;
this.painter = painter;
this.dom = dom;
this._initDomEvents();
}
private _initDomEvents() {
// 绑定 this 上下文
this.dom.addEventListener('click', this._clickHandler.bind(this));
this.dom.addEventListener('mousemove', this._mouseMoveHandler.bind(this));
// 这里还可以加 mousedown, mouseup 等
}
/**
* 辅助方法:获取相对于 Canvas 左上角的坐标
*/
private _getEventPoint(e: MouseEvent) {
const rect = this.dom.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
private _clickHandler(e: MouseEvent) {
const { x, y } = this._getEventPoint(e);
const target = this._findHover(x, y);
if (target) {
target.trigger('click', { target, event: e });
}
}
/**
* 核心:处理鼠标移动,计算 Hover 状态
*/
private _mouseMoveHandler(e: MouseEvent) {
const { x, y } = this._getEventPoint(e);
const target = this._findHover(x, y);
const lastHovered = this._hovered;
// 如果鼠标下的元素变了
if (target !== lastHovered) {
// 1. 处理移出 (MouseOut)
// 如果之前有悬停元素,说明从那个元素出来了
if (lastHovered) {
lastHovered.trigger('mouseout', { target: lastHovered, event: e });
}
// 2. 处理移入 (MouseOver)
// 如果当前有元素,说明进入了这个元素
if (target) {
target.trigger('mouseover', { target: target, event: e });
}
// 3. 更新状态
this._hovered = target;
}
// 4. 处理鼠标移动 (MouseMove)
// 即使目标没变,也可以触发 move 事件
if (target) {
target.trigger('mousemove', { target, event: e });
}
// 5. 设置光标样式 (UX 优化)
if (target) {
this.dom.style.cursor = 'pointer';
} else {
this.dom.style.cursor = 'default';
}
}
private _findHover(x: number, y: number): Displayable | null {
const list = this.storage.getDisplayList();
for (let i = list.length - 1; i >= 0; i--) {
const el = list[i];
if (el.invisible) continue;
if (el.contain(x, y)) {
return el;
}
}
return null;
}
}
2. 验证
现在我们来写一个复杂的 Demo。我们将创建多个矩形,每个矩形都有独立的悬停变色效果。
index.ts:
typescript
import { init } from './core/MiniRender';
import { Rect } from './graphic/shape/Rect';
import { Text } from './graphic/Text';
const miniRender = init(document.getElementById('main')!);
// 创建 5 个卡片
for (let i = 0; i < 5; i++) {
const x = 50 + i * 110;
const y = 100;
// --- 创建矩形 ---
const rect = new Rect({
shape: { x: x, y: y, width: 100, height: 100, r: 5 },
style: {
fill: '#FFF',
stroke: '#999',
lineWidth: 2
}
});
// --- 创建文本 ---
const text = new Text({
style: {
text: `Card ${i + 1}`,
fill: '#666',
fontSize: 14,
textAlign: 'center',
textBaseline: 'middle'
},
position: [x + 50, y + 50],
z: 1,
});
// --- 绑定交互事件 ---
// 1. 移入高亮
rect.on('mouseover', () => {
console.log(`Mouse Over Rect ${i}`);
// 变色
rect.style.fill = '#E6F7FF'; // 浅蓝背景
rect.style.stroke = '#1890FF'; // 深蓝边框
// 放大动画效果(这里手动改 scale)
// 稍微放大一点,注意 scale 是以 origin 为中心的
// 我们还没有实现自动计算中心,所以这里手动设
rect.origin = [x + 50, y + 50];
rect.scale = [1.1, 1.1];
miniRender.refresh();
});
// 2. 移出恢复
rect.on('mouseout', () => {
console.log(`Mouse Out Rect ${i}`);
// 恢复颜色
rect.style.fill = '#FFF';
rect.style.stroke = '#999';
// 恢复大小
rect.scale = [1, 1];
miniRender.refresh();
});
miniRender.add(rect);
miniRender.add(text);
}
// 简单的提示
const tip = new Text({
style: {
text: 'Try Hovering on the cards!',
fill: '#333',
fontSize: 18,
},
position: [50, 30]
});
miniRender.add(tip);
// 渲染
miniRender.refresh();
预期效果
- 光标变化:当鼠标移动到卡片(白色矩形)上时,鼠标指针会变成手型。
- 高亮反馈 :
- 移入时:卡片变大(1.1倍),背景变浅蓝,边框变深蓝。
- 移出时:卡片恢复原状。
- 日志 :控制台会打印出对应的
Mouse Over和Mouse Out。 - 状态切换 :如果你快速从卡片 1 移到卡片 2(不经过空白区),你会发现卡片 1 立刻恢复,卡片 2 立刻高亮。这就是
target !== lastHovered逻辑在起作用。
3.缺陷
在上面的代码中,如果鼠标移到了中间的文字 Card N 上:
- 因为 Text 也是个 Displayable,而且在 Rect 上面 (
z: 1)。 - Handler 会认为
target变成了Text。 - 于是
Rect会触发mouseout(变回白色)。 - 如果你没给
Text绑定事件,它就没有反应。
结果:鼠标在卡片边缘是高亮的,一移到文字上,卡片就"灭"了。
解决方案思路 : 在 ZRender 和 ECharts 中,有一个属性叫 silent (静默)。 如果 el.silent = true,则 Handler 在 _findHover 遍历时会直接跳过它 (continue)。这样检测到的 target 就会是底下的 Rect。
- 修改
src/graphic/Displayable.ts:
typescript
export interface DisplayableProps extends ElementProps {
// ...
silent?: boolean; // 新增:是否响应交互
}
export abstract class Displayable extends Element {
// ...
silent: boolean = false;
constructor(opts?: DisplayableProps) {
super(opts);
// ...
if (opts && opts.silent != null) this.silent = opts.silent;
}
}
- 修改
src/handler/Handler.ts的_findHover:
typescript
private _findHover(x: number, y: number): Displayable | null {
const list = this.storage.getDisplayList();
for (let i = list.length - 1; i >= 0; i--) {
const el = list[i];
// 增加 !el.silent 判断
if (el.invisible || el.silent) continue;
if (el.contain(x, y)) {
return el;
}
}
return null;
}
- 修改
index.ts中的 Text 创建:
typescript
const text = new Text({
// ... 样式
silent: true // 关键!让文字不阻挡鼠标
});
现在,鼠标移到文字上时,Handler 会忽略文字,直接检测到下面的 Rect,Hover 效果就不会中断了。
