简介

G2 1.x,G2 2.x 内置了所有的交互,哪些交互能用,哪些不能用全部是固定的,无法更改,只能设置是否生效。
G2 3.x 开始我们探索 interaction 的形式,在 F2 上有了比较好的应用,G2 上也实现了几个 interaction 但是仅仅算是尝试。
G2 4.0 开始,我们从底层的 G 4.0 开始重构事件的实现,组件层统一接口和事件机制,上层 Geometry 拆分出 Element 用以适应新的交互机制,一切已经就绪,我们开始交互语法的新篇章。

交互方案的选择

我们来看一下从简到复杂的几种交互方案:

  • 内置交互:不可更改
  • 可注册交互:每个交互一个名称,可以增加配置项用以控制触发和反馈
  • 交互语法:将交互分为多个阶段,每个阶段分为触发和反馈,实现常见的反馈,通过搭配触发和反馈组合出一套新的交互

内置交互

我们可以看出 内置交互 就是 1.x 和 2.x 的方案,这种方案实现简单,但是无法满足一些定制场景的需求。交互的配置项同组件的配置项设置在一起:

  1. chart.legend({checkable: false}); // 图例不能被选中、取消选中
  2. chart.tooltip({triggerEvent: 'click'}); // 更改 tootip 的触发方式
  • 由于交互实现在 G2 的代码内部,所以无法扩展

    可注册交互

    目前 G2、F2、G6 都实现了可注册交互:
  1. class Brush extends Interaction {
  2. start() {}
  3. process() {}
  4. end() {}
  5. reset() {}
  6. }
  7. G2.registerInteraction('brush', Brush);

这种方案下,交互都是在外部插入的,所以用户可以很方便的禁用交互、设置交互或者自定义交互,但是目前存在以下问题:

  • 组件和 Geometry 并没有提供太多交互的接口,
  • 只能操作底层 shape,很难拿到对应的数据
  • 组件的接口不统一,一个交互只能对应一个组件

交互语法

G2 的图形语法本质上是将数据映射到图形的过程拆解成为 Coord, Scale, Attr, Geometry 和 Shape ,然后通过这几个组合组合搭配,可以产生千变万化的图表。而交互语法也是一样,需要将交互拆解,然后再组合:

  • 一个交互由多个交互环节构成,这些环节的类型有:
    • 示能:表示交互可以进行
    • 开始:交互开始
    • 持续:交互持续
    • 结束:交互结束
    • 暂停:交互暂停
    • 回滚:取消交互,恢复到原始状态
  • 每个交互环节的类型,在一个交互中可以有多个
  • 每个交互环节又可以分为:
    • 触发,交互环节的触发,包括触发对象和触发事件
    • 反馈,交互环节的结果

所以 G2 的交互语法就变成了对交互环节的组装,以框选图表上的数据进行过滤示例:

  • 示能:
    • 触发对象:画布
    • 触发事件:移动进画布绘图区域
    • 反馈:鼠标形状变成十字
  • 开始:
    • 触发对象:画布
    • 触发事件:按下鼠标,并滑动鼠标
    • 反馈:出现 mask
  • 持续 1
    • 触发对象:画布
    • 触发事件:持续滑动鼠标
    • 反馈:mask 随着鼠标变化
  • 持续 2
    • 触发对象:mask
    • 触发事件:mask 的大小变化
    • 反馈:被 mask 遮挡的图形变颜色
  • 结束:
    • 触发对象:画布
    • 触发事件:鼠标抬起
    • 反馈:数据过滤,mask 消失
  • 回滚:
    • 触发对象:画布
    • 触发事件:鼠标双击
    • 反馈:取消过滤,恢复到原始状态

触发

通过上面的示例,我们可以看到触发分为:

  • 触发对象
  • 触发事件

触发对象在 G2 的层面我们可以考到有以下几种:

  • 容器:chart, view
  • Element:图表的图形元素
  • Component:组件
  • 数据源

由于数据源的更新不完全受 图表的控制,所以在这里我们仅考虑前 3 种情况

触发事件是在触发对象上触发的所有事件,

  • 有来自于底层 G 的事件
  • 有 Chart 大小改变的事件,生命周期的事件
  • 也有来自组件的自定义事件

反馈

反馈也分为:

  • 反馈的对象
  • 反馈的结果

反馈的对象类型有:

  • 鼠标,鼠标的形状会在不同的情况下不同
  • Chart、View、Geometry
  • Element
  • Component
  • 数据源

反馈的对象同触发对象一些情况下是一致的,例如 Element 响应鼠标进入时颜色发生变化,但是另外一些情况下两者并不相同,例如:在画布上进行框选时,被框选的 Element 变颜色。

反馈的结果本质上是可以在反馈的对象上进行的操作,例如:

  • 鼠标变换形状
  • Chart 的窗口大小发生变化
  • Geometry 的显示隐藏
  • Element 的状态改变
  • 数据的过滤,重新加载

上下文

交互语法仅依据触发和反馈依然不够,触发和反馈之间如何传递信息?仅仅依靠存储反馈的对象和响应的对象是不够的,一些上下文信息必须具有:

  • 当前进行的交互有哪些交互环节,正在执行到哪一步,哪一步已经完成
  • 当前的容器(Chart、View)上的状态量
  • 当前图形所属的 Element、Component 的状态

同一交互,多个环节的关系

一个交互有多个环节构成,多个环节之间可以并行执行也可顺序执行,也可以部分顺序,部分并行:

  • 鼠标移动到 Element 的图形上,鼠标形状变成手型,同时图形 active(并行)
  • 鼠标移入 view,鼠标形状变成十字,鼠标按下出现 Mask,拖拽过程中 mask 变大(顺序),mask 变大的同时选中的图形 active(并行)

因此,我们需要区分哪些环节可以并行,哪些必须顺序执行。
为了解决这个问题,我们将交互环节划分成不同的阶段,同一个阶段内不同的交互环节可以并行,但是不同阶段必须顺序执行:

  • 交互的开始,可以由多种触发方式,多种响应
  • 交互的持续,必须在开始后才能触发,但是可以同时有多个反馈
  • 交互的结束,必须在交互开始后才能进行

交互语法的设计 - 图1

多个交互之间的影响

由于每个交互有多个交互环节,每个交互环节都独立的触发和反馈,那么每种触发和反馈可能会出现在同一个图表的不同交互中

相同的触发,不同的响应

触发的对象和事件相同时,在不同的交互中会有不同的反馈

  • 框选过滤图形,时鼠标移入 view 变成“十字”,拖拽时出现框选 mask
  • view 允许拖拽时,鼠标移入 view 变成”手型“,拖拽时 view 移动

相同的响应不同的触发

  • 点击 view 的绘图区域,显示临近的图形的 tooltip
  • 在 view 的绘图区域移动,显示临近图形的 tooltip

这两种情况的存在对我们交互语法的实现带来影响:

  • 第一种情况需要使用者自己思考反馈是否有冲突,如果有冲突需要提前避免;
  • 第二种情况要求我们必须保证反馈只能由同一个交互环节触发;

    交互环节的触发条件

    多个交互之间互不干涉,需要给交互环节的执行限定条件,同时也能解决交互环节按要求执行的问题:

  • 前一个阶段完成后一个阶段才能开始

  • 每个交互环节的触发,必须可以进行控制

例如:

  • 只有交互开始(start) 后,交互 执行、结束的触发才有可能执行
  • 在拖拽的交互中,需要对 dragend 事件结束时的元素进行判定

交互语法的实现

交互语法的设计 - 图2

Trigger

前面我们讨论过触发的对象主要有三种:容器(Chart 和 View) 、Element 和 组件,所以在图形语法中我们需要能够标识这三种元素,我们可以使用命名系统来对这三种对象进行命名:

  • 图表:chart
  • 子视图:view
  • 容器的状态量:selectedElements, cursorInfo 等
  • Element 名称:interval, line, point, area 等名称
  • Element 内部的图形元素的名称: line-label, point-label 等
  • 组件的名称:legend, axis, annotation
  • 组件的组成部分: legend-item, annotaion-line

对象名称同事件名进行组合,使用 : 进行连接,如: interval:click

Action

Action 对象

Action 对触发进行响应,Action 的对象必须与前面的触发关联:

  • 可以是前面触发的对象,
  • 也可以是位置信息计算出来的对象,
  • 还可以是触发对象关联的其他对象

Action 结果

反馈的结果,无法直接用 name + method 来定义,可以在回调函数中指定,为了组合成交互语法,每个 Action 可以事先定义,在交互语法中直接指定 Action 名称即可。

Action 定义

action 的定义中需要解决的问题是:

  • 如何拿到 action 需要的信息
  • 如何区分不同的交互,一个交互的触发 trigger 不应该导致其他交互的响应(Action)

这两个问题本质上是一个交互的上下文
交互的上下文可以挂载在两类对象上:

  • 在 chart、view 设置状态量
  • 在一个交互上设置所有交互环节中的 action 都可以共享的信息

Trigger 和 Action 的约束

从上面我们可以看到一个交互为了顺利的执行,需要在每个交互环节中定义 Trigger 和 Action,这里就需要回答一个问题:是否所有的 trigger 和 action 都能组合,如果不能怎么进行约束?错误搭配的后果是什么?

搭配的合理性

我们用几个示例来说明这个问题:

  • 鼠标进入 view ,鼠标变成十字形状(合理搭配)
  • 鼠标进入 view, legend 的项高亮(不合理搭配)
  • 点击图形,图形选中,显示对应的 tooltip,对应的 legend 项高亮(合理搭配)
  • 点击图例项,view 上显示 tooltip(不合理搭配)
  • 在图例项上 hover,view 上显示 tooltip(合理搭配,但是有些奇怪)
  • 鼠标在 view 上移动,临近的图形 active(合理搭配)
  • 按下 delete 键,选中的 Element 被移除

元素之间的关联性

我们从上面的示例可以看到一些关联性:

  • view, chart 上的位置同 Element 的图形有关联,一般通过鼠标触发(在上面或者临近)
  • Element 的图形同 axis, legend, tooltip 等组件互相关联,一般通过视觉映射通道关联
    • postion 同 axis关联
    • color 同 legend 关联
    • Element 上的整条记录同 tooltip 关联
  • 一些 Trigger 同 view、chart 上的状态量关联

上下文

从上面的分析我们可以看到 Trigger 和 Action 之间需要上下文,有两种上下文:

  • view、chart 上的全局状态量
  • 交互过程中,各个环节需要共享的的信息

view、chart 上的状态量

我们这里枚举一下参与交互的状态量,这些状态量可以自由的定义:

  • size:大小
  • cursorPoint:鼠标位置
  • currentShape, currentElement, currentComponent, currentView 等,根据鼠标位置推导出来的信息
  • activeElements, selectedElements, xxxStateElements,跟状态量相关的 Element
  • 自定义的状态量

交互环节中共享的信息

  • 当前交互的 id
  • 当前交互执行到的 环节,阶段
  • 执行完毕的环节传递给后续环节的信息

以框选过滤为例:

  • 在触发时,需要记录触发开始的坐标点
  • 在拖拽过程中,需要记录当前点的坐标点,同时计算被框选的图形
  • 结束时,取到被框选图形,进行过滤

接口实现和语法

交互语法的实现由三部分组成:

  • 定义反馈 Action
  • 组装交互
  • 使用交互

定义反馈

一个反馈必须有的信息有:

  • 当前交互的上下文
  • view/chart 的状态量(响应交互的对象)
  1. G2.RegisterAction('actionName', {
  2. isEnable(context) { // 是否满足执行的条件
  3. },
  4. excute(context) {
  5. }
  6. });

我们以框选 Element (不过滤)为例来定义多个 Action 反馈:

  • 移动进入 view 时改变鼠标形状
  • 按下鼠标,显示 mask
  • 拖拽鼠标,resize mask, 同时选中mask 范围内的所有 elements
  • 拖拽mask,同时选中范围内所有的 elements

    改变鼠标形状

    1. G2.RegisterAction('cursor-cross', {
    2. isEnable(context) { // 鼠标进入当前 View 时触发
    3. const view = context.view;
    4. const cursorPoint = view.getState('cursorPoint');
    5. return !!cursorPoint;
    6. },
    7. excute(context) {
    8. const view = context.view;
    9. view.setCursor('cross');
    10. }
    11. });

显示 Mask

  1. G2.RegisterAction('create-mask', {
  2. isEnable(context) { // 在 view 中
  3. const view = context.view;
  4. const cursorPoint = view.getState('cursorPoint');
  5. return !!cursorPoint;
  6. },
  7. excute(context) {
  8. const cursorPoint = context.view.getState('cursorPoint');
  9. const mask = createMask(cursorPoint); // 生成 mask 根据
  10. context.startPoint = cursorPoint; // 注册信息ß
  11. context.mask = mask; // 注册信息
  12. }
  13. });

mask resize

  1. G2.RegisterAction('resize-mask', {
  2. isEnable(context) {
  3. return !!context.mask;
  4. },
  5. excute(context) {
  6. const currentPoint = context.view.getState('cursorPoint');
  7. const startPoint = context.startPoint;
  8. const mask = context.mask;
  9. resizeMask(mask);// 自己实现
  10. }
  11. });

clear mask

  1. G2.RegisterAction('clear-mask', {
  2. isEnable(context) {
  3. return !!context.mask;
  4. },
  5. excute(context) {
  6. const mask = context.mask;
  7. removeMask(mask);// 自己实现
  8. }
  9. });

range selected elements

  1. G2.RegisterAction('range-selected', {
  2. isEnable(context) {
  3. return !!context.startPoint;
  4. },
  5. excute() {
  6. const currentPoint = context.view.getState('cursorPoint');
  7. const startPoint = context.startPoint;
  8. const elements = view.findElements(callback);
  9. view.clearStates('selected'); // 取消选中
  10. // 设置 selected 状态
  11. elements.forEach(el => {
  12. el.setState('selected', true);
  13. });
  14. }
  15. });

移动 mask

  1. G2.RegisterAction('move-mask', {
  2. isEnable(context) {
  3. return context.mask;
  4. },
  5. excute(context) {
  6. const currentPoint = context.view.getState('cursorPoint');
  7. const startPoint = context.startPoint;
  8. const mask = context.mask;
  9. resizeMask(mask);// 自己实现
  10. }
  11. });

组装交互

我们以上面定义的 Action 来组装交互,我们分成两个交互:

  • 框选图形,设置选中
  • 拖拽 mask,改变选中
    1. G2.RegisterInteraction('range-drag-selected', {
    2. showEnable: [
    3. {trigger: 'view:enter', action: 'cursor-cross'},
    4. ],
    5. closeEnable: [
    6. {trigger: 'view:leave', action: 'cursor-default'}
    7. ],
    8. start: [
    9. {trigger: 'view:mousedow', action: 'create-mask'}
    10. ],
    11. processing: [
    12. {trigger: 'view:mousemove', action: 'resize-mask'},
    13. {trigger: 'view:mousemove', action: 'range-selected'}
    14. ],
    15. end: [
    16. {trigger: 'view:mouseup'}) // 无 action ,但是会清除 context 上的上下文信息
    17. ],
    18. rollback: [
    19. {trigger: 'view:dblclick', action: 'clear-selected'}
    20. ]
    21. });
  1. G2.RegisterInteraction('drag-mask', {
  2. showEnable: [
  3. {trigger: 'mask:mouseenter', action: 'cursor-pointer'}
  4. ],
  5. closeEnable: [
  6. {trigger: 'mask:mouseleave', action: 'cursor-default'}
  7. ],
  8. start: [
  9. {trigger: 'mask:drag', action(context) {
  10. context.startPoint = context.view.getState('cursorPoint');
  11. }}
  12. ],
  13. end: [
  14. {trigger: 'mask:dragend'}
  15. ],
  16. rollback: [
  17. {trigger: 'view:dblclick', action: 'clear-mask'},
  18. {trigger: 'view:dblclick', action: 'clear-selected'}
  19. ]
  20. });

继续改进

我们可以看到上面的 Action 定义和交互语法的组合存在以下一些问题:

  • action 定义繁琐,需要将一个交互的细节拆分为非常多的 action,多个 action 其实都是在操作一个元素
  • 上下文依赖,一个交互中,各个环节,存在上下文的依赖,需要在交互的某个环节注入依赖信息,这些信息并不透明,易于理解。
  • 触发条件如何限定,一个交互的执行必须满足一些条件,否则会出现多个交互冲突、多个交互环节混乱的情况

解决 action 的繁琐问题

我们可以将交互过程中的元素定位为一个对象,实现一些不需要参数的方法,action 中直接执行对象的方法,例如:

  1. class Mask {
  2. constructor(contex) {},
  3. move() {},
  4. show() {},
  5. hide() {},
  6. resize() {},
  7. destroy() {}
  8. }

此时在交互语法中可以直接使用, context 上的name 和 方法名进行组合 ‘mask:show’

  1. G2.RegisterInteraction('range-drag-selected', {
  2. showEnable: [
  3. {trigger: 'view:enter', action: 'cursor-cross'},
  4. ],
  5. closeEnable: [
  6. {trigger: 'view:leave', action: 'cursor-default'}
  7. ],
  8. start: [
  9. {trigger: 'view:mousedow', action(context) {
  10. context.mask = new Mask(context);
  11. }}
  12. ],
  13. processing: [
  14. {trigger: 'view:mousemove', action: 'mask:resize'},
  15. {trigger: 'view:mousemove', action: 'range-selected'}
  16. ],
  17. end: [
  18. {trigger: 'view:mouseup'}) // 无 action ,但是会清除 context 上的上下文信息
  19. ],
  20. rollback: [
  21. {trigger: 'view:dblclick', action: 'mask:destroy'},
  22. {trigger: 'view:dblclick', action: 'clear-selected'}
  23. ]
  24. });

解决上下文依赖问题

上下文之所以出现依赖,很大原因是一些按顺序执行的交互在操作共同的图形元素,这些交互有依赖性或者共同性,这时候我们可以按照上面定义 Mask 时一样来定义 Action 对应的对象,我们可以来定义框选对象:

  1. class RangeSelected extends Action {
  2. name = 'range';
  3. constructor(context) {}
  4. start() {
  5. this.startPoint = this.context.view.getState('cursorPoint');
  6. }
  7. selected() {
  8. const context = this.context;
  9. const currentPoint = context.view.getState('cursorPoint');
  10. const startPoint = this.startPoint; // 不再往 context 上绑定属性
  11. const elements = view.findElements(callback);
  12. context.view.clearStates('selected'); // 取消选中
  13. // 设置 selected 状态
  14. elements.forEach(el => {
  15. el.setState('selected', true);
  16. });
  17. }
  18. clear() {
  19. context.view.clearStates('selected'); // 取消选中
  20. }
  21. }
  22. class Mask extends Action {
  23. name = 'mask';
  24. constructor(context) {}
  25. show() {
  26. this.startPoint = this.context.view.getState('cursorPoint');
  27. showMask();
  28. }
  29. hide() {}
  30. resize() {
  31. const currentPoint = context.view.getState('cursorPoint');
  32. const startPoint = this.startPoint;
  33. // resizemask
  34. }
  35. destroy() {}
  36. }
  37. G2.RegisterAction('mask', Mask);
  38. G2.RegisterAction('range', RangeSelected);

触发条件的限定

仅仅靠 elementName:eventName 的组合方式来限定触发,无法解决多个交互之间的冲突,所以我们需要给触发添加限定,限定触发有多个途径:

  • 在交互的执行过程中,控制交互环节的触发。在 interaction 的机制中保障,编写 action 和 组合语法时不需要感知
  • 在 Action 每个方法中增加限定条件,不满足,则拒绝执行。可以解决部分问题,主要的目的是避免执行中报错,而无法进行各种触发条件的判定
  • trigger 触发时增加限定条件,可以在交互环节上增加触发的回调函数

改进后语法的实现

  1. G2.RegisterInteraction('range-drag-selected', {
  2. // actions: [Mask, RangeSelected, Cursor],
  3. // 初始化时,可以扫描所有的 action ,自动挂载在上下文上
  4. showEnable: [
  5. {trigger: 'view:enter', action: 'cursor:cross'},
  6. ],
  7. closeEnable: [
  8. {trigger: 'view:leave', action: 'cursor:default'}
  9. ],
  10. start: [
  11. // 开始
  12. {trigger: 'view:mousedown', isEnable(context) {
  13. const event = context.event;
  14. if (event.shape) { // 点击到了图形元素,不触发框选
  15. return false;
  16. }
  17. return true;
  18. }, 'mask:show;range:start'},
  19. ],
  20. processing: [
  21. {trigger: 'view:mousemove', action: 'mask:resize;range:selected'},
  22. ],
  23. end: [
  24. {trigger: 'view:mouseup'})
  25. ],
  26. rollback: [
  27. {trigger: 'view:dblclick', action: 'range:clear;mask:hide'}
  28. ]
  29. });

显示 tooltip

定义 tooltip 的 Action

  1. class SingleTooltip extends Action {
  2. show() {}
  3. hide() {}
  4. }
  5. class SharedTooltip extends Action {
  6. show() {}
  7. change() {}
  8. hide() {}
  9. }
  10. G2.RegisterAction('single-tooltip', SingleTooltip);
  11. G2.RegisterAction('shared-tooltip', SharedTooltip);

单个图形上显示 tooltip

  1. G2.RegisterInteraction('show-single-tooltip', {
  2. start: [
  3. {trigger: 'element:mouseenter', action: 'single-tooltip:show'},
  4. {trigger: 'element:mouseenter', action: 'element-active:start'},
  5. ],
  6. end: [
  7. {trigger: 'element:mouseout', action: 'single-tooltip:hide'},
  8. {trigger: 'element:mouseout', action: 'element-active:end'},
  9. ]
  10. });

共享显示多个图形的 tooltip

  1. G2.RegisterInteraction('show-shared-tooltip', {
  2. start: [
  3. {trigger: 'view:enter', action: 'shared-tooltip:show'},
  4. ],
  5. processing: [
  6. {trigger: 'view:move', action: 'shared-tooltip:change'},
  7. ],
  8. end: [
  9. {trigger: 'view:leave', action: 'shared-tooltip:hide'},
  10. ]
  11. });
  1. chart.addInteraction('show-single-tooltip', {
  2. onStart(context) {},
  3. onProcessing(context) {},
  4. onEnd(context) {}
  5. });
  6. chart.addInteraction('show-shared-tooltip');