二维码

图形开发学院(GraphAnywhere.com)

第七章 图形交互操作:视点控制与动画

  视点控制是指在图形系统中,通过调整视点的位置和角度,来改变用户观察图形的范围。本章我们讲述如何在图形初始化时控制图形视点,以及通过API控制图形视点。

1. 视图对象

  在第二章 图形管理类(Graph)中讲述过图形管理对象相关的类包括:Graph类、Layer类、View类、GraphRenderer类,其中Graph类是这几个类的核心,负责构建另外几个类,并提供外部访问api接口;GraphRenderer类是负责图形的渲染过程、Layer类负责图层属性、图层数据和图层的渲染、View类负责图形的视点控制。

  本节我们来讲述视图控制类 View,这几个类的关系如下图所示:

classDiagram

Graph --> GraphRenderer
Graph --> View
Graph *-- Layer

class Graph{
    name:String
    layers:Array
    addLayer(layer)
    removeLayer(layer)
    render()
    setView(view)
    getCoordinateFromPixel(pixel)
    getPixelFromCoordinate(coordinate)
}

class Layer{
    source: Source
    renderer: LayerRenderer
    setStyle(style)
    getVisible()
    visibleAtResolution()
    setOffset(x, y)
}

class GraphRenderer{
    mainCanvas:Canvas
    getSize()
    prepareFrame()
    composeBuffer(frameState)
    renderFrame()
    filter()
}

class View{
    center: [float,float]
    resolutions:float
    zoom: int
    fill()   
    calculateCenterZoom()
    getState()
}

  视图对象主要用于控制图形的显示范围。下图显示的是某个图形的全图,而红色框内的是当前屏幕中显示的内容,称之为当前视点范围或图形显示范围。

运行效果

  在第五章 图形交互操作:平移和缩放中,我们讲述过,实现图形的缩放与平移其核心思路是改变图形的显示范围,而不是改变图形中各图形对象的坐标值。图形显示范围(Extent) 是指图形在屏幕或窗口中的可视区域,是一个由显示的图形中最小坐标值(min)和最大坐标值(max)确定的矩形区域,图形显示范围采用图形坐标来表达,可记录为[minX, minY, maxX, maxY]

  在SVG图形中,当前图形视点范围称之为 ViewBox,在SVG文件中由根节点的 viewBox 属性指定,例如 viewBox='40 20 300 200' ;此外还有一个 ViewPort 属性,指的是当前图形在浏览器中显示的宽度和高度,在SVG文件中由根节点的 widthheight 属性指定。

  在 AnyGraph 中,也存在上述两个概念,图形显示范围 对应的就是 viewBox ,而 Canvas 画布大小对应的就是 viewPort。而 视图对象 除了包含图形显示范围这些属性之外,还提供了 视图约束 方面的属性。

1.1 属性

名称 说明
center 当前的中心点坐标
resolution 当前的分辨率
viewPortSize viewPort大小
resolutions 图形分辨率数组
minResolution 最小分辨率
maxResolution 最大分辨率
extentConstrain 允许显示的最大图形范围

说明:

  • center 和 resolution 这两个属性是View类中最常用的两个属性,表达的是当前图形显示范围的中心点坐标和分辨率;
  • viewPortSize viewPort大小,本质上就是Canvas尺寸;
  • resolutions 分辨率数组,常用于分级缩放的图形系统中,表达的是每一个缩放级别的分辨率;
  • minResolution 最小分辨率,是指在图形缩放时所允许的最小分辨率;
  • maxResolution 最大分辨率,是指在图形缩放时所允许的最大分辨率;
  • extentConstrain 属性用于约束图形移动的范围;

1.2 方法

名称 说明
setCenter(center) 设置中心点坐标
setResolution(resolution) 设置当前分辨率
calculateCenterZoom(resolution, anchor) 根据锚点和分辨率计算中心点
fill(extent, size) 改变视图位置,根据四角坐标和窗口像素宽高
getExtent() 获取图形显示范围
getCenter() 获取中心点坐标
getResolution() 返回当前的分辨率
getZoom() 返还当前的zoom level

说明:

  • setCenter(center) 设置中心点坐标,该坐标将限制在 extentConstrain 范围内;
  • setResolution(resolution) 设置当前分辨率,分辨率也将限制在 minResolution 和 maxResolution的范围内;

2. 图形初始化时控制视点

  AnyGraph 通过 视图对象(View) 控制图形的显示。在初始化 Graph 对象的时候如果指定了 view 属性,则按照 view 属性指定的信息显示图形。如果没有指定 view 属性,则默认按照图形坐标与 Canvas坐标 1:1 的方式显示图形,此外还提供了 fullView 属性,设置为 true 时可在初始化的时候显示全图。

  下面我们通过几个示例来熟悉视图控制方面的功能。

2.1 显示默认图形

  在初始化Graph对象时,如果没有指定任何显示相关的属性,图形初始化之后将按照图形坐标与Canvas坐标1:1的方式(即 resolution=1 )显示图形。如果图形的内容较多,这种方式只会显示图形的一部分。

  下面这段代码在初始化时没有指定任何显示相关的属性,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script type="module">
import { Graph, VectorSource, Layer, debug } from "../../../src/index.js";

// 初始化graph对象
let graph = new Graph({
"target": "graphWrapper",
"layers": [
new Layer({
source: new VectorSource({
"fileUrl": "../../../data/geom.json"
}),
zIndex: 10010,
name: "数据层"
})
]
});

// 显示辅助网格
debug.generateGrid(Object.assign({ "interval": 10, "graph": graph }, graph.getSize()));

// 图形渲染
graph.render();
</script>

  运行效果如下图所示:

运行效果

2.2 显示全图

  下面这个示例,在初始化 Graph 对象时,通过 fullView=true 属性,指定图形初始化之后显示全图,如果图形内容较少,则会放大图形充满整个Canvas画布,如果图形内容较多,则会缩小图形至Canvas画布中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script type="module">
import { Graph, VectorSource, Layer, debug } from "../../../src/index.js";

// 初始化graph对象
let graph = new Graph({
"target": "graphWrapper",
"fullView": true,
"layers": [
new Layer({
source: new VectorSource({
"fileUrl": "../../../data/geom.json"
}),
zIndex: 10010,
name: "数据层"
})
]
});

// 显示辅助网格
debug.generateGrid(Object.assign({ "interval": 10, "graph": graph }, graph.getSize()));

// 图形渲染
graph.render();
</script>

  运行效果如下图所示:

运行效果

2.3 显示指定位置

  下面这个示例,在初始化Graph对象时,设置 view 属性,在该属性中指定了初始视图的中心点坐标 center 和分辨率 resolutionGraph 对象初始完成之后将按照这两个信息显示图形位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script type="module">
import { Graph, View, VectorSource, Layer, debug } from "../../../src/index.js";

// 初始化graph对象
let graph = new Graph({
"target": "graphWrapper",
"fullView": true,
"layers": [
new Layer({
source: new VectorSource({
"fileUrl": "../../../data/geom.json"
}),
zIndex: 10010,
name: "数据层"
})
],
"view": new View({
center: [220, 240], // 初始中心点
resolution: 0.5 // 初始分辨率
})
});

// 显示辅助网格
debug.generateGrid(Object.assign({ "interval": 10, "graph": graph }, graph.getSize()));

// 图形渲染
graph.render();
</script>

  运行效果如下图所示:

运行效果

3. 使用API控制视点

  Graph 类中包含了对 视图对象的实例,可通过 setView()getView() 方法设置和获取 视图对象,同时还提供了以下几个方法通过修改视图对象属性而控制图形的当前视点。

名称 说明
showExtent(extent) 设置图形显示范围,并重绘图形
setView(view) 设置中心点和密度,并重绘图形
getFullExtent() 根据各图层的数据计算当前图形的最大范围

3.1 显示全图

  下面这段代码,可将当前图形的显示范围改变为显示全图。

1
2
let maxExtent = graph.getFullExtent();
graph.showExtent(maxExtent);

3.2 显示指定坐标

  下面这段代码,可将当前图形的显示范围改变为显示指定坐标位置,并按指定分辨率控制图形大小。

1
2
3
4
5
6
let center = [280, 200];
let resolution = 0.25;
if (graph.getView().setResolution(resolution)) {
graph.getView().setCenter(center);
}
graph.render();

3.3 显示指定范围

  下面这段代码,可将当前图形的显示范围改变为显示指定的图形范围。

1
2
let extent = [200, 120, 400, 220];
graph.showExtent(extent);

4. 动画

  在图形系统中,动画是由一系列叫做“帧(frame)”的图形组成的,通过一系列连续的画面来展示物体或场景的运动和变化。它们以一定的帧率(即每秒显示的帧数)进行播放,从而在视觉上呈现出连续的运动效果。

  一帧即对应了一幅图形,在1秒内如果能够重复绘制15帧以上,图形看起来就不卡了,通常所说的60帧是指每秒重绘60次,这已经超过了人眼的极限,可以达到非常流畅的效果。

  在图形系统开发实战课程-第八章动画 中讲述了在浏览器中实现动画功能的几种方法,分别是setInterval()setTimeout()requestAnimationFrame() 。从对动画的精确控制和节省计算机资源等角度,推荐在浏览器中使用requestAnimationFrame() 实现动画。

  图形系统中通常按以下几个步骤实现动画:

  1. 清空Canvas
  2. 绘制当前帧图形
  3. 计算下一帧图形数据
  4. 循环,重复上述过程

  直接使用 requestAnimationFrame() 实现动画时,需要在每一帧绘制完成后再次调用 requestAnimationFrame() 且需要自己编程实现帧率的控制,或是自己编码实现动画持续的时间等功能。AnyGraph 中提供了一个 Animation 工具类,封装了这几个功能。

4.1 Animation 工具类

Animation 工具类提供了以下几个方法:

名称 说明
start(callback, duration, frameRate) 开始动画
stop(animationId) 停止动画
frame(callback) 显示一帧动画

start()

下表为该方法的参数:

名称 说明
callback 回调函数
duration 持续时间
frameRate 帧率

  该方法用于开始一段动画,callback 参数用于指定回调函数,对于图形系统而言,我们在该回调方法中重绘图形。 duration 用于指定动画持续的时间,如果没有指定该参数,则动画一直重复下去,直至通过 stop 方法来停止该动画。 frameRate 用于指定帧率,即每秒执行的次数,对于某些不需要在1秒内频繁刷新的动画(例如时钟),我们可通过该参数来降低系统运行资源。

  由于采用了 window.requestAnimationFrame(frame) 动画技术,该方式为根据屏幕刷新率控制帧率,因此屏幕刷新率即为最大帧率。

stop()

  在执行 star() 方法开始一段动画时,star() 方法会返回一个整数类型的 “动画ID”,调用stop()方法时通过该 “动画ID” 结束该动画。

Animation源代码

  以下为Animation的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* 动画工具类
*/
const Animation = (function (window) {
// 缺省帧率60帧/秒
let TIME = Math.floor(1000 / 60);
let stop, frame;
let frames = {};
let lastFrameTime = 0;
let counter = 0;
let loops = {};

if (typeof window.requestAnimationFrame === 'function' && typeof window.cancelAnimationFrame === 'function') {
frame = function (callback) {
let id = Math.random();
frames[id] = requestAnimationFrame(function onFrame(time) {
if (lastFrameTime === time || lastFrameTime + TIME - 1 < time) {
lastFrameTime = time;
delete frames[id];
callback();
} else {
frames[id] = requestAnimationFrame(onFrame);
}
});
return id;
};
stop = function (id) {
if (frames[id]) {
cancelAnimationFrame(frames[id]);
}
delete loops[id];
};
} else {
frame = function (callback) {
return setTimeout(callback, TIME);
};
stop = function (timer) {
delete loops[timer];
return clearTimeout(timer);
};
}

/**
* 开始动画
* @param {*} callback 绘制帧函数
* @param {*} duration 持续时间(动画执行时长(秒))
* @param {*} frameRate 帧率(每秒执行多少次)
* @returns
*/
function start(callback, duration=0, frameRate=0) {
duration = duration > 0 ? duration : Number.POSITIVE_INFINITY;
if(frameRate > 0) {
TIME = Math.floor(1000 / frameRate);
}
let id = ++counter;
let start = Date.now();
loops[id] = function () {
if (loops[id] && Date.now() - start <= duration) {
callback();
if (loops[id]) {
frame(loops[id]);
}
} else {
delete loops[id];
}
};
frame(loops[id]);
return id;
}

return {
frame, // 执行一次 callback
start, // 开始循环执行 callback
stop // 停止动画
};
}(window));

4.2 缓动功能

  现实生活中,物体并不是突然启动或者停止, 当然也不可能一直保持匀速移动。就像我们 打开抽屉的过程那样,刚开始拉的那一下动作很快, 但是当抽屉被拉出来之后我们会不自觉的放慢动作。 或是掉落在地板上的物体,一开始下降的速度很快, 接着就会在地板上来回反弹直到停止。

  缓动动画,指带有一定缓冲的动画,物体在一定时间内渐进加速或者减速,从而使动画更加的真实和自然。我们先感受一下使用缓动动画实现小球平移功能的示例。

运行效果

  在这个示例中,easeIn的小球刚开始启动很缓慢,然后逐渐加速,最终与其他小球同时到达终点;而 linear 行的小球则始终保持匀速移动。easeOut小球刚开始启动很快,然后逐渐减速,也是同时到达终点。

  上述小球运动的缓动动画包括以下几个要点:

  1. 确定起点位置和终点位置,由于小球做的是水平运动,因此小球在运动前的X坐标均为50,结束时的坐标是800,Y坐标保持不变;
  2. 确定运动的时间或次数;在该示例中,小球在两秒内移动了10次到达了终点;
  3. 通过缓动函数计算每一次移动的距离;
  4. 绘制帧图形;

  下面代码为一个水平运动的小球,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<script type="module">
import { Graph, Circle, debug, Animation, Easing } from "../../../src/index.js";

// 初始化graph对象
let graph = new Graph({
"target": "graphWrapper"
});

// 增加数据层
let layer = graph.addLayer({ "name": "数据层" });
let ball = layer.getSource().add(
new Circle({
"x": 50,
"y": 100,
"radius": 20,
"style": { "color": "none", "fillStyle": 1, "fillColor": "#FF0000" }
})
)

// 动画相关变量
let minX = 50, maxX = 800;
let totalTimes = 10;
let times = 0;
let rafId = -1;

// 绘制帧图形
function frame() {
// 计算小球移动的距离
let dx = (maxX - minX) * Easing.easeIn(times / totalTimes);
ball.moveTo(minX + dx, 100);
// 图形渲染
graph.render();
// 动画停止条件
if (times < totalTimes) {
times++;
} else {
times = 0;
Animation.stop(rafId);
}
}
$("#btnStart").on("click", function () {
times = 0;
rafId = Animation.start(frame, 0, 5);
});
$("#btnStop").on("click", function () {
Animation.stop(rafId);
})
</script>

  AnyGraph 将缓动函数封装到了工具类 Easing 中,Easing.easeIn()为缓入动画函数,该函数开始启动很缓慢然后逐渐加速,将该函数换成其他缓动函数就可以实现不同的动画效果。

  缓动函数的实现比较多,有关这部分的内容请参见:缓动函数速查表。下面介绍几个最常见的缓动效果。

缓慢加速 easeIn

运行效果

1
2
3
4
5
6
7
8
/**
* 启动缓慢,后期加速快(加速曲线)
* @param {number} t 输入参数(0至1之间). (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
* @return {number} 返回参数(0至1之间). (0.001, 0.008, 0.026, 0.064, 0.125, 0.215, 0.343, 0.512, 0.729, 1.0)
*/
function easeIn(t) {
return Math.pow(t, 3);
}

缓慢减速 easeOut

运行效果

1
2
3
4
5
6
7
8
/**
* 启动加速快,结束缓慢(减速曲线)
* @param {number} t 输入参数(0至1之间). (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
* @return {number} 返回参数(0至1之间). (0.270, 0.488, 0.657, 0.784, 0.875, 0.936, 0.973, 0.992, 0.999, 1.0)
*/
function easeOut(t) {
return 1 - easeIn(1 - t);
}

匀速 linear

运行效果

1
2
3
4
5
6
7
8
/**
* 随着时间的推移保持恒定的速度(匀速)
* @param {number} t 输入参数(0至1之间). (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
* @return {number} 返回参数(0至1之间). (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
*/
function linear(t) {
return t;
}

#### 先缓慢加速后缓慢减速 inAndOut

运行效果

1
2
3
4
5
6
7
8
/**
* 先缓慢加速后缓慢减速(加速减速曲线)
* @param {number} t 输入参数(0至1之间). (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
* @return {number} 返回参数(0至1之间). (0.028, 0.104, 0.215, 0.352, 0.5, 0.648, 0.784, 0.896, 0.972, 1.0)
*/
function inAndOut(t) {
return 3 * t * t - 2 * t * t * t;
}

来回运动 upAndDown

运行效果

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 来回运动
* @param {number} t 输入参数(0至1之间). (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0)
* @return {number} 返回参数(0至1之间). (0.104, 0.352, 0.648, 0.896, 1.0, 0.896, 0.648, 0.352, 0.104, 0.0)
*/
function upAndDown(t) {
if (t < 0.5) {
return inAndOut(2 * t);
} else {
return 1 - inAndOut(2 * (t - 0.5));
}
}

## 5. 视图动画

  在本章第三节中讲述了通过访问API控制视点,Graph类还提供了两个api用于进行视点控制,这两个方法在执行的时候应用了缓动的动画效果,使得在进行图形缩放和平移时可以产生更好的视觉效果。

名称 说明
animailMove(center, resolution, duration) 具有动画效果的图形移动
animailZoom(scale, anchor, duration) 具有动画效果的图形缩放

5.1 平移动画

  在 animailMove() 方法中,通过使用 Animation 类和 Easing.easeOut() 减速缓动功能,实现了动画效果的图形移动,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 具有动画效果的图形移动
* @param {Coord} center 中心点坐标
* @param {Number} resolution 新的分辨率,如果为空则不改变分辨率
* @param {int} duration 延时时间
*/
animailMove(center, resolution, duration = 500) {
let start = Date.now();
let that = this;
let originalCenter = this.getView().getCenter();
let originalRes = this.getView().getResolution();

// 开始动画
Animation.start(function () {
let drawTime = Date.now() - 1;
let delta = Easing.easeOut((drawTime - start) / duration);
let centerX = originalCenter[0] + delta * (center[0] - originalCenter[0]);
let centerY = originalCenter[1] + delta * (center[1] - originalCenter[1]);
that.getView().setCenter([centerX, centerY]);
if (resolution != null && resolution > 0) {
let res = originalRes + delta * (resolution - originalRes);
that.getView().setResolution(res);
}
that.renderSync();
}, duration);
}

  在这个方法中,我们先记录当前的中心点坐标和分辨率,根据缓动方法计算当前中心点和目标点坐标之间的线性差值,计算当前分辨率和目标分辨率之间的线性差值,逐帧进行图形重绘,从而实现移动的动画效果。缺省动画时间为500毫秒,如果按照每秒60帧计算,这个过程共绘制了30次图形。

  其调用示例如下:

1
2
3
let center = [280, 200];
let resolution = 0.25;
graph.animailMove(center, resolution, 600);

  运行效果如下:

运行效果

5.2 缩放动画

  在 animailZoom() 方法中,通过使用 Animation 类和 Easing.easeOut() 减速缓动功能,实现了动画效果的图形缩放,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 具有动画效果的图形缩放
* @param {Number} scale 缩放倍率
* @param {Coord} anchor 锚点坐标
*/
animailZoom(scale = 1.5, anchor, duration = 500) {
let originalRes = this.getView().getResolution();
let targetRes = this.getView().getResolution() * scale;
let start = Date.now();
let that = this;
// 缺省锚点为中心点
if(anchor == null) {
anchor = Extent.getCenter(this.getExtent());
}
// 开始动画
Animation.start(function () {
let drawTime = Date.now() - 1;
let delta = Easing.easeOut((drawTime - start) / duration);
let res = originalRes + delta * (targetRes - originalRes);
let center = that.getView().calculateCenterZoom(res, anchor);
that.getView().setCenter(center);
that.getView().setResolution(res);
that.renderSync();
}, duration);
}

  在这个方法中,我们先获取当前分辨率和计算目标分辨率,根据缓动方法计算当前分辨率和目标分辨率之间的线性差值,逐帧进行图形重绘,从而实现图形缩放的动画效果。缺省动画时间为500毫秒,如果按照每秒60帧计算,这个过程共绘制了30次图形。

  其调用示例如下:

1
2
3
let scale = 1.5;  // 放大
// let scale = 0.67; // 缩小
graph.animailZoom(scale);

运行效果


  “图形系统实战开发-进阶篇 第七章 图形交互操作:视点控制与动画” 的内容讲解到这里就结束了,如果觉得对你有帮助有收获,可以关注我们的官方账号,持续关注更多精彩内容。


本文为“图形开发学院”(graphanywhere.com)网站原创文章,遵循CC BY-NC-ND 4.0版权协议,商业转载请联系作者获得授权,非商业转载请附上原文出处链接及本声明。