简介
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 的方案,这种方案实现简单,但是无法满足一些定制场景的需求。交互的配置项同组件的配置项设置在一起:
chart.legend({checkable: false}); // 图例不能被选中、取消选中chart.tooltip({triggerEvent: 'click'}); // 更改 tootip 的触发方式
class Brush extends Interaction {start() {}process() {}end() {}reset() {}}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(并行)
 
因此,我们需要区分哪些环节可以并行,哪些必须顺序执行。
为了解决这个问题,我们将交互环节划分成不同的阶段,同一个阶段内不同的交互环节可以并行,但是不同阶段必须顺序执行:
- 交互的开始,可以由多种触发方式,多种响应
 - 交互的持续,必须在开始后才能触发,但是可以同时有多个反馈
 - 交互的结束,必须在交互开始后才能进行
 
多个交互之间的影响
由于每个交互有多个交互环节,每个交互环节都独立的触发和反馈,那么每种触发和反馈可能会出现在同一个图表的不同交互中
相同的触发,不同的响应
触发的对象和事件相同时,在不同的交互中会有不同的反馈
- 框选过滤图形,时鼠标移入 view 变成“十字”,拖拽时出现框选 mask
 - view 允许拖拽时,鼠标移入 view 变成”手型“,拖拽时 view 移动
 
相同的响应不同的触发
- 点击 view 的绘图区域,显示临近的图形的 tooltip
 - 在 view 的绘图区域移动,显示临近图形的 tooltip
 
这两种情况的存在对我们交互语法的实现带来影响:
- 第一种情况需要使用者自己思考反馈是否有冲突,如果有冲突需要提前避免;
 - 
交互环节的触发条件
多个交互之间互不干涉,需要给交互环节的执行限定条件,同时也能解决交互环节按要求执行的问题:
 前一个阶段完成后一个阶段才能开始
- 每个交互环节的触发,必须可以进行控制
 
例如:
- 只有交互开始(start) 后,交互 执行、结束的触发才有可能执行
 - 在拖拽的交互中,需要对 dragend 事件结束时的元素进行判定
 
交互语法的实现
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 的状态量(响应交互的对象)
 
G2.RegisterAction('actionName', {isEnable(context) { // 是否满足执行的条件},excute(context) {}});
我们以框选 Element (不过滤)为例来定义多个 Action 反馈:
- 移动进入 view 时改变鼠标形状
 - 按下鼠标,显示 mask
 - 拖拽鼠标,resize mask, 同时选中mask 范围内的所有 elements
 - 拖拽mask,同时选中范围内所有的 elements
改变鼠标形状
G2.RegisterAction('cursor-cross', {isEnable(context) { // 鼠标进入当前 View 时触发const view = context.view;const cursorPoint = view.getState('cursorPoint');return !!cursorPoint;},excute(context) {const view = context.view;view.setCursor('cross');}});
 
显示 Mask
G2.RegisterAction('create-mask', {isEnable(context) { // 在 view 中const view = context.view;const cursorPoint = view.getState('cursorPoint');return !!cursorPoint;},excute(context) {const cursorPoint = context.view.getState('cursorPoint');const mask = createMask(cursorPoint); // 生成 mask 根据context.startPoint = cursorPoint; // 注册信息ßcontext.mask = mask; // 注册信息}});
mask resize
G2.RegisterAction('resize-mask', {isEnable(context) {return !!context.mask;},excute(context) {const currentPoint = context.view.getState('cursorPoint');const startPoint = context.startPoint;const mask = context.mask;resizeMask(mask);// 自己实现}});
clear mask
G2.RegisterAction('clear-mask', {isEnable(context) {return !!context.mask;},excute(context) {const mask = context.mask;removeMask(mask);// 自己实现}});
range selected elements
G2.RegisterAction('range-selected', {isEnable(context) {return !!context.startPoint;},excute() {const currentPoint = context.view.getState('cursorPoint');const startPoint = context.startPoint;const elements = view.findElements(callback);view.clearStates('selected'); // 取消选中// 设置 selected 状态elements.forEach(el => {el.setState('selected', true);});}});
移动 mask
G2.RegisterAction('move-mask', {isEnable(context) {return context.mask;},excute(context) {const currentPoint = context.view.getState('cursorPoint');const startPoint = context.startPoint;const mask = context.mask;resizeMask(mask);// 自己实现}});
组装交互
我们以上面定义的 Action 来组装交互,我们分成两个交互:
- 框选图形,设置选中
 - 拖拽 mask,改变选中
G2.RegisterInteraction('range-drag-selected', {showEnable: [{trigger: 'view:enter', action: 'cursor-cross'},],closeEnable: [{trigger: 'view:leave', action: 'cursor-default'}],start: [{trigger: 'view:mousedow', action: 'create-mask'}],processing: [{trigger: 'view:mousemove', action: 'resize-mask'},{trigger: 'view:mousemove', action: 'range-selected'}],end: [{trigger: 'view:mouseup'}) // 无 action ,但是会清除 context 上的上下文信息],rollback: [{trigger: 'view:dblclick', action: 'clear-selected'}]});
 
G2.RegisterInteraction('drag-mask', {showEnable: [{trigger: 'mask:mouseenter', action: 'cursor-pointer'}],closeEnable: [{trigger: 'mask:mouseleave', action: 'cursor-default'}],start: [{trigger: 'mask:drag', action(context) {context.startPoint = context.view.getState('cursorPoint');}}],end: [{trigger: 'mask:dragend'}],rollback: [{trigger: 'view:dblclick', action: 'clear-mask'},{trigger: 'view:dblclick', action: 'clear-selected'}]});
继续改进
我们可以看到上面的 Action 定义和交互语法的组合存在以下一些问题:
- action 定义繁琐,需要将一个交互的细节拆分为非常多的 action,多个 action 其实都是在操作一个元素
 - 上下文依赖,一个交互中,各个环节,存在上下文的依赖,需要在交互的某个环节注入依赖信息,这些信息并不透明,易于理解。
 - 触发条件如何限定,一个交互的执行必须满足一些条件,否则会出现多个交互冲突、多个交互环节混乱的情况
 
解决 action 的繁琐问题
我们可以将交互过程中的元素定位为一个对象,实现一些不需要参数的方法,action 中直接执行对象的方法,例如:
class Mask {constructor(contex) {},move() {},show() {},hide() {},resize() {},destroy() {}}
此时在交互语法中可以直接使用, context 上的name 和 方法名进行组合 ‘mask:show’
G2.RegisterInteraction('range-drag-selected', {showEnable: [{trigger: 'view:enter', action: 'cursor-cross'},],closeEnable: [{trigger: 'view:leave', action: 'cursor-default'}],start: [{trigger: 'view:mousedow', action(context) {context.mask = new Mask(context);}}],processing: [{trigger: 'view:mousemove', action: 'mask:resize'},{trigger: 'view:mousemove', action: 'range-selected'}],end: [{trigger: 'view:mouseup'}) // 无 action ,但是会清除 context 上的上下文信息],rollback: [{trigger: 'view:dblclick', action: 'mask:destroy'},{trigger: 'view:dblclick', action: 'clear-selected'}]});
解决上下文依赖问题
上下文之所以出现依赖,很大原因是一些按顺序执行的交互在操作共同的图形元素,这些交互有依赖性或者共同性,这时候我们可以按照上面定义 Mask 时一样来定义 Action 对应的对象,我们可以来定义框选对象:
class RangeSelected extends Action {name = 'range';constructor(context) {}start() {this.startPoint = this.context.view.getState('cursorPoint');}selected() {const context = this.context;const currentPoint = context.view.getState('cursorPoint');const startPoint = this.startPoint; // 不再往 context 上绑定属性const elements = view.findElements(callback);context.view.clearStates('selected'); // 取消选中// 设置 selected 状态elements.forEach(el => {el.setState('selected', true);});}clear() {context.view.clearStates('selected'); // 取消选中}}class Mask extends Action {name = 'mask';constructor(context) {}show() {this.startPoint = this.context.view.getState('cursorPoint');showMask();}hide() {}resize() {const currentPoint = context.view.getState('cursorPoint');const startPoint = this.startPoint;// resizemask}destroy() {}}G2.RegisterAction('mask', Mask);G2.RegisterAction('range', RangeSelected);
触发条件的限定
仅仅靠 elementName:eventName 的组合方式来限定触发,无法解决多个交互之间的冲突,所以我们需要给触发添加限定,限定触发有多个途径:
- 在交互的执行过程中,控制交互环节的触发。在 interaction 的机制中保障,编写 action 和 组合语法时不需要感知
 - 在 Action 每个方法中增加限定条件,不满足,则拒绝执行。可以解决部分问题,主要的目的是避免执行中报错,而无法进行各种触发条件的判定
 - trigger 触发时增加限定条件,可以在交互环节上增加触发的回调函数
 
改进后语法的实现
G2.RegisterInteraction('range-drag-selected', {// actions: [Mask, RangeSelected, Cursor],// 初始化时,可以扫描所有的 action ,自动挂载在上下文上showEnable: [{trigger: 'view:enter', action: 'cursor:cross'},],closeEnable: [{trigger: 'view:leave', action: 'cursor:default'}],start: [// 开始{trigger: 'view:mousedown', isEnable(context) {const event = context.event;if (event.shape) { // 点击到了图形元素,不触发框选return false;}return true;}, 'mask:show;range:start'},],processing: [{trigger: 'view:mousemove', action: 'mask:resize;range:selected'},],end: [{trigger: 'view:mouseup'})],rollback: [{trigger: 'view:dblclick', action: 'range:clear;mask:hide'}]});
显示 tooltip
定义 tooltip 的 Action
class SingleTooltip extends Action {show() {}hide() {}}class SharedTooltip extends Action {show() {}change() {}hide() {}}G2.RegisterAction('single-tooltip', SingleTooltip);G2.RegisterAction('shared-tooltip', SharedTooltip);
单个图形上显示 tooltip
G2.RegisterInteraction('show-single-tooltip', {start: [{trigger: 'element:mouseenter', action: 'single-tooltip:show'},{trigger: 'element:mouseenter', action: 'element-active:start'},],end: [{trigger: 'element:mouseout', action: 'single-tooltip:hide'},{trigger: 'element:mouseout', action: 'element-active:end'},]});
共享显示多个图形的 tooltip
G2.RegisterInteraction('show-shared-tooltip', {start: [{trigger: 'view:enter', action: 'shared-tooltip:show'},],processing: [{trigger: 'view:move', action: 'shared-tooltip:change'},],end: [{trigger: 'view:leave', action: 'shared-tooltip:hide'},]});
chart.addInteraction('show-single-tooltip', {onStart(context) {},onProcessing(context) {},onEnd(context) {}});chart.addInteraction('show-shared-tooltip');
