前言
在数据可视化过程中,数据不是单单的展示,更需要与图表交互。本章节中,我们将构建我们引擎的事件处理框架。
在HTML中,我们可以为某个DOM绑定相应的事件,这里我们将图元类比HTML中的DOM元素,我们可以给对应的图元绑定相应的事件。
事件对象
首先我们定义一下事件基类Event。
export default class Event {
static EVENT_MOUSE_DOWN = 1; // 鼠标按下 MouseDownEvent
static EVENT_MOUSE_MOVE = 2; // 鼠标移动 MouseMoveEvent
static EVENT_MOUSE_UP = 3; // 鼠标弹起 MouseUpEvent
static EVENT_CLICK = 4; // 鼠标单击 ClickEvent
static EVENT_LONG_CLICK = 5; // 长按鼠标 LongClickEvent
static EVENT_DRAG = 6; // 拖拽事件(鼠标或手势)DragEvent
static EVENT_DRAG_END = 14; // 拖拽结束事件 DragEndEvent
static EVENT_TOUCH_START = 7; // TOUCH START事件 TouchStartEvent
static EVENT_TOUCH_MOVE = 8; // TOUCH MOVE事件 TouchMoveEvent
static EVENT_TOUCH_END = 10; // TOUCH END事件 TouchEndEvent
static EVENT_TAP = 11; // 手势TAP事件 TapEvent
static EVENT_LONG_TAP = 12; // 手势长按事件 LongTouchEvent
static EVENT_SCALE = 13; // 缩放事件 ScaleEvent
static EVENT_WHEEL = 15; // 滚轮事件 WheelEvent
static EVENT_MOUSE_IN = 16; // 鼠标进入事件 MouseInEvent
static EVENT_MOUSE_OUT = 17; // 鼠标移除事件 MouseOutEvent
constructor(_event, _callback) {
this.event = _event; // 事件类型
this.callback = _callback; // 事件回调函数
this.node = null; // 图元对象
this.isProcessed = false; // 事件处理标志,事件是否被处理
this.eventPoint = null; // 事件发生坐标
this.clientPoint = null; // 事件发生的文档坐标
this.manager = null; // 事件管理器
}
/**
* 事件处理函数
*/
doEvent() {
if (!this.isProcessed) {
this.callback && this.callback(this);
this.isProcessed = true;
}
}
}
由上述代码,可以看出,我们的事件类很简单,除了构造函数,就只有一个事件处理函数。这里我们定义了17种事件类型。由于篇幅有限,我们将不一一介绍每个事件子类的具体实现,有兴趣可以查看git源码来查看每个事件子类的实现,这里我们将在下面给出几个具体的实现方式。
图元的点击与访问
要想捕获到某个图元上的事件,我们必须要获得该图元的访问。Canvas 规范为我们提供了addHitRegion() 方法在canvas上添加一个点击区域,使得我们可以检测画布上特定区域的事件。但是该方法目前尚在实验中,存在兼容问题,这里我们就不展开讨论利用addHitRegion() 方法构建我们的引擎了。我们将通过自定义访问区域来实现我们的事件引擎。我们给图元类新增一个抽象函数containsPoint,图元子类通过实现该方法来描述该图元函数的访问区域。
export default class Node {
// ...其他代码
containsPoint(point) {
return false;
}
}
containsPoint函数提供了当前事件发生的画布坐标,图元函数可以根据该坐标计算该坐标是否被包含在该图元中,下面是矩形图元的containsPoint函数的实现。
containsPoint(point) {
return point.x <= this.position.x + this.width / 2 // x小于矩形的最右边框
&& point.x >= this.position.x - this.width / 2 // x大于矩形的最左边框
&& point.y >= this.position.y - this.height / 2 // y大于矩形下边框
&& point.y <= this.position.y + this.height / 2;// y小于矩形上边框
}
这里我们没有考虑矩形的旋转及其他变化,有兴趣的同学可以自己利用三角函数实现。我们给前面章节的基本图元都添加了访问函数containsPoint(),由于篇幅有限,有兴趣的同学可以自己查看git源码。
事件管理器
我们通过定义事件管理器EventManager进行事件管理,事件管理器的功能如下:
- 事件注册
- 事件解析
- 事件分发
管理事件注册功能我们采用注册监听模式,所以我们需要先定义一个监听器类Listener,并为管理器添加监听器队列listeners和addEventListener监听注册函数与removeEventListener移除监听函数。监听器类Listener代码如下:
export default class Listener {
constructor(_obj, _event) {
if (_obj instanceof Node && _event instanceof Event) {
this.obj = _obj; // 图元对象
this.event = _event;
} else {
throw new Error('Error Arguments for create event listener');
}
}
}
EvnetManager类addEventListener与removeEventListener代码如下:
export default class EventManager {
constructor(canvas) {
this.canvas = canvas;
this.listeners = []; // 事件监听器实例列表
}
addEventListener(listener) {
const indexs = this.listeners.findIndex((item) => {
return item.event.event === listener.event.event
&& item.obj === listener.obj;
});
if (indexs >= 0) {
return;
}
if (listener instanceof Listener) {
this.listeners.push(listener);
} else {
throw new Error('Error arguments of addEventListener');
}
}
removeEventListener(listener) {
for (let i = 0; i < this.listeners.length; i++) {
const l = this.listeners[i];
if (l.event.event === listener.event.event) {
this.listeners.splice(i, 1)
break;
}
}
}
}
事件管理器解析事件,并将事件分发到相应的图元上,所以我们事件管理器需要全面接管canvas画布上的事件,由于篇幅限制,我们这里仅以拖拽与点击事件为例,具体其他事件可以参考git源码。
export default class EventManager {
constructor(canvas) {
this.canvas = canvas;
this.listeners = []; // 事件监听器实例列表
this.processQueue = []; // 事件处理队列
this.moveQueue = []; // 拖动事件处理队列
// 接管canvas mouse down 事件
this.canvas.onmousedown = (e) => {
this.mouseDown(e);
};
// 接管canvas mouse up 事件
this.canvas.onmouseup = (e) => {
this.mouseUp(e);
};
// 接管canvas mouse move事件
this.canvas.onmousemove = (e) => {
this.mouseMove(e);
};
}
// ...其他代码
mouseDown(e) {
// 获取canvas画布大小及其相对于视口的位置
const box = this.canvas.canvas.getBoundingClientRect();
// 将鼠标X坐标转换为画布X轴坐标
const mouseX = (e.clientX - box.left) * this.canvas.width / box.width;
// 将鼠标Y坐标转换为画布Y轴坐标
const mouseY = (e.clientY - box.top) * this.canvas.height / box.height;
// 根据坐标系规则变化坐标
const point = new Point(mouseX, this.canvas.height - mouseY);
// 遍历监听器,寻找符合事件触发条件的监听器
for (const listener of this.listeners) {
if (listener.obj !== null && listener.obj instanceof Node) {
// 若事件点击点包含于图元中
if (listener.obj.containsPoint(point)) {
// 声明事件对象
let event = null;
if (listener.event.event === Event.EVENT_CLICK) {
// 事件类型为点击事件,构建点击事件
event = new ClickEvent(listener.event.callback);
} else if (listener.event.event === Event.EVENT_LONG_CLICK) {
event = new LongClickEvent(listener.event.callback);
} else if (listener.event.event === Event.EVENT_MOUSE_DOWN) {
event = new MouseDownEvent(listener.event.callback);
} else if (listener.event.event === Event.EVENT_DRAG) {
// 事件类型为拖拽事件,构建拖拽事件
event = new DragEvent(listener.event.callback);
// 拖拽事件需要记录多个操作事件,如鼠标移动事件,所以将其加入移动事件队列moveQueue
this.pushMoveEvent(event);
} else if (listener.event.event === Event.EVENT_MOUSE_MOVE) {
event = new MouseMoveEvent(listener.event.callback);
this.pushMoveEvent(event);
} else if (listener.event.event === Event.EVENT_DRAG_END) {
event = new DragEndEvent(listener.event.callback);
this.pushMoveEvent(event);
}
if (event) {
// event !==null 意味着该图元注册了事件
// 设置事件必要的其他属性
// 设置图元对象
event.node = listener.obj;
// 设置事件发生坐标
event.eventPoint = point;
// 设置事件发生的文档坐标
event.clientPoint = { x: e.clientX, y: e.clientY };
// 将事件加入事件处理队列processQueue
this.pushEvent(event);
}
}
}
}
}
mouseMove(e) {
if (this.moveQueue.length > 0) {
// 阻止默认事件
e.preventDefault();
e.stopPropagation();
}
// 获取canvas画布大小及其相对于视口的位置
const box = this.canvas.canvas.getBoundingClientRect();
// 将鼠标X坐标转换为画布X轴坐标
const mouseX = (e.clientX - box.left) * this.canvas.width / box.width;
// 将鼠标Y坐标转换为画布Y轴坐标
const mouseY = (e.clientY - box.top) * this.canvas.height / box.height;
// 根据坐标系规则变化坐标
const point = new Point(mouseX, this.canvas.height - mouseY);
// 遍历移动事件队列moveQueue
for (const event of this.moveQueue) {
if (!event.isProcessed) {
// 事件未被处理
if (event instanceof MouseMoveEvent) {
// 鼠标移动事件
Promise.resolve().then(() => {
// 调用鼠标移动事件的moving方法处理事件
event.moving(point);
});
} else if (event instanceof DragEvent) {
// 拖拽事件
Promise.resolve().then(() => {
event.dragging(point);
});
}
}
}
}
mouseUp(e) {
// processQueue事件队列中存储的都是MouseDown事件开始的,所以MouseUp为所有在processQueue队列的结束标志事件
while (this.processQueue.length > 0) {
// 从队列中取出事件
const event = this.popEvent();
if (!event.isProcessed) {
// 若事件未被处理
Promise.resolve().then(() => {
event.doEvent();
});
}
}
}
}
上面的注释已经非常详细了,这里我们就不赘述了。接下来,我们看一下ClickEvent类和DragEvent类。
export default class ClickEvent extends Event {
constructor(_callback) {
super(Event.EVENT_CLICK, _callback);
this.startTime = new Date().getTime();
}
doEvent() {
const endTime = new Date().getTime();
if (endTime - this.startTime < 1500) {
// 当前时间小于1.5s为click事件, 区别与长按事件
this.callback(this);
}
this.isProcessed = true;
}
}
export default class DragEvent extends Event {
constructor(callback) {
super(Event.EVENT_DRAG, callback);
this.distanceX = 0; // X轴移动距离
this.distanceY = 0; // Y轴移动距离
}
dragging(point) {
if (!this.isProcessed) {
// 计算移动距离
this.distanceX = point.x - this.eventPoint.x;
this.distanceY = point.y - this.eventPoint.y;
// 把当前事件点赋值给事件发生坐标属性
this.eventPoint = point;
setTimeout(() => {
this.callback(this);
}, 0);
}
}
doEvent() {
// 将事件从管理器中移除
this.manager.moveQueue.splice(this.manager.moveQueue.indexOf(this), 1);
super.doEvent();
}
}
至此,我们构建可视化引擎的基础建设已经阶段性完成,下面的章节我们将通过该基础框架构建我们具体的可视化图表。
目录
【实现自己的可视化引擎01】认识Canvas
【实现自己的可视化框架引擎02】抽象图像元素
【实现自己的可视化引擎03】构建基础图元库
【实现自己的可视化引擎04】图像元素动画
【实现自己的可视化引擎05】交互与事件
【实现自己的可视化引擎06】折线图
【实现自己的可视化引擎07】柱状图
【实现自己的可视化引擎08】条形图
【实现自己的可视化引擎09】饼图
【实现自己的可视化引擎10】散点图
【实现自己的可视化引擎11】雷达图
【实现自己的可视化引擎12】K线图
【实现自己的可视化引擎13】仪表盘
【实现自己的可视化引擎14】地图
【实现自己的可视化引擎15】关系图