【实现自己的可视化引擎05】交互与事件

770 阅读7分钟

前言

在数据可视化过程中,数据不是单单的展示,更需要与图表交互。本章节中,我们将构建我们引擎的事件处理框架。
在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】关系图