简介
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');