要实现交互,我们需要解决三个层面的问题:
- 数学层:矩阵求逆(为了把鼠标点转换到图形内部坐标系)。
- 基类层:事件订阅机制(On/Off)和包含判断接口(Contain)。
- 控制层:监听 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();
- 屏幕上有一个旋转的圆。
- 当你点击圆的内部时,控制台输出 "Circle Clicked!",并且圆颜色在红蓝之间切换。
- 关键点 :即使圆旋转到了奇怪的角度,或者被 Group 缩放了,只要你的鼠标点在视觉上的圆内,事件就应该触发。这就是
invert逆矩阵的作用。
