使用 Echarts 实现折线图中线条添加、删除、编辑
小帅
小白,也能改变浪花的方向,公众号:51reboot运维开发
介绍一下使用 Echarts 做数据统计分析,如何实现支持折线图的添加、删除、编辑更新至数据库,其中后端使用 spring+mybatis+mysql,前端使用 bootstrap 布局配合 bootstrap-datepicker、bootstrap-tags、bootstrap-dialog 和 echarts 插件。
设计
首先看下截图有个直观的对各要素的了解。然后具体展开各个元素。
整个页面采用 bootstrap 布局,具体样式就不赘述了。显示的为某一天 24 小时内每分钟的订单量。
然后可以看到导航栏下方右侧是echarts图表区,用于展示图表,右上角是图表工具栏,依次为数据视图、还原、开启区域缩放/关闭、保存图片、柱状/折线图切换。
左侧自上至下依次是一个日期选择组件、3 个功能按钮、标签组件(用于列出、删除某一天的数据,便于操作)。
3 个功能按钮分别为添加某一天(日期选择框)的数据、清空图表展示内容(不删除后台数据,只清空图表)、在数据视图编辑数据后更新当前数据至后台数据库。
实现
数据表
首先简单介绍下数据表结构:
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', '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'
后端
后端具体设计不赘述,重点说下后端的接口,以及批量更新操作。
接口
/flight_minute_orders/date/{date} //获取某一天的数据
/flight_minute_orders/update //批量更新数据
批量更新
批量更新
controller 接口定义:可以看到接收参数为 json 格式的对象数组,其中 FlightMinuteModel 为数据库持久层对象。
@RequestMapping(value = "/update", method = { RequestMethod.POST })
@JsonBody
public int batchUpdate(@RequestBody List<FlightMinuteModel> list){
Preconditions.checkNotNull(list);
return flightMinuteService.batchUpdate(list);
}
mybatis 中 XXXMapper.xml 中的相关配置:
<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)。
<!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 容器大小。如下:
//获得容器
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);
之后为了适应页面大小变化时,容器能自适应调整大小
//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'
});
}
构建横轴24小时每分钟数据
//获取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轴指示器。
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),所以放到这里介绍
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 展示。
//响应用户请求,展示某天数据
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();
}
}
更新数据到数据库
由于用户可以在数据视图中编辑数据,所以想到了可以利用此处的数据作为更新后的数据来支持更新数据库操作,当然这么大量数据肯定要用批量更新,同时,采用只更新修改过的数据可以提高执行效率。也可以考虑直接在图表视图中拖拽线条来编辑数据,这个功能 echarts 本身没提供,需要自定义实现,之后有时间会考虑实现这种方式。
数据视图如下:
点右下角刷新后点击更新按钮,将当前数据更新至数据库。
通过查看工具栏中数据视图的源码可以发现刷新操作是利用当前编辑栏的数据 new 了一个 newOption 再 setOption 完成,并没有修改原有的 option 对象,所以不能通过原有 option 获得当前数据,而是通过echarts.getOption 来拿到当前数据。看刷新操作源码,可以发现最后一部分是通过事件触发器来触发 setOption(newOption) 操作。所以可以通过 newOption 与 option 对比来得到数据项是否有修改。
更新代码如下。
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完整代码如下:
$(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(https://github.com/hellolvs/echarts-spring-mybatis)。由于有一些内容依赖公司组件,所以 clone 下来并不能直接运行,需要剔除这些部分,如有疑问欢迎在评论交流。
本文作者: Lvs