从零实现2D绘图引擎:5.鼠标悬停事件

MiniRender仓库地址参考

好的,我们开始 悬停事件 (Hover Events) 的实现。

这是交互体验中质的飞跃。目前的点击是"瞬间"的,而悬停是"连续"的状态管理。

核心逻辑分析

要实现 mouseover (移入) 和 mouseout (移出),Handler 需要记忆上一帧鼠标在哪一个图形上。

我们定义:

  • target: 当前鼠标下的图形。
  • _hovered: 上一次鼠标所在的图形(缓存状态)。

逻辑如下:

  1. 监听 DOM 的 mousemove
  2. 计算当前鼠标下的 target
  3. 对比 target_hovered
    • 如果 target !== _hovered ,说明发生了状态切换:
      • 如果有 _hovered,对它触发 mouseout
      • 如果有 target,对它触发 mouseover
      • 更新 _hovered = target
  4. 为了更好的体验,当有 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. 光标变化:当鼠标移动到卡片(白色矩形)上时,鼠标指针会变成手型。
  2. 高亮反馈
    • 移入时:卡片变大(1.1倍),背景变浅蓝,边框变深蓝。
    • 移出时:卡片恢复原状。
  3. 日志 :控制台会打印出对应的 Mouse OverMouse Out
  4. 状态切换 :如果你快速从卡片 1 移到卡片 2(不经过空白区),你会发现卡片 1 立刻恢复,卡片 2 立刻高亮。这就是 target !== lastHovered 逻辑在起作用。

3.缺陷

在上面的代码中,如果鼠标移到了中间的文字 Card N 上:

  1. 因为 Text 也是个 Displayable,而且在 Rect 上面 (z: 1)。
  2. Handler 会认为 target 变成了 Text
  3. 于是 Rect 会触发 mouseout(变回白色)。
  4. 如果你没给 Text 绑定事件,它就没有反应。

结果:鼠标在卡片边缘是高亮的,一移到文字上,卡片就"灭"了。

解决方案思路 : 在 ZRender 和 ECharts 中,有一个属性叫 silent (静默)。 如果 el.silent = true,则 Handler_findHover 遍历时会直接跳过它 (continue)。这样检测到的 target 就会是底下的 Rect。

  1. 修改 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;
    }
}
  1. 修改 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;
    }
  1. 修改 index.ts 中的 Text 创建:
typescript 复制代码
    const text = new Text({
        // ... 样式
        silent: true // 关键!让文字不阻挡鼠标
    });

现在,鼠标移到文字上时,Handler 会忽略文字,直接检测到下面的 Rect,Hover 效果就不会中断了。

相关推荐
Howie Zphile1 小时前
NEXTJS/REACT有哪些主流的UI可选
前端·react.js·ui
fruge1 小时前
React Server Components 实战:下一代 SSR 开发指南
前端·javascript·react.js
lichong9511 小时前
harmonyos 大屏设备怎么弹出 u 盘
前端·macos·华为·typescript·android studio·harmonyos·大前端
irises1 小时前
从零实现2D绘图引擎:5.5.简单图表demo
前端·数据可视化
青莲8431 小时前
Android Lifecycle 完全指南:从设计原理到生产实践
android·前端
irises1 小时前
从零实现2D绘图引擎:4.矩形与文本的实现
前端·数据可视化
前端_逍遥生1 小时前
Vue 2 vs React 18 深度对比指南
前端·vue.js·react.js
irises1 小时前
从零实现2D绘图引擎:2.Storage和Painter的实现
前端·数据可视化
irises1 小时前
从零实现2D绘图引擎:3.交互系统(Handle)的实现
前端·数据可视化