使用 echarts 绘制 2020 疫情折线图

2020 年开始到现在的时间,真是发生了太多的事情,心情复杂,但又没有什么话好说,只能以有限的方式表达着对这些事件的思考。

最近买了个服务器,也正在着手搭建网站,利用一下我已经学到的前端知识,第一个应用性的内容,就以这次疫情的情况为出发点吧!

了解 echarts

ECharts,缩写来自 Enterprise Charts,商业级数据图表,是一个开源的数据可视化工具,一个纯 Javascript 的图表库,能够在 PC 端和移动设备上流畅运行,兼容当前绝大部分浏览器(IE6/7/8/9/10/11,chrome,firefox,Safari 等),底层依赖轻量级的 Canvas 库 ZRender,ECharts 提供直观,生动,可交互,可高度个性化定制的数据可视化图表。创新的拖拽重计算、数据视图、值域漫游等特性大大增强了用户体验,赋予了用户对数据进行挖掘、整合的能力。

需求确定

使用一个绘图库(这要怎么手画啊.jpg),完成 2020 疫情的数据收集,并绘制折线图。

准备零:工程建立

使用 IDE 新建一个 html5 工程,目录结构如下所示

其实里面的大部分文件与这次任务并无太大关系,这样直接新建只是为了省事。

准备一:绘图库

经过网上一番搜索和对比后,本次统计图绘图任务我选择用 echarts 来完成。它的首页是 https://www.echartsjs.com/zh/index.html,还蛮精美。

点击 “下载” 来到它的下载界面。或直接访问 https://www.echartsjs.com/zh/download.html

网站提供了三种安装方式:

  1. 从下载的源代码或编译产物安装
  2. 从 npm 安装(npm install echarts
  3. 选择所需模块,在线定制安装

由于这次任务并不需要所有的图表支持,我选择第三种安装方式,点击 “在线定制” 即可进入定制页面。

选择需要的图表、坐标系、组件类型,在开发环境中我选择保留 IE8 的兼容性,同时不选择 “代码压缩” 以方便调试。

点击 “下载” 后,会出现一个编译界面,待到用到的所有组件加入完毕后,就可以下载生成的 js 文件了,默认文件名为echarts.js,我们把它保存在事先建好网页工程的 js 文件夹中。

同时生成一份 “代码压缩” 版的 js 以供线上利用,这个默认文件名为echarts.min.js

下载好所需的 js 文件以后,就可以开始工作了~

  1. 建立一个空的 html 文档,填写必要的网页信息。

    index.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <!doctype html>
    <html class="no-js" lang="zh">

    <head>
    <meta charset="utf-8">
    <title>2020肺炎疫情数据 - 即时更新</title>
    <!--省略其余meta相关代码-->
    </head>

    <body>
    <!--省略jQuery相关代码-->
    </body>

    </html>
  2. 引入echarts.js

    在线上环境时,我们将引入echarts.min.js

    index.html
    1
    2
    <!--head标签下-->
    <script src="js/echarts.js"></script>
  3. 为 Echarts 准备一个具有高宽的 DOM 容器

    index.html
    1
    2
    3
    4
      <body>
    <!-- 为 ECharts 准备一个具备大小(宽高)的 DOM -->
    <div id="main" style="width: 600px;height:400px;"></div>
    </body>
  4. 通过echarts.init方法初始化一个 echarts 实例,并通过setOption方法生成一个简单的统计图,大概是像这样的格式:

    index.html
    1
    2
    3
    4
    5
    6
    <script type="text/javascript">
    // 基于准备好的dom,初始化echarts实例
    var myChart = echarts.init(document.getElementById('main'));
    var option = {...}; //这里做好设置和填充数据
    myChart.setOption(option); //使用刚指定的配置项和数据显示图表
    </script>
    • options 中要怎么指定设置和数据呢?别急,我们先来看它的官方示例数据和它渲染出来的图:

      main.js
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
       option = {
      xAxis: {
      type: 'category',
      data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
      },
      yAxis: {
      type: 'value'
      },
      series: [{
      data: [820, 932, 901, 934, 1290, 1330, 1320],
      type: 'line'
      }]
      };
      • option 中有 xAxis、yAxis、series 三个对象,它们都拥有 type 和 data 两个属性。对于这个简单的示例来说:

        • xAxis 是横轴数据及类型描述
        • yAxis 是纵轴数据及类型描述
        • series 是系列描述,可以包含多组 “统计数据”

          在 echarts 里,系列(series)是指:一组数值以及他们映射成的图。“系列” 这个词原本可能来源于 “一系列的数据”,而在 echarts 中取其扩展的概念,不仅表示数据,也表示数据映射成为的图。所以,一个 系列包含的要素至少有:一组数值、图表类型(series.type)、以及其他的关于这些数据如何映射成图的参数。

          • 实际上,这也意味着可以在一个 echarts 对象上同时绘制多组类型各同或各异的统计数据(注意到 series 是一个数组!)!如果把以上例子的 series 数据稍作修改,就可以得到多折线的统计图,这也是本次数据处理任务的重要模型!
            1
            2
            3
            4
            5
            6
            7
            8
            9
            10
            11
            12
            13
            14
            15
            16
            17
            18
            19
            option = {
            xAxis: {
            type: 'category',
            data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
            },
            yAxis: {
            type: 'value'
            },
            series: [
            {
            data: [820, 932, 901, 934, 1290, 1330, 1320],
            type: 'line'
            },
            {
            data: [100,170,250,330,440,114,225],
            type: 'line'
            }
            ]
            };
      • xAxis、yAxis、series 实质为统计图的 “组件”。在 echarts 中,各种内容都是被抽象为 “组件” 的,除了前述的 xAxis、yAxis、series 以外,还有 grid、polar、geo 等组件,与本次任务关系并不大。

        • 所有的组件都在 option 对象中声明,可以是一个对象或者数组。
      • 上层的 option 对象描述了图表的各种需求,包括:有什么数据、要画什么图表、图表长什么样子、含有什么组件、组件能操作什么事情等等。这些设置内容通过setOption函数绑定到 echarts 对象上。

        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
                // 用 option 描述数据、数据如何映射成图形、交互行为等。
        // option 是个大的 JavaScript 对象。
        var option = {
        // option 每个属性是一类组件。
        legend: {...},
        grid: {...},
        tooltip: {...},
        toolbox: {...},
        dataZoom: {...},
        visualMap: {...},
        // 如果有多个同类组件,那么就是个数组。例如这里有三个 X 轴。
        xAxis: [
        // 数组每项表示一个组件实例,用 type 描述“子类型”。
        {type: 'category', /*...*/},
        {type: 'category', ...},
        {type: 'value', ...}
        ],
        yAxis: [{...}, {...}],
        // 这里有多个系列,也是构成一个数组。
        series: [
        // 每个系列,也有 type 描述“子类型”,即“图表类型”。
        {type: 'line', data: [['AA', 332], ['CC', 124], ['FF', 412], /_ ... _/ ]},
        {type: 'line', data: [2231, 1234, 552, /* ... */ ]},
        {type: 'line', data: [[4, 51], [8, 12], /* ... */ ]}
        ]
        };
        • 数据都在series.data中,也可通过 dataset 来取得数据。

          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19
           var option = {
          dataset: {
          source: [
          [121, "XX", 442, 43.11],
          [663, "ZZ", 311, 91.14],
          [913, "ZZ", 312, 92.12],
          /* ... */
          ],
          },
          xAxis: {},
          yAxis: {},
          series: [
          // 数据从 dataset 中取,encode 中的数值是 dataset.source 的维度 index(即第几列)
          { type: "bar", encode: { x: 1, y: 0 } },
          { type: "bar", encode: { x: 1, y: 2 } },
          { type: "scatter", encode: { x: 1, y: 3 } },
          /* ... */
          ],
          };

        总的来说,option 表述了:数据、数据如何映射成图形、交互行为。

      • 通过以上这几个步骤,我们可以绘制一个简单的统计图。

准备二:数据

本次使用的数据源为丁香园疫情实时播报网页。打开网站发现我们所要的数据在这里呈现:

右键或者 F12 打开开发者模式,发现数据来源为这段 JavaScript 代码

其中正好有我们需要的数据

1
2
3
4
5
6
7
8
9
{
/* .... */
currentConfirmedCount: 2934, //现存确诊
confirmedCount: 82691, //累计确诊数
suspectedCount: 806, //境外输入
curedCount: 76436, //累计治愈数
deadCount: 3321, //累计死亡数
/* .... */
}

使用 python requests 库爬下来,扔进 BeautifulSoup 解析一通,然后存到数据库中,其中数据库的结构为

1
2
3
4
5
6
7
8
create table `2020_pneumonia`
(
last_since datetime null, # 上次数据更新时间
proved int null, # 已确诊数
uncertain int null, # 未确诊数
died int null, # 死亡数
cured int null # 治愈数
);
crawl.py
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
req = requests.get(url='https://ncov.dxy.cn/ncovh5/view/pneumonia', headers=headers, verify=False)
req.encoding = req.apparent_encoding
soup = BeautifulSoup(req.text, 'html.parser')
final_data = {}
try:
import json
json_text = soup.find('script', {'id': 'getStatisticsService'}).get_text().replace(
'try { window.getStatisticsService = ',
'').replace('catch(e){}', '')
json_text = json_text[:-1]
data_dict = json.loads(json_text)
final_data['confirmed'] = int(data_dict['confirmedCount'])
final_data['suspected'] = int(data_dict['suspectedCount'])
final_data['cured'] = int(data_dict['curedCount'])
final_data['dead'] = int(data_dict['deadCount'])
final_data['last_since'] = data_dict['modifyTime'] # "modifyTime":单位是 ms!
except Exception as e:
# json 解析出错,几乎不可能出现(因为这样的话原来以js驱动的网页也显示不出数据了)
quit(-1)
finally:
try:
import datetime,time
# 省略连库过程
cur.execute('select last_since from 2020_pneumonia order by last_since desc')
ret = cur.fetchone()
logger.info(ret) # {'last_since': datetime.datetime(2020, 2, 9, 22, 55)}
ret_time = ret.get('last_since')
local_time = datetime.datetime.fromtimestamp(final_data['last_since']/1000)
if ret_time == local_time:
logger.info("[*]No need to insert!")
else:
date_in = datetime.datetime.fromtimestamp(final_data['last_since']/1000).strftime("%Y-%m-%d %H:%M:%S")
proved = final_data['confirmed']
uncertain = final_data['suspected']
died = final_data['dead']
cured = final_data['cured']
sql = "insert into 2020_pneumonia values('%s',%d,%d,%d,%d);"%(date_in,proved,uncertain,died,cured)
cur.execute(sql)
conn.commit()
logger.info("[√]Insert success!")
finally:
conn.close()

准备三:数据交互

编写一个 php 接口,借以从数据库中获取数据,其中接口行为设计如下:

1
2
3
4
POST api.php HTTP /1.1
action=getfulldata 获得全部数据,对应的 SQL 语句为 select _ from 表名,最多加上个 limit
action=getlatest 得到最新数据,对应的 SQL 语句可以写为 select _ from 表名 order by last_since desc limit 1
action=getsingleday&param={Y-m-d} 得到某一天的统计数据,SQL 语句是一个简单的条件查询,不再赘述了

稍后会在 html 网页中用 jQuery ajax 技术调用这个接口。

编写主网页

  1. body标签中,准备一个div容器,一会放我们的 echarts 图表。这个容器需要指明一个高度

    index.html
    1
    2
    3
    <div id="chartbox" style="width:100%;height:100%;margin:0 auto">
    <div id="main" style="width: 600px;height:calc(70% + 10px);margin:0 auto;"></div>
    </div>
  2. div容器中初始化 echarts 对象

    index.html
    1
    2
    var main = document.getElementById("main");
    var chart = echarts.init(main, "light"); //设置echarts显示主题为亮色主题
  3. 图标样式设置(chartOptions)

    main.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
    chartoption = {
    title: {
    text: "2020肺炎疫情数据",
    lineHeight: 40,
    height: 40,
    subtext: "数据来源:丁香园·丁香医生",
    sublink: "https://ncov.dxy.cn/ncovh5/view/pneumonia",
    },
    dataZoom: [
    {
    id: "dataZoomX",
    type: "inside",
    xAxisIndex: [0],
    filterMode: "empty",
    },
    {
    id: "dataZoomY",
    type: "inside",
    yAxisIndex: [0],
    filterMode: "empty",
    },
    ],
    dataset: {
    dimensions: ["last_since", "proved", "uncertain", "died", "cured"],
    source: [
    // {last_since:xx(datetime),proved:xx(int),uncertain:xx(int),died:xx(int),cured:(int)}
    ],
    },
    tooltip: {
    trigger: "axis",
    },
    toolbox: {
    show: true,
    orient: "vertical",
    top: 20,
    feature: {
    dataZoom: {
    yAxisIndex: "none",
    },
    // dataView: {
    // readOnly: true
    // },
    restore: {},
    saveAsImage: {
    title: "保存为图片…",
    },
    },
    },
    legend: {
    type: "scroll",
    data: ["proved", "uncertain", "died", "cured"],
    left: "right",
    },
    xAxis: {
    type: "category",
    // boundaryGap: false,
    },
    yAxis: {
    type: "value",
    },
    series: [
    {
    type: "line",
    },
    {
    type: "line",
    },
    {
    type: "line",
    },
    {
    type: "line",
    },
    ],
    animationEasing: "quarticOut",
    };

    然后再将设置对象绑定到之前的 chart 对象上

    main.js
    1
    chart.setOption(chartoption);
  4. 异步更新及获取数据,根据 echarts 官方文档的说法,只要在 jQuery 等工具异步获取完数据后,再调用 setOption 填入数据和配置项就行,非常方便

    main.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    $(document).ready(function () {
    $.post("api.php", { action: "getfull" }, "json").done(function (data) {
    chart.setOption({
    dataset: {
    source: data.contents, // 返回数据大致为{"success":true,"contents":[{},{},...]}
    },
    });
    });
    });
  5. 由于数据较多,加载时间较长,我们给 chart 对象添加一个简单的 loading 动画,在数据加载完成后,移除 loading 动画。

    main.js
    1
    2
    3
    4
    5
    chart.showLoading();
    $(document).ready(function () {
    //getting data with jQuery...
    chart.hideLoading();
    });

效果

  • 经过以上步骤后,打开网页,现在显示效果如下,虽然不怎么酷炫,但是已经基本上满足了我们的需求,可以看到大概的数据走向。可以用鼠标滚轮实现局部放大效果。调一调样式,就可以嵌入到一般网页中去使用了。

后续的思考和启示

  • 其实丁香园网页的开头还有几段 javascript 数据,多加研究的话还有可以值得提取和研究的东西,比如最近新加的国外疫情数据
  • 原来页面真的可以全用 javascript 渲染出来…… 丁香园疫情网页应该用到了类似于 webpack 和客户端渲染的东西,网页body元素中全是 javascript 代码
    • 具体来讲,应该是在script标签中加载了一些 js 和 css 文件
  • 数据获取部分本来想用 php 一起写掉,但奈何 php 中想要爬虫,只有使用 curl 库来进行抓取,且后续正则提取麻烦,索性放弃了