首发于奇舞周刊
疫情专题之ECHARTS 经验总结

疫情专题之ECHARTS 经验总结

最近开发肺炎疫情专题页,大量的使用到了 ECharts 地图和图表相关的功能。之前也没有使用过 ECharts,所以把一些稍微复杂的需求实现总结一下,希望以后能帮助到大家。

地图



不同的文字颜色

上面是一个非常简单的中国肺炎确诊数地图,其中的数据都是模拟非真实的数据。默认文字颜色都是黑色的,但是由于湖北的背景色比较重,所以需要换一个浅色的文字。省名都已经使用 label.formatter 方法进行了重新的格式化,而 formatter 支持 rich 文本,格式为 {<stylename>|text},可以针对部分标签进行样式个性化。

{
  geo: {
    label: {
      formatter(params) {
        const serieItem = dataList.find(({name}) => params.name === name);
        if (serieItem && serieItem.value > 1000) {
          return `{white|${params.name}}`;
        }
      },
      rich: {
        white: {
          fontSize: 14,
          color: '#FFF'
        }
      }
    }  
  }  
}

但是这么做了之后你会发现 hover 的时候也还是白色的,这时候因为 hover 的背景色变成亮色了就会不和谐了。后来发现其实 map 数据有一个 regions 属性可以支持对不同地区进行样式个性化,通过 regions.label 单独针对湖北进行标签颜色设置就不会影响 hover 时候的状态了。

{
  geo: {
    regions: dataList.filter(({value}) => value > 1000).map(({name}) => ({
      name,
      label: { color: '#FFF' }
    }))
  }
}

展示 tooltip

地图完成后需要自定义提示框,这个直接使用 tooltip.formatter 方法自定义即可。提示框是 ECharts 使用 HTML 追加上去的,所以我们可以愉快的忽略掉 tooltip 中所有的样式配置自己写 CSS 就好了。如果你的提示框里有按钮或者链接需要被点击,记得将 tooltip.enterable 设置成 true

除了 hover 展示提示框,产品还想要一进入页面就展示某个地区的提示框,这个时候就需要使用 dispatchAction() 来触发事件了。其中 seriesIndex 是必填参数,官网文档中写的 系列的 index,在 tooltip 的 trigger 为 axis 的时候可选。 把我误导了很久,一直以为是非必填参数。

myChart.dispatchAction({
  type: "showTip",
  seriesIndex: 0,
  name: "湖北"
});

挪动文字位置

地图完成后 PC 上效果还行,但是在移动端有些地方太小就不太好选中,比如香港和澳门。在明确地图点击区域范围无法修改,沟通之后他们想要把香港、澳门两个文字标注拖出来一点,然后将文字的点击区域变大来解决地图无法点击的问题。

ECharts 的地图库文件是使用的 geojson 格式编写,然后使用 ECharts 独有的万国码编码方式进行编码。可以打开库文件看一下,整体的结构还是 geojson 的结构,只是在 coordinate 字段对一系列的坐标值进行了编码压缩体积。下面是 ECharts geojson 的示例,其中 features.properties.cp 属性点的位置就是 ECharts 用来指定标签绘制的位置的。也就是说我们只要直接修改地图文件中的这个坐标点就能实现我们移动标签名的需求了。

{
  "type": "FeatureCollection",
  "features": [
    {
      "id": "820000",
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [113.5537109375, 22.1083984375],
            [113.5322265625, 22.17578125],
            [113.5498046875, 22.21484375],
            [113.6044921875, 22.1337890625],
            [113.5537109375, 22.1083984375]
          ]
        ],
        "encodeOffsets": [
          [116279, 22639]
        ]
      },
      "properties": {
        "cp": [114.54909, 21.198951],
        "name": "澳门",
        "childNum": 1
      }
    }
  ],
  "UTF8Encoding": false
}

修改坐标点,大家可以手工加减坐标调整,也可以使用 ECharts 的事件返回,它提供了 convertFromPixel() 方法将画布上的像素点转成成经纬度坐标。这样我们就可以通过鼠标点击来返回合适的坐标点了。

mapChart.getZr().on('click', params => {
  const pointInPixel = [params.offsetX, params.offsetY];
  if(!mapChart.containPixel('geo', pointInPixel)) {
    return;
  }
  const [longitude, latitude] = mapChart.convertFromPixel('geo', pointInPixel);
  console.log([logitude, latitude]);
});

将省市名称挪动到合适的位置后,根据产品需求还需要增大它们的点击区域。这个时候我们可以利用刚才的思路,点击的时候是可以获取到当前位置的坐标的,那么我只需要定义范围,在这个范围内的点击就认为点击到了名称并执行对应的操作即可。

const criticalValues = {
  香港: {
    latitude: {min: 21.351907733532478, max: 22.593942914070382},
    longitude: {min: 115.2082740504213, max: 118.71519666876716}
  },
  澳门: {
    latitude: {min: 19.817628981103297, max: 21.20578594758684},
    longitude: {min: 111.60393691489914, max: 115.2082740504213},
  }
};

function getTarget(mapChart, criticalValues, pointInPixel) {
  if(!mapChart.containPixel('geo', pointInPixel)) {
    return;
  }

  const [longitude, latitude] = mapChart.convertFromPixel('geo', pointInPixel);
  for(const province in criticalValues) {
    const {min: minLati, max: maxLati} = criticalValues[province].latitude;
    const {min: minLong, max: maxLong} = criticalValues[province].longitude;
    if(latitude < minLati || latitude > maxLati) {
      continue;
    }
    if(longitude < minLong || longitude > maxLong) {
      continue;
    }

    return province;
  }
}

mapChart.getZr().on('click', params => {
  const pointInPixel = [params.offsetX, params.offsetY];
  const province = getTarget(mapChart, criticalValues, pointInPixel);
  if(!province) {
    return;
  }

  mapChart.dispatchAction({
    type: 'showTip',
    seriesIndex: 0,
    name: province,
    position: 'top'
  });
});

增加辅助线

标签名字离远了之后产品又怕会对地图造成歧义歧义引发法律问题,所以让我再增加两根指向线将名称和地域进行指向标记。在 GeoJSON 中是支持使用 LineString 画线的,也是定义一些关键坐标即可。不过不知道是不是 ECharts 不支持,我这边尝试并没有成功。后来看到 ECharts 其实是支持类似跃迁图的,虽然大材小用,但也是能实现这个需求的。

{
  series: [
    {
      name: '辅助线',
      type: 'lines',
      silent: true,
      lineStyle: {
        color: 'black',
        opacity: 1,
      },
      data: [
        {coords: [
          [114.42895791301109, 22.155577556233474], 
          [115.5979321191264, 21.936394877315017]
        ]},
        {coords: [
          [113.45481274124836, 21.936394877315017], 
          [113.35739822407209, 21.20578594758684]
        ]},
      ]
    }
  ]
}

图表

多行图例

除了地图之外,页面内还有很多可视化图表的需求。其中有一个需求是需要实现将图例两行排列。ECharts 中是没有直接的参数控制图例的排列方式的。后来搜索到其实 legend 是可以定义成数组的,通过 legend.top 去控制每行图例的高度即可。

{
  legend: [
    { x: 'center', top: 40, icon: 'circle', itemWidth: 7, itemHeight: 7, textStyle: { color: '#888' }, data: ['新增病例', '累计病例'] },
    { x: 'center', top: 60, icon: 'circle', itemWidth: 7, itemHeight: 7, textStyle: { color: '#888' }, data: ['新增疑似', '累计疑似'] },
  ]
}



布局对齐

不等距Y轴



2月12日的时候,由于卫健委更改了肺炎疫情的确诊标准,把临床确诊的病例也加入进来,导致了当天病例数大量的增加,图表其它数据则由于该峰值其它数据都不明显了,结果则如上图。为了解决这个问题,数据统计的同事建议我们将该异常峰值在图表中的点位拉低,顶部 Y 轴刻度线不标记具体刻度,点上标记真实数字,相邻数据使用虚线连接。这样处理能比较好的解决单个点位异常峰值的问题,大家最后都比较认同这种处理方案。



那 ECharts 需要如何实现这种方案呢?总结一下我们应该需要实现以下三部分:

  1. 虚线连接相邻数据
  2. 异常值点位拉低,并将其真实数据使用文字标出
  3. 顶部实现一处不标记 Y 轴点位的坐标阴影区

异常值虚线连接相邻数据

我们知道 ECharts 是可以通过 series.lineStyle 来设置线段的样式的,但是它最大的问题是没办法设置部分线段的样式。取巧的办法就是将单条线段数据分成两条实线和虚线,实线显示的点位虚线数据补为空,虚线显示的点位实现补上空数据。这样就能分别针对两条数据进行线段样式设置了。

同时由于本质上是两条数据,我们为了让线段显示连续,所以在结合处做了重复数据显示。所以在 tooltip 显示的时候会出现两个重复数据显示以及空值显示等问题,需要使用 tooltip.formatter 单独处理一下。

{
  xAxis: {
    data: ['02.09', '02.10', '02.11', '02.12', '02.13', '02.14', '02.15']
  },
  series: [
    {
      name: "新增确诊",
      data: [2656, 3062, 2478, null, 2450, 2277, 1918]
    },
    {
      name: "新增确诊",
      data: [null, null, 2478, 15153, 2450, null, null],
      itemStyle: {
        normal: {
          lineStyle: {type: 'dotted'}
        }
      }
    }
  ],
  tooltip: {
    formatter(params) {
      //使用哈希表过滤掉同线段名称的重复数据以及空值
      const paramsObj = {};
      params.forEach(p => {
        if(p.value !== null) {
          paramsObj[p.seriesName] = p;
        }
      });

      const newParams = Object.values(paramsObj).map(p => p.marker + ' ' + p.seriesName + ': ' + value);
      return newParams.length ? params[0].name + '<br/>' + newParams.join('<br/>') : '';
    }
  },
}

异常值点位拉低

我们知道折线图图表点的所在高度是和它的值是有关系的,所以要实现异常值点位拉低,则需要显示的修改它的值才行。同时增加一个 raw 字段的映射标记下原始值,方便在 labeltooltip 中使用。

{
  series: [
    {
      name: "新增确诊",
      //为了拉低点位将异常值的显示点位值拉低到 4000,同时使用 except 记录真实值,方便 label 中使用
      data: [null, null, 2478, {value: 4000, except: 15153}, 2450, null, null],
    }
  ],
  label: {
    formatter(param) {
      if(param.data && param.data.except) {
        return param.data.except;
      }
    }
  }
}

不标记的Y轴空挡区

如何创建一个不标记数字的 Y 轴空档区?这个我查了下资料还真没找到方法,最后也是通过一个取巧的方法实现的。通过文档知道了 Y 轴是可以通过 yAxis.axisLabel.showMaxLabel 设置不显示最大值的标记。所以我将异常值设置成和 Y 轴最大刻度值一致,这样就能有一个顶部空档区了。最后综合下显示出来的效果就如下图。

{
  yAxis: {
    axisLabel: {
      showMaxLabel: false
    }
  }
}



数字排列

移动端因为位置太小的原因,我们最开始没有将折线数字外显的,点击后会出提示框显示当天的数据。后来老板觉得不直观,让我们加上了数字外显的逻辑。这个时候就会碰上某些地方因为数值的原因数字显示会挤在一块。而 label 顶多只能通过 label.position 统一设置该条数据的数字的显示位置是在上方还是在下方,对于局部的微调则无能为力了。

最后看到 label.formatter 是可以返回 {<style_name>|value} 这样的 rich 文本格式来自定义样式的,在自定义样式中可以设置 padding 来控制该点数字的偏移。例如下面的代码就实现了当前确诊数大于疑似数的时候,疑似数向下显示,否则确诊数向下显示的逻辑。

{
  series: [
    {
      name: '累计确诊',
      label: {
        formatter(param) {
          const {diagnosed, suspected} = data[data.length - 1 - param.dataIndex];
          if(parseInt(diagnosed) < parseInt(suspected)) {
            return `{padding|${param.value}}`;
          }
        },
        rich: {
          padding: {
            padding: [-40, 0, 0, 0]
          }
        }
      }
    },
    {
      name: '现有疑似',
      label: {
        formatter(param) {
          const {diagnosed, suspected} = data[data.length - 1 - param.dataIndex];
          if(parseInt(diagnosed) < parseInt(suspected)) {
            return `{padding|${param.value}}`;
          }
        },
        rich: {
          padding: {
            padding: [0, 0, -40, 0]
          }
        }
      }
    }
  ]
}



参考资料:

发布于 03-09

文章被以下专栏收录