从零实现2D绘图引擎:3.交互系统(Handle)的实现

MiniRender仓库地址参考

要实现交互,我们需要解决三个层面的问题:

  1. 数学层:矩阵求逆(为了把鼠标点转换到图形内部坐标系)。
  2. 基类层:事件订阅机制(On/Off)和包含判断接口(Contain)。
  3. 控制层:监听 DOM 事件并分发给图形。

1.数学工具升级 (矩阵求逆)

我们在 src/utils/matrix.ts 中只实现了乘法和合成。为了做碰撞检测,我们需要逆矩阵 。 图形被平移、旋转、缩放了。判断点 (100, 100) 是否在被旋转了 30 度的矩形里很难;但把点逆向旋转 30 度,判断它是否在未旋转的矩形里很简单。

src/utils/matrix.ts 中追加:

typescript 复制代码
// src/utils/matrix.ts

/**
 * 求逆矩阵
 * out = invert(a)
 */
export function invert(out: MatrixArray, a: MatrixArray): MatrixArray {
    const aa = a[0], ac = a[2], atx = a[4];
    const ab = a[1], ad = a[3], aty = a[5];

    // 计算行列式
    let det = aa * ad - ab * ac;
    if (!det) {
        // 行列式为0,无法求逆,返回 null 或 设为单位矩阵
        return [1, 0, 0, 1, 0, 0] as any; // 简单处理
    }
    det = 1.0 / det;

    out[0] = ad * det;
    out[1] = -ab * det;
    out[2] = -ac * det;
    out[3] = aa * det;
    out[4] = (ac * aty - ad * atx) * det;
    out[5] = (ab * atx - aa * aty) * det;
    return out;
}

2.实现事件中心 (Eventful)

我们需要一个类来管理 .on, .trigger

创建 src/core/Eventful.ts

typescript 复制代码
// src/core/Eventful.ts

type EventHandler = (...args: any[]) => void;

export class Eventful {
    private _handlers: { [event: string]: EventHandler[] } = {};

    on(event: string, handler: EventHandler): this {
        if (!this._handlers[event]) {
            this._handlers[event] = [];
        }
        this._handlers[event].push(handler);
        return this;
    }

    off(event?: string, handler?: EventHandler): this {
        // 简化实现:清空指定事件或全部
        if (event && !handler) {
            this._handlers[event] = [];
        } else if (!event) {
            this._handlers = {};
        }
        // 完整实现还需要处理移除特定 handler,这里略过
        return this;
    }

    trigger(event: string, ...args: any[]): this {
        const handlers = this._handlers[event];
        if (handlers) {
            handlers.forEach(h => h.apply(this, args));
        }
        return this;
    }
}

Element 继承 Eventful。 修改 src/graphic/Element.ts

typescript 复制代码
import { Eventful } from '../core/Eventful';
// ...
export abstract class Element extends Eventful { 
    // ... 
}

3.图形拾取逻辑 (Element & Shape)

我们需要在 Displayable 中定义标准,并在 Circle 中实现具体算法。

1. 修改 src/graphic/Element.ts 增加坐标转换方法。这是交互的核心。

typescript 复制代码
// src/graphic/Element.ts

export abstract class Element extends Eventful {
    // ... 原有代码 ...

    // 辅助矩阵,避免重复创建对象 (GC优化)
    private static _invertMat: MatrixArray = matrix.create();

    /**
     * 将全局坐标转换到当前元素的局部坐标系
     * @param x 全局 x
     * @param y 全局 y
     * @return [localX, localY]
     */
    globalToLocal(x: number, y: number): Point {
        const m = this.globalTransform;
        // 计算逆矩阵
        // 注意:这里用简单的静态变量缓存逆矩阵,非线程安全但JS是单线程所以OK
        const inv = Element._invertMat;
        matrix.invert(inv, m);

        // 应用逆变换: x' = a*x + c*y + tx
        const lx = inv[0] * x + inv[2] * y + inv[4];
        const ly = inv[1] * x + inv[3] * y + inv[5];
        
        return [lx, ly];
    }
}

2. 修改 src/graphic/Displayable.ts 增加抽象方法 contain

typescript 复制代码
// src/graphic/Displayable.ts

export abstract class Displayable extends Element {
    // ... 原有代码 ...

    /**
     * 判断点是否在图形内
     * @param x 全局 x
     * @param y 全局 y
     */
    contain(x: number, y: number): boolean {
        // 1. 转换为局部坐标
        const local = this.globalToLocal(x, y);
        // 2. 调用具体形状的几何判断
        return this.containLocal(local[0], local[1]);
    }

    /**
     * 具体形状实现这个方法,判断局部坐标是否在路径内
     */
    abstract containLocal(x: number, y: number): boolean;
}

3. 修改 src/graphic/shape/Circle.ts 实现圆形的几何判断。

typescript 复制代码
// src/graphic/shape/Circle.ts

export class Circle extends Displayable {
    // ... 原有代码 ...

    containLocal(x: number, y: number): boolean {
        // 圆形判断很简单:点到圆心的距离 < 半径
        // 注意:这里的 x,y 已经是经过逆变换的,所以是在圆没有被旋转缩放的坐标系下
        // 而 this.shape.cx/cy 也是在这个坐标系下
        const d2 = Math.pow(x - this.shape.cx, 2) + Math.pow(y - this.shape.cy, 2);
        return d2 <= this.shape.r * this.shape.r;
    }
}

4.实现 Handler 控制器

这是最后一块拼图。它监听 DOM 事件,找到图形,然后由图形触发事件。

创建 src/handler/Handler.ts

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;

    constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
        this.storage = storage;
        this.painter = painter;
        this.dom = dom;

        // 初始化 DOM 监听
        this._initDomEvents();
    }

    private _initDomEvents() {
        // 简单的监听 click 事件作为示例
        this.dom.addEventListener('click', (e) => {
            this._clickHandler(e);
        });
        
        // 实际还有 mousedown, mouseup, mousemove 等复杂逻辑
    }

    private _clickHandler(e: MouseEvent) {
        // 1. 获取相对于 Canvas 的坐标
        // getBoundingClientRect 包含了页面滚动和边框
        const rect = this.dom.getBoundingClientRect();
        // Canvas 的实际像素尺寸是 CSS 尺寸的 dpr 倍
        const x = (e.clientX - rect.left - this.dom.clientLeft) * window.devicePixelRatio;
        const y = (e.clientY - rect.top - this.dom.clientTop) * window.devicePixelRatio;

        // 2. 寻找被点击的图形
        const target = this._findHover(x, y);

        if (target) {
            // 3. 触发图形事件
            console.log('Clicked shape:', target.id);
            target.trigger('click', { target: target, event: e });
        } else {
            console.log('Clicked empty space');
        }
    }

    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        
        // 核心:逆序遍历!
        // 因为 displayList 是按渲染顺序排的(后面的盖在前面),
        // 所以我们检测点击时,要从最上面(数组末尾)开始查。
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            
            // 忽略不可见或不响应鼠标的元素
            if (el.invisible) continue; // 可以再加 ignoreMouse 等标志

            // 碰撞检测
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
}

5.集成到 MiniRender

修改 src/core/MiniRender.ts,初始化 Handler。

typescript 复制代码
// src/core/MiniRender.ts
import { Handler } from '../handler/Handler';

export class MiniRender {
    storage: Storage;
    painter: Painter;
    handler: Handler; // 新增

    constructor(dom: HTMLElement) {
        this.storage = new Storage();
        this.painter = new Painter(dom, this.storage);
        // 初始化交互系统
        this.handler = new Handler(this.storage, this.painter, dom);
    }
    // ...
}

6.测试

现在我们修改 index.ts 来测试点击事件。我们利用 Eventful 的能力。

typescript 复制代码
// index.ts
import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';

const miniRender = init(document.getElementById('main')!);

const group = new Group({ position: [200, 200] });

// 一个红色的圆
const circle = new Circle({
    shape: { r: 50 },
    style: { fill: '#F00' }
});

// 绑定点击事件!
circle.on('click', () => {
    console.log('Circle Clicked!');
    // 点击变色
    if (circle.style.fill === '#F00') {
        circle.style.fill = '#00F'; // 变蓝
    } else {
        circle.style.fill = '#F00'; // 变红
    }
    miniRender.refresh(); // 记得手动刷新
});

group.add(circle);
miniRender.add(group);

// 动画:让它旋转,测试旋转后的点击检测是否准确
let angle = 0;
function loop() {
    angle += 0.01;
    group.rotation = angle;
    // 手动更新 Group 属性,Painter 会在 refresh 时计算矩阵
    
    // 如果想要点击生效,不需要一直 refresh,但为了看动画:
    miniRender.refresh();
    requestAnimationFrame(loop);
}
loop();
  1. 屏幕上有一个旋转的圆。
  2. 当你点击圆的内部时,控制台输出 "Circle Clicked!",并且圆颜色在红蓝之间切换。
  3. 关键点 :即使圆旋转到了奇怪的角度,或者被 Group 缩放了,只要你的鼠标点在视觉上的圆内,事件就应该触发。这就是 invert 逆矩阵的作用。
相关推荐
irises2 小时前
从零实现2D绘图引擎:2.Storage和Painter的实现
前端·数据可视化
irises2 小时前
从零实现2D绘图引擎:1.实现数学工具库与基础图形类
前端·数据可视化
葡萄城技术团队2 小时前
SpreadJS 自定义函数实战指南:从入门到避坑
前端
m0_740043732 小时前
v-bind 和 v-model 的核心区别
前端·javascript·vue.js
魂祈梦2 小时前
页面出现莫名其妙的滚动条
前端·css
重铸码农荣光2 小时前
从零实现一个「就地编辑」组件:深入理解 OOP 封装与复用的艺术
前端·javascript·前端框架
攻心的子乐2 小时前
redission 分布式锁
前端·bootstrap·mybatis
前端老宋Running2 小时前
拒绝“无效焦虑”:为什么你 80% 的 useMemo 都在做负优化?
前端·javascript·react.js
品克缤2 小时前
vue项目配置代理,解决跨域问题
前端·javascript·vue.js