使用Echarts实现折线图中线条添加、删除、编辑

介绍一下使用Echarts做数据统计分析,如何实现支持折线图的添加、删除、编辑更新至数据库,其中后端使用spring+mybatis+mysql,前端使用bootstrap布局配合bootstrap-datepicker、bootstrap-tags、bootstrap-dialog和echarts插件。

设计

首先看下截图有个直观的对各要素的了解。然后具体展开各个元素。

chart

整个页面采用bootstrap布局,具体样式就不赘述了。显示的为某一天24小时内每分钟的订单量。

然后可以看到导航栏下方右侧是echarts图表区,用于展示图表,右上角是图表工具栏,依次为数据视图、还原、开启区域缩放/关闭、保存图片、柱状/折线图切换。

左侧自上至下依次是一个日期选择组件、3个功能按钮、标签组件(用于列出、删除某一天的数据,便于操作)。

3个功能按钮分别为添加某一天(日期选择框)的数据、清空图表展示内容(不删除后台数据,只清空图表)、在数据视图编辑数据后更新当前数据至后台数据库。

实现

数据表

首先简单介绍下数据表结构:

1
2
3
4
5
6
7
CREATE TABLE `flight_minute` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`datetime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '时间',
`order_num` smallint(5) unsigned NOT NULL DEFAULT '0' COMMENT '订单量',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_datetime` (`datetime`)
) ENGINE=InnoDB AUTO_INCREMENT=30241 DEFAULT CHARSET=utf8mb4 COMMENT='机票每分钟订单量';

数据示例:

1
2
3
4
5
6
7
'1', '2017-06-01 00:00:00', '129'
'2', '2017-06-01 00:01:00', '135'
'3', '2017-06-01 00:02:00', '170'
'4', '2017-06-01 00:03:00', '149'
'5', '2017-06-01 00:04:00', '163'
'6', '2017-06-01 00:05:00', '163'
'7', '2017-06-01 00:06:00', '170'

后端

后端具体设计不赘述,重点说下后端的接口,以及批量更新操作。

接口

1
2
/flight_minute_orders/date/{date}            //获取某一天的数据
/flight_minute_orders/update //批量更新数据

批量更新

controller接口定义:可以看到接收参数为json格式的对象数组,其中FlightMinuteModel为数据库持久层对象。

1
2
3
4
5
6
@RequestMapping(value = "/update", method = { RequestMethod.POST })
@JsonBody
public int batchUpdate(@RequestBody List<FlightMinuteModel> list){
Preconditions.checkNotNull(list);
return flightMinuteService.batchUpdate(list);
}

mybatis中XXXMapper.xml中的相关配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
<update id="batchUpdate"  parameterType="java.util.List">

<foreach collection="list" item="item" index="index" open="" close="" separator=";">
UPDATE
flight_minute
<set>
order_num = #{item.orderNum}
</set>
WHERE
datetime = #{item.datetime,jdbcType=TIMESTAMP}
</foreach>

</update>

前端

html页面

首先看下页面html代码:没啥好说的,依次为导航栏、日期组件、按钮组、标签组件以及echarts容器(id=main的div)。

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>订单统计</title>
<link rel="stylesheet" href="http://common.qunarzz.com/lib/prd/bootstrap/3.3.7/css/bootstrap.css">
<link rel="stylesheet" href="//cdn.bootcss.com/bootstrap3-dialog/1.35.4/css/bootstrap-dialog.min.css">
<link rel="stylesheet" href="//cdn.bootcss.com/bootstrap-datepicker/1.7.0/css/bootstrap-datepicker3.min.css">
<link rel="stylesheet" href="/static/css/atp.css">
<link rel="stylesheet" href="/static/css/bootstrap-tags.css">
</head>
<body>

<nav class="navbar navbar-default navbar-fixed-top" role="navigation">
<div class="container-fluid">
<div class="navbar-header navbar-left">
<span class="navbar-brand glyphicon glyphicon-stats"></span>
<span class="navbar-brand">Orders Statistics</span>
</div>
</div>
</nav>

<div id="container" class="container-fluid">
<div id="row" class="row">
<div class="col-xs-2">
<div class="form-group">
<label>选择日期:</label>
<div class="input-icon-group">
<div class='input-group date' id='datetimepicker' data-date="2017-06-01" data-date-format="yyyy-mm-dd">
<input id="dateInput" type='text' class="form-control" readonly/>
<span class="input-group-addon glyphicon glyphicon-calendar"></span>
</div>
</div>
<div class="btn-group" style="width: 100%">
<button id="addLine" style="width: 33%" class="btn btn-primary" type="button">添加</button>
<button id="clearCharts" style="width: 33%" class="btn btn-warning" type="button">清空</button>
<button id="updateCharts" style="width: 34%" class="btn btn-danger" type="button">更新</button>
</div>
</div>
<div class="form-group">
<div id="my-tag-list" class="tag-list"></div>
</div>
</div>
<div class="col-xs-9">
<div id="main"></div>
</div>
</div>
</div>

<script src="http://common.qunarzz.com/lib/prd/jquery/3.1.1/jquery.js"></script>
<script src="http://common.qunarzz.com/lib/prd/bootstrap/3.3.7/js/bootstrap.js"></script>
<script src="http://common.qunarzz.com/lib/prd/echarts/3.5.4/echarts.common.min.js"></script>
<script src="//cdn.bootcss.com/bootstrap3-dialog/1.35.4/js/bootstrap-dialog.min.js"></script>
<script src="//cdn.bootcss.com/bootstrap-datepicker/1.7.0/js/bootstrap-datepicker.min.js"></script>
<script src="//cdn.bootcss.com/bootstrap-datepicker/1.7.0/locales/bootstrap-datepicker.zh-CN.min.js"></script>
<script src="/static/js/bootstrap-tags.js"></script>
<script src="/static/js/atp.js"></script>
</body>
</html>

接下来是重点!js文件中的内容。

创建echarts实例

首先echarts容器中创建echarts实例:

不得不提echarts很蛋疼的一点,就是其容器必须指定宽高,而为了适应不同大小的屏幕,这里采用了先获得浏览器窗口(window)的宽高,再通过减去一定像素得到echarts容器大小。如下:

1
2
3
4
5
6
7
8
9
10
11
//获得容器
var container = document.getElementById('main');

//容器大小初始化函数,使chart容器自适应window大小
function resizeContainer() {
container.style.width = (window.innerWidth - 300) + 'px';
container.style.height = (window.innerHeight - 100) + 'px';
}

//创建echarts实例
echart = echarts.init(container);

之后为了适应页面大小变化时,容器能自适应调整大小:

1
2
3
4
5
6
7
8
9
//window大小变化时,chart自动调整
function resize() {
//用于使chart自适应高度和宽度
window.onresize = function () {
//重置容器高宽
resizeContainer(container);
echart.resize();
};
}

创建日期选择器

1
2
3
4
5
6
7
8
9
function buildDatepicker() {
$("#datetimepicker").datepicker({
format: "yyyy-mm-dd",
startDate: '2017-06-01',
endDate: '2017-06-21',
autoclose: true,
language: 'zh-CN'
});
}

构建横轴24小时每分钟数据

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
//获取24小时内每隔一分钟的时间数组(默认)
function getDateArray(endDate, splitTime, count) {
if (!endDate) {
endDate = new Date('2000-01-01 23:59:00');
}
if (!splitTime) {
splitTime = 60 * 1000;
}
if (!count) {
count = 1440;
}
var endTime = endDate.getTime();
var mod = endTime % splitTime;
if (mod > 0) {
endTime -= mod;
}
var dateArray = [];
while (count-- > 0) {
var d = new Date();
d.setTime(endTime - count * splitTime);
dateArray.push(checkTime(d.getHours()) + ':' + checkTime(d.getMinutes()) + ":00");
}
return dateArray;
}

//小时、分钟小于10时补上前面的0
function checkTime(i) {
if (i < 10) {
i = "0" + i;
}
return i;
}

创建echarts的初始option

各个配置项的含义可参考echarts官网,这里的个性化配置主要是工具栏、缩放条、X轴指示器。

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
var option = {
grid: {
top: '10%',
left: '5%'
},
title: {
text: '机票每分钟订单量'
},
tooltip: {
trigger: 'axis'
},
toolbox: { //可视化的工具箱
show: true,
feature: {
dataView: { //数据视图
show: true
},
restore: { //重置
show: true
},
dataZoom: { //数据缩放视图
show: true
},
saveAsImage: {//保存图片
show: true
},
magicType: {//动态类型切换
type: ['bar', 'line']
}
}
},
legend: {
data: []
// orient: 'vertical'
},
dataZoom: [
{
type: 'slider',
xAxisIndex: 0,
start: 0,
end: 100
},
{
type: 'inside',
xAxisIndex: 0,
start: 0,
end: 100
},
{
type: 'slider',
yAxisIndex: 0,
start: 0,
end: 100
},
{
type: 'inside',
yAxisIndex: 0,
start: 0,
end: 100
}
],
xAxis: {
data: getDateArray(),
axisPointer: {
show: true
}
},
yAxis: {},
series: []
};

创建标签组件

因为标签组件带有“X”(删除)标记,涉及到删除折线(afterDeletingTag配置项,即删除option.series中对应的数据再setOption),所以放到这里介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function bulidTags() {
tags = $('#my-tag-list').tags({
tagData: ["2017-06-01"],
promptText: "显示在图表中的日期列表",
tagSize: 'lg',
afterDeletingTag: function (tag) {
if ($.inArray(tag, option.legend.data) >= 0) {
option.legend.data = option.legend.data.filter(function (item) {
return tag !== item;
});
option.series = option.series.filter(function (item) {
return tag !== item.name;
});
echart.setOption(option, true);
}
}
});
}

添加某天数据到图表

主要是请求后端获取数据后,更新option,然后调用setOption展示。

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
//响应用户请求,展示某天数据
function showLine() {
var someday = $('#dateInput').val();
if (!someday) {
someday = '2017-06-01'
}
if (option.legend.data.length > 4) {
BootstrapDialog.alert('<p class="imporMsg">不能多于5条数据</p>');
return;
}
if ($.inArray(someday, option.legend.data) >= 0) {
return;
}

tags.addTag(someday);
$.ajax({
type: 'GET',
url: '/flight_minute_orders/date/' + someday
}).done(function (res) {
if (res && res.data) {
var tempSeries = {};
tempSeries.name = someday;
tempSeries.type = 'line';
tempSeries.calculable = true;
tempSeries.data = [];
res.data.forEach(function (ele, index) {
tempSeries.data.push(ele['orderNum']);
});
option.legend.data.push(someday);
option.series.push(tempSeries);
echart.setOption(option);
}
});
}

清空图表数据

实际上就是对标签组件中的每个日期,调用删除标签

1
2
3
4
5
6
function clearCharts() {
var length = tags.tagData.length;
for (var i = 0; i < length; i++) {
tags.removeLastTag();
}
}

更新数据到数据库

由于用户可以在数据视图中编辑数据,所以想到了可以利用此处的数据作为更新后的数据来支持更新数据库操作,当然这么大量数据肯定要用批量更新,同时,采用只更新修改过的数据可以提高执行效率。也可以考虑直接在图表视图中拖拽线条来编辑数据,这个功能echarts本身没提供,需要自定义实现,之后有时间会考虑实现这种方式。

数据视图如下:

chart2

点右下角刷新后点击更新按钮,将当前数据更新至数据库。

通过查看工具栏中数据视图的源码可以发现刷新操作是利用当前编辑栏的数据new了一个newOption再setOption完成,并没有修改原有的option对象,所以不能通过原有option获得当前数据,而是通过echarts.getOption来拿到当前数据。看刷新操作源码,可以发现最后一部分是通过事件触发器来触发setOption(newOption)操作。所以可以通过newOption与option对比来得到数据项是否有修改。

更新代码如下。

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
function update() {
if (option.series.length === 0) {
BootstrapDialog.alert('<p class="imporMsg">没有数据,无法更新</p>');
return;
}

BootstrapDialog.show({
size: 'size-wide',
title: '请确认',
message: $('<div class="imporMsg">确定要更新到数据库吗?</div>'),
buttons: [{
label: '确认',
cssClass: 'btn btn-primary',
action: function (dialog) {
var modelList = [];
var currentSeries = option.series;
echart.getOption().series.forEach(function (ele) {
var dateArray = getDateArray();
var currentSerie = currentSeries.shift();
ele.data.forEach(function (ele2) {
var currentTime = dateArray.shift();
if (parseInt(ele2) !== currentSerie.data.shift()) {
var model = {};
model.datetime = ele.name + " " + currentTime;
model.orderNum = ele2;
modelList.push(model);
}
});
});
if (modelList.length > 0) {
$.ajax({
type: 'POST',
data: JSON.stringify(modelList),
contentType: 'application/json; charset=utf-8',
url: '/flight_minute_orders/update'
}).done(function (res) {
if (res.status === 0) {
dialog.close();
BootstrapDialog.alert('<p class="imporMsg">更新成功!</p>');
}
});
} else {
dialog.close();
BootstrapDialog.alert('<p class="imporMsg">数据已是最新的!</p>');
}
}
}]
});
}

完整代码

js完整代码如下:

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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
$(function () {
resizeContainer(); //容器大小初始化
echart = echarts.init(container); //创建echarts实例
buildDatepicker(); //创建日期选择器
bulidTags(); //创建标签插件
showLine(); //绘制echarts图像,默认日期2017-06-01

$('#addLine').on('click', showLine); //监听添加按钮,添加echarts折线
$('#clearCharts').on('click', clearCharts); //监听清空按钮,清除echarts数据
$('#updateCharts').on('click', update); //监听更新按钮,将数据更新到数据库
resize(); //监听窗口大小,自动调整chart
});

var container = document.getElementById('main');
var tags;
var echart;
var option = {
grid: {
top: '10%',
left: '5%'
},
title: {
text: '机票每分钟订单量'
},
tooltip: {
trigger: 'axis'
},
toolbox: { //可视化的工具箱
show: true,
feature: {
dataView: { //数据视图
show: true
},
restore: { //重置
show: true
},
dataZoom: { //数据缩放视图
show: true
},
saveAsImage: {//保存图片
show: true
},
magicType: {//动态类型切换
type: ['bar', 'line']
}
}
},
legend: {
data: []
// orient: 'vertical'
},
dataZoom: [
{
type: 'slider',
xAxisIndex: 0,
start: 0,
end: 100
},
{
type: 'inside',
xAxisIndex: 0,
start: 0,
end: 100
},
{
type: 'slider',
yAxisIndex: 0,
start: 0,
end: 100
},
{
type: 'inside',
yAxisIndex: 0,
start: 0,
end: 100
}
],
xAxis: {
data: getDateArray(),
axisPointer: {
show: true
}
},
yAxis: {},
series: []
};

//获取24小时内每隔一分钟的时间数组(默认)
function getDateArray(endDate, splitTime, count) {
if (!endDate) {
endDate = new Date('2000-01-01 23:59:00');
}
if (!splitTime) {
splitTime = 60 * 1000;
}
if (!count) {
count = 1440;
}
var endTime = endDate.getTime();
var mod = endTime % splitTime;
if (mod > 0) {
endTime -= mod;
}
var dateArray = [];
while (count-- > 0) {
var d = new Date();
d.setTime(endTime - count * splitTime);
dateArray.push(checkTime(d.getHours()) + ':' + checkTime(d.getMinutes()) + ":00");
}
return dateArray;
}

//小时、分钟小于10时补上前面的0
function checkTime(i) {
if (i < 10) {
i = "0" + i;
}
return i;
}

//响应用户请求,展示某天数据
function showLine() {
var someday = $('#dateInput').val();
if (!someday) {
someday = '2017-06-01'
}
if (option.legend.data.length > 4) {
BootstrapDialog.alert('<p class="imporMsg">不能多于5条数据</p>');
return;
}
if ($.inArray(someday, option.legend.data) >= 0) {
return;
}

tags.addTag(someday);
$.ajax({
type: 'GET',
url: '/flight_minute_orders/date/' + someday
}).done(function (res) {
if (res && res.data) {
var tempSeries = {};
tempSeries.name = someday;
tempSeries.type = 'line';
tempSeries.calculable = true;
tempSeries.data = [];
res.data.forEach(function (ele, index) {
tempSeries.data.push(ele['orderNum']);
});
option.legend.data.push(someday);
option.series.push(tempSeries);
echart.setOption(option);
}
});
}

//清空图表中的数据
function clearCharts() {
var length = tags.tagData.length;
for (var i = 0; i < length; i++) {
tags.removeLastTag();
}
}

//使chart容器自适应window大小
function resizeContainer() {
container.style.width = (window.innerWidth - 300) + 'px';
container.style.height = (window.innerHeight - 100) + 'px';
}

//window大小变化时,chart自动调整
function resize() {
//用于使chart自适应高度和宽度
window.onresize = function () {
//重置容器高宽
resizeContainer(container);
echart.resize();
};
}

function buildDatepicker() {
$("#datetimepicker").datepicker({
format: "yyyy-mm-dd",
startDate: '2017-06-01',
endDate: '2017-06-21',
autoclose: true,
language: 'zh-CN'
});
}

function bulidTags() {
tags = $('#my-tag-list').tags({
tagData: ["2017-06-01"],
promptText: "显示在图表中的日期列表",
tagSize: 'lg',
afterDeletingTag: function (tag) {
if ($.inArray(tag, option.legend.data) >= 0) {
option.legend.data = option.legend.data.filter(function (item) {
return tag !== item;
});
option.series = option.series.filter(function (item) {
return tag !== item.name;
});
echart.setOption(option, true);
}
}
});
}

function update() {
if (option.series.length === 0) {
BootstrapDialog.alert('<p class="imporMsg">没有数据,无法更新</p>');
return;
}

BootstrapDialog.show({
size: 'size-wide',
title: '请确认',
message: $('<div class="imporMsg">确定要更新到数据库吗?</div>'),
buttons: [{
label: '确认',
cssClass: 'btn btn-primary',
action: function (dialog) {
var modelList = [];
var currentSeries = option.series;
echart.getOption().series.forEach(function (ele) {
var dateArray = getDateArray();
var currentSerie = currentSeries.shift();
ele.data.forEach(function (ele2) {
var currentTime = dateArray.shift();
if (parseInt(ele2) !== currentSerie.data.shift()) {
var model = {};
model.datetime = ele.name + " " + currentTime;
model.orderNum = ele2;
modelList.push(model);
}
});
});
if (modelList.length > 0) {
$.ajax({
type: 'POST',
data: JSON.stringify(modelList),
contentType: 'application/json; charset=utf-8',
url: '/flight_minute_orders/update'
}).done(function (res) {
if (res.status === 0) {
dialog.close();
BootstrapDialog.alert('<p class="imporMsg">更新成功!</p>');
}
});
} else {
dialog.close();
BootstrapDialog.alert('<p class="imporMsg">数据已是最新的!</p>');
}
}
}]
});
}

代码已开源至Github。由于有一些内容依赖公司组件,所以clone下来并不能直接运行,需要剔除这些部分,如有疑问欢迎在评论交流。

坚持原创技术分享,您的支持将鼓励我继续创作!
分享到: