简介
在前面的章节里面大概介绍了新设计的思路,在这里展开进行讨论两种交互写法:
- 触发和反馈写到一起
 - 触发和反馈分开写
 
实现
都写到一起
active
active 的场景非常简单,所以写到一起更加合适
G2.registerInteraction('active', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('element:mouseenter', Util.bind(this, 'onEnter'));chart.on('element:mouseenter', Util.bind(this, 'onEnter'));},onEnter(ev) {const element = ev.element;element.setState('active', true);},onOut(ev) {const element = ev.element;element.setState('active', false);},destroy() {this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));}});
- 写到一起时,不需要全局的 stateManager 管理状态量,直接调用 element 的方法即可
 - 触发形式只有一种的情况下,这种方案非常合适也很好理解
highlight
highlight 比较复杂一些,需要高亮对应的节点,也需要将其他节点变暗,实现代码如下:G2.registerInteraction('highlight', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('element:mouseenter', Util.bind(this, 'onEnter'));chart.on('element:mouseleave', Util.bind(this, 'onOut'));},onEnter(ev) {const element = ev.element;element.setState('active', true);const allElements = this.chart.getAllElements();allElements.forEach(el => {if (el !== element) {el.setState('dark', true);}});},onOut(ev) {const element = ev.element;element.setState('active', false);const allElements = this.chart.getAllElements();allElements.forEach(el => {if (el !== element) {el.setState('dark', false);}});},destroy() {this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('element:mouseleave', Util.bind(this, 'onOut'));}});
 
selected
selected 同 active 类似,但是需要考虑单选、多选、取消选中的场景:
G2.registerInteraction('selected', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('element:click', Util.bind(this, 'onClick'));},onClick(ev) {const element = ev.element;// 清理状态if (element.hasState('selected')) {element.setState('selected', false);} else {// 清理原先选中的元素const elements = chart.getAllElements();elements.forEach(el => {if (el.hasState('selected')) {el.setState('selected', false);}});element.setState('selected', true);}},destroy() {this.chart.off('element:click', Util.bind(this, 'onClick'));}});
tooltip
G2 中的 tooltip 主要有两中方式:
- 根据 x 轴的位置,批量显示数据
 - 鼠标移动到的元素上显示数据
 
首先看一下批量显示数据
G2.registerInteraction('shared-tooltip', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('mousemove', Util.bind(this, 'onMove'));},onMove(ev) {const elements = chart.getSnapElements({x: ev.x, y: ev.y}); // 虚拟的方法,获取逼近的元素const items = [];elements.forEach(el => {const model = el.getModel(); // 获取 element 的数据const originData = model.origin;items.push({title: originData.x,name: 'xxx',value: 'xxx'});// element.setState('active', true);// 或者 element.getTooltipItems();// 也可以作为 interaction 的回调函数 this.itemFormatter(record,index);});// 这个接口怎么设计// tooltip 是否要chart.showTooltip({x: ev.x,y: ev.y,items: items});},destroy() {this.chart.off('mousemove', Util.bind(this, 'onMove'));}});// 外面使用 tooltip 时:chart.interaction('shared-tooltip', {itemFormatter(record, index) {return { name: 'xxx', value: 'xxx' }; // 这里面需要思考的是如何拿到这一项的 color}});
直接显示对应图形的 tooltip 也类似,这里面有一些问题需要解决:
- tooltip 信息在不同的图表上有不同的显示,可能要针对不同的图表分别写 interaction,line-tooltip,pie-tooltip 等,是否需要一个通用的 tooltip
 - tooltip 信息如何让用户自定义,之前是通过注册到 geometry 上的,而现在则是用户在图表的外层实现
 - tooltip 显示时同时需要高亮 element,而 shared-tooltip 的场景下默认的 active 无法高亮默认的多个 element。可以禁用 active 的 interaction,而在 tooltip 的 interaction 中进行 active 操作,但是这就导致一个交互做了多件事,同一个反馈在多个交互里面都需要实现,难免会出现冲突。
legend
我们这里考虑三个 legend 上的交互, 
一个是在legend 上过滤数据,
- 第二个是在 legend hover 时高亮对应的图形
 - 第三个是图形 active 时高亮对应的 legend 选项
 
- 数据过滤:
G2.registerInteraction('legend-category-filter', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('legend:chenge', Util.bind(this, 'onClick'));},onClick(ev) {const legendItem = ev.item;const legend = ev.legend;const field = legendItem.field;const value = legendItem.value;const checked = legendItem.checked;const elements = chart.getAllElements();elements.forEach(el => {const record = el.getModel().origin; // 还没设计好如何定义if (record[field] === value) {if (checked) {element.show();} else {element.hide();}}});},destroy() {this.chart.off('legend:chenge', Util.bind(this, 'onClick'));}});
 
- 图例需要自己实现勾选功能,然后通过 change 事件同 interaction 中交互,并没有解开 legend 和 chart 的耦合,后面会提供解开耦合的版本,但是实现上更加复杂。
 - 如果图例要实现单选功能,也可以通过设置element 的 show,hide
 - 这种方式存在的很大一个问题:无法同其他的过滤方式整合,例如默认情况下已经有几个类别被过滤,过滤数据不仅仅是图形隐藏,还会牵扯到数据 scale 范围的变化。
 
- legend 的 hover 时高亮
G2.registerInteraction('legend-category-active', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('legend-item:mouseenter', Util.bind(this, 'onEnter'));chart.on('legend-item:mouseout', Util.bind(this, 'onOut'));},onEnter(ev) {const legendItem = ev.item;this.setActive(legendItem, true);},onOut(ev) {const legendItem = ev.item;this.setActive(legendItem, false);},setActive(legendItem, active) {const chart = this.chart;const field = legendItem.field;const value = legendItem.value;const checked = legendItem.checked;if (checked) { // 只有 checked 的才会 activeconst elements = chart.getAllElements();elements.forEach(el => {const record = el.getModel().origin; // 还没设计好如何定义if (record[field] === value) {element.setState('active', active);}});}},destroy() {this.chart.off('legend-item:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('legend-item:mouseout', Util.bind(this, 'onOut'));}});
 
- legend item 上的 hover 效果需要 legend 组件自己维护,也需要自己监听 mouseenter,mouseleave 时间,同时在 chart 上 emit 出来。
 - element 的 active 再次出现,多个 interaction 都来操作 element 的 active 时肯定会发生冲突
 
- 图形 active 时高亮对应的 legend 选项
G2.registerInteraction('active-legend', {init(chart) {this.chart = chart;this.initEvents(chart)},initEvents(chart) {chart.on('element:mouseenter', Util.bind(this, 'onEnter'));chart.on('element:mouseleave', Util.bind(this, 'onOut'));},onEnter(ev) {const element = ev.element;element.setState('active', true);this.setLegendActive(element, true);},onOut(ev) {const element = ev.element;element.setState('active', false);this.setLegendActive(element, false);},setLegendActive(element, active) {const chart = this.chart;const record = element.getModel().origin;const legends = chart.getLegends(); // 有多个图例legends.forEach(legend => {// 也应该判定 legend 的类型是否是分类,如果是连续,需要另一种实现const field = legend.get('field'); // 这里怎么存储 field 可以再设计一下const items = legend.getItems();items.forEach(item => {const value = item.value;if (record[field] === value) {item.active = true;legend.updateItem(item); // 更新图例项}});});},destroy() {this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('element:mouseleave', Util.bind(this, 'onOut'));}});
 
- element 的 active 设置再次出现,而且都是 mouseeneter 和 mouseleave 导致的
 legend 的事件最好在 chart 上使用委托的机制绑定,图表的生命周期内会创建和销毁图例
小结
把事件的触发和图表的响应都写到一起的方案,通过上面的几个示例可以看到其优点:
实现简单,仅需要处理绑定事件、响应事件即可
- 易于理解
 
但是同时我们也发现了这种方案的弊端:
- active、selected 等常见的反馈到处需要设置,逻辑重复,可能会出现冲突的的情况
 - 不同 interaction 之间不知道彼此做的事情,很容易出现同时操作单个图形、组件的情况,例如:给折线图写了一个 tooltip 的交互,也给柱状图写了一个,同时触发交互时,仅有一个 tooltip 这时候后出现者会覆盖前面的交互。
 
这个方案问题的本质其实是复用和耦合的冲突,要解决这个问题需要:
- 相同的行为只有一个入口
 - 可以监听其他交互的行为
 
触发和反馈分开
这种方案的思路是一些反馈不仅仅在一个场景用得到,例如:
- 图表元素的 active 有多种情况会发生,鼠标移动到一个图形上,显示 tooltip 时,hover 到 legend 项上时。
 - 图表元素的 selected 可能会是用户点击导致,也可能通过程序设置。
 - 过滤在多种情况下发生,而触发过滤和响应过滤本质上时一体的,例如点击图例项会导致图表发生过滤,而其他情况引起过滤后,图例项也需要做出响应。
 
将这些通用的交互单独实现,其他交互中可以调用的方案有多种,但是一种解耦最好的方案是使用一个通用的状态管理控制器: StateManager
class StateManager extends EventEmiter {setState(name, value) { // 设置状态,同时触发 'name' + change 的事件const originValue = this.states[name];this.states[name] = value;// 这个地方其实可以做 orginValue 和 value 是否相等的判定// 可能两者是复杂的对象,判定比较复杂this.emit(name + 'change', { name: name, value: value, originValue: originValue));},getState(name) {return this.states[name];}}
通用的交互 active, selected 等不再自己触发,而是监听状态量的变化,进行相应。
active
G2.registerInteraction('element-active', {init(chart) {this.stateManager = chart.getStateManager();this.initEvents()},initEvents(chart) {const stateManager = this.stateManager;stateManager.on('activeElementschange', Util.bind(this, 'onChange'));},onChange(ev) {const value = ev.value;const activedElements = originValue;// 如果存在之前激活的元素activedElements && activedElements.forEach(el => {el.setState('active', false);});const elements = value.elements;// 如果 elements = null 这时候也会清理掉原先 active 的元素,会实现取消所有 active 的效果// 这是一种互斥的方案,在很多编程中会变得更加简单elements && elements.forEach(el => {el.setState('active', true);});}destroy() {this.stateManager.off('activeElements', Util.bind(this, 'onChange'));}});// 外面需要调用stateManager.setState('activeElements', [element1, element2]);// 清理所有的 activestateManager.setState('activeElements', null);
如果这时候我们想实现 hover 的 active 效果,则需要定义一个新的 interaction
G2.registerInteraction('active', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();},initEvents(chart) {chart.on('element:mouseenter', Util.bind(this, 'onEnter'));chart.on('element:mouseenter', Util.bind(this, 'onEnter'));},onEnter(ev) {const element = ev.element;this.stateManager.setState('activeElments', [element]);},onOut(ev) {this.stateManager.setState('activeElements', null);},destroy() {this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));}});
- 跟原先的代码相比变化不大,但是增加了一个 stateManager 和 状态量的概念,易理解带来困难
 - 也可以在业务代码中监听 activeElementschange ,显示被激活元素的信息
 
highlight
highlight 由于还牵扯到另一种元素的状态 dark ,可以先定义 dark 的交互,其他交互也可以复用
G2.registerInteraction('element-dark', {init(chart) {this.stateManager = chart.getStateManager();this.initEvents()},initEvents(chart) {const stateManager = this.stateManager;stateManager.on('darkElementschange', Util.bind(this, 'onChange'));},onChange(ev) {const value = ev.value;const activedElements = originValue;// 如果存在之前变暗的元素activedElements && activedElements.forEach(el => {el.setState('dark', false);});const elements = value.elements;// 如果 elements = null 这时候也会清理掉原先 dark 的元素,会实现取消所有 dark 的效果// 这是一种互斥的方案,在很多编程中会变得更加简单elements && elements.forEach(el => {el.setState('dark', true);});}destroy() {this.stateManager.off('darkElementschange', Util.bind(this, 'onChange'));}});// 外面需要调用stateManager.setState('darkElements', [element1, element2]);// 清理所有的 activestateManager.setState('darkElements', null);
- 我们会发现 active, dark 的状态完全一样,可以通过一套代码来实现
```javascript
G2.createElementInteraction = function(stateName) {
  G2.G2.registerInteraction(‘element-‘ + stateName, { 
}); }....
 
G2.createElementInteraction(‘active’); G2.createElementInteraction(‘dark’);
// and so on
```javascriptG2.registerInteraction('highlight', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();this.initEvents(chart)},initEvents(chart) {chart.on('element:mouseenter', Util.bind(this, 'onEnter'));chart.on('element:mouseleave', Util.bind(this, 'onOut'));},onEnter(ev) {const element = ev.element;this.stateManager.setState('activeElements', element);const allElements = this.chart.getAllElements();const darkElments = [];allElements.forEach(el => {if (el !== element) {darkElments.push(el);}});this.stateManager.setState('darkElements', darkElments);},onOut(ev) {this.stateManager.setState('darkElements', null);this.stateManager.setState('activeElements', null);},destroy() {this.chart.off('element:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('element:mouseleave', Util.bind(this, 'onOut'));}});
selected
在前面我们已经定义了 active, dark 的元素状态,这里仅需要定义 selected 状态即可
G2.createElementInteraction('selected'); // 定义新的元素状态量
监听 click 时间,选中元素
G2.registerInteraction('click-selected', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();this.initEvents(chart)},initEvents(chart) {chart.on('element:click', Util.bind(this, 'onClick'));},onClick(ev) {const element = ev.element;stateManager = this.stateManager;// 仅考虑单选,如果已经选中,则清理状态// 如果支持多选,这里复杂一些if (element.hasState('selected')) {stateManager.setState('selectedElements', null);} else {// 清理原先选中的元素stateManager.setState('selectedElements', [element]);}},destroy() {this.chart.off('element:click', Util.bind(this, 'onClick'));}});
由于清理逻辑已经在之前的 interaction 中支持,这里的代码会精简很多
tooltip
我们可以将显示 tooltip 这个行为也实现成一个交互,批量调用个单个调用都可以复用这个交互
G2.registerInteraction('show-tooltip', {init(chart) {this.stateManager = chart.getStateManager();this.initEvents()},initEvents(chart) {const stateManager = this.stateManager;stateManager.on('tooltipElementschange', Util.bind(this, 'onChange'));},onChange(ev) {const elements = ev.value;const items = [];elements.forEach(el => {const model = el.getModel(); // 获取 element 的数据const originData = model.origin;items.push({title: originData.x,name: 'xxx',value: 'xxx'});});// 这个接口怎么设计// tooltip 是否要this.chart.showTooltip({x: ev.x,y: ev.y,items: items});}destroy() {this.stateManager.off('tooltipElementschange', Util.bind(this, 'onChange'));}});
这里仅仅显示 tooltip 的信息,而不要进行 active 设置
同时显示多个元素的信息
G2.registerInteraction('shared-tooltip', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();this.initEvents(chart)},initEvents(chart) {chart.on('mousemove', Util.bind(this, 'onMove'));// 移出画布是 tooltip 消失的逻辑也要加},onMove(ev) {const elements = chart.getSnapElements({x: ev.x, y: ev.y}); // 虚拟的方法,获取逼近的元素this.stateManager.setState('tooltipElements', elements);this.stateManager.setState('activeElements', elements); // 同时 active 元素},destroy() {this.chart.off('mousemove', Util.bind(this, 'onMove'));}});
移动到单个图形上显示 tooltip
G2.registerInteraction('-tooltip', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();this.initEvents(chart)},initEvents(chart) {chart.on('mouseenter', Util.bind(this, 'onEnter'));chart.on('mouseleave', Util.bind(this, 'onOut'));},onEnter(ev) {const element = ev.element;this.stateManager.setState('tooltipElements', [element]);this.stateManager.setState('activeElements', [element]); // 同时 active 元素},onOut(ev) {this.stateManager.setState('tooltipElements', null);this.stateManager.setState('activeElements', null);},destroy() {this.chart.off('mousemove', Util.bind(this, 'onMove'));this.chart.off('mouseleave', Util.bind(this, 'onOut'));}});
legend
我们在这里同样实现常见的三种交互:
- 一个是在legend 上过滤数据,
 - 第二个是在 legend hover 时高亮对应的图形
 - 第三个是图形 active 时高亮对应的 legend 选项
 
- 数据过滤:
 
首先我们可以定义 filter-element 的交互和定义 filterElements 的状态量
G2.createElementInteraction('filtered');
通过 legend 过滤图形
G2.registerInteraction('legend-category-filter', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();this.initEvents(chart)},initEvents(chart) {chart.on('legend:chenge', Util.bind(this, 'onClick'));},onClick(ev) {const legend = ev.legend;const field = legend.field;const items = legend.getItems();const elements = chart.getAllElements();const filetedElements = [];elements.forEach(el => {const record = el.getModel().origin; // 还没设计好如何定义items.forEach(item => {if (!item.checked && element[field] == item.value){filetedElements.push(element);}});});this.stateManager.setState('filetedElements', filetedElements);},destroy() {this.chart.off('legend:chenge', Util.bind(this, 'onClick'));}});
- 这种写法性能会变差,因为需要遍历所有 Legend 的选项
 
- legend 的 hover 时高亮
G2.registerInteraction('legend-category-active', {init(chart) {this.chart = chart;this.stateManager = chart.getStateManager();this.initEvents(chart)},initEvents(chart) {chart.on('legend-item:mouseenter', Util.bind(this, 'onEnter'));chart.on('legend-item:mouseout', Util.bind(this, 'onOut'));},onEnter(ev) {const legendItem = ev.item;this.setActive(legendItem, true);},onOut(ev) {const legendItem = ev.item;this.setActive(legendItem, false);},setActive(legendItem, active) {const chart = this.chart;const field = legendItem.field;const value = legendItem.value;const checked = legendItem.checked;const activeElements = [];if (checked) { // 只有 checked 的才会 activeconst elements = chart.getAllElements();elements.forEach(el => {const record = el.getModel().origin; // 还没设计好如何定义if (record[field] === value) {activeElements.push(el);}});}this.stateManager.setState('activeElements', activeElements);},destroy() {this.chart.off('legend-item:mouseenter', Util.bind(this, 'onEnter'));this.chart.off('legend-item:mouseout', Util.bind(this, 'onOut'));}});
 
- 这个实现看上去同第一种写法没什么差别,仅仅是屏蔽了如何进行 active
 
- hover 图表的图形,legend 高亮
 
小结
将触发和反馈分离可以得到一些好处:
- 常用的反馈可以集中编写,提升代码复用率,反馈的实现方式对上层屏蔽,便于后面修改
 - 可以在业务代码中监听反馈,而不管是如何触发的
 - 也可以同一个反馈使用不同的交互实现,仅需要按需加载即可
 
同时也带了一些问题:
- 用户需要更多的知识,需要理解每个状态量的定义(可以是复杂对象)
 - 有些交互编写起来更复杂一些
 
总结
本章把 G2 现在支持的一些交互,按照两种方式分别实现出来,进行了一些对比,但这两种设计仅仅将图表绘制和交互的耦合解开,并不能称得上是图形语法,真正的图形语法会在后面的章节中详细的讲解,让用户定制交互更加轻松简单。
