可视化巴以冲突10天伤亡情况

avatar
数据可视化 @蚂蚁集团

最近巴以冲突是人们关注的热点,这里我们就使用 G2 可视化一下其时间线,最后的效果如下图所示:

巴以冲突.jpg

那接下来就来看看如何用 G2 来绘制这张图,看完你将有如下的收获:

  • G2 如何设置图表的 title
  • G2 如何设置图表的背景颜色
  • G2 中 fold 数据处理的使用方法
  • G2 如何自定义映射颜色
  • G2 如何绘制数据驱动的 label
  • G2 如何绘制 html label
  • G2 如何自定义图例的文本

可交互的版本可以通过如下渠道看:

接下来我们就开始可视化。注意,这里更多是从技术视角介绍这张图的可视化思路,更多相关介绍请看新闻稿

准备

首先我们了解一下数据,该数据是一份表格数据,每一个数据行包含时间、事件以及当前两方的伤亡情况。

image.png

我们简单封装一个工具函数来简化可视化声明,这里用到了 G2 5.0 新的 Spec API

function g2(options) {
  const chart = new G2.Chart();
  chart.options(options);
  chart.render();
  return chart.getContainer();
}

思路

在开始写代码前,我们简单来梳理一下可视化的思路。上面这张图看起来复杂,其实主体本质上就是一个柱形图 + 面积图 + 数据标签,而我们可以通过类似“叠图层”的方式,将它们组合成一个图表,最后再优化一下样式即可。

image.png

下面就到了写代码时间。

视图

我们所有的内容都是放在如下的一个视图里的,然后通过 children 配置去添加不同的标记。

g2({
  type: "view",
  width: 800, // 一些布局信息
  height: 1200,
  insetTop: 15,
  insetRight: 10,
  marginRight: 150,
  data, // 可视化数据
  title: { // 图表标题
    title: "巴以冲突时间线",
    subtitle: "数据来源:新华网|统计时间:2023.10.17",
    titleFontWeight: "bolder",
    titleFontSize: 20
  },
  coordinate: { transform: [{ type: "transpose" }] }, // 向 y 方向绘制
  encode: { x: "date", y: (d) => d.death + d.injured }, // 一些共用的编码信息
  axis: false, // 隐藏坐标轴
  style: { viewFill: "rgb(253, 247, 239)" }, // 设置背景颜色
  children: [ // 添加标记,具体的配置见下面
    { type: "area", /* ... */ },
    { type: "interval", /* ... */ },
    {
      type: "line", /* ... */
      labels: [{/* ... */}, {/* ... */}]
    },
    { type: "point", /* ... */ },
    { type: "text", /* ... */ }
  ]
})

面积图

我们首先添加一个 Area Mark 绘制一个面积图,来作为我们的背景:

image.png

g2({
  // ...
  children: [
    {
      type: "area",
      tooltip: false,
      style: { fillOpacity: 0.1, fill: "red" }
    },
    // ...
  ]
})

条形图

接下来我们使用 Interval Mark 绘制图中的条形图:

image.png

这里我们首先需要使用 fold 数据处理方法将数据展开,使得每一个条对应一条数据,然后自定义颜色、图例文本和独立 x 通道的比例尺。

g2({
  // ...
  children: [
    // ...
    {
      type: "interval",
      scale: {
        x: { key: "interval", paddingInner: 0.15 }, // 独立 x 通道比例尺
        color: {
          range: [ // 自定义颜色
            "rgba(0, 0, 0, 0.8)",
            "rgba(0, 0, 0, 0.5)",
            "rgba(236, 61, 11, 0.8)",
            "rgba(236, 61, 11, 0.5)"
          ]
        }
      },
      legend: {
        color: {
          position: "bottom",
          labelFormatter: (d) => {
            const labelByValue = { // 自定义图例文本
              israelDeath: "以色列死亡人数",
              hamasDeath: "哈马斯死亡人数",
              israeInjured: "以色列受伤人数",
              hamasInjured: "哈马斯受伤人数"
            };
            return labelByValue[d];
          }
        }
      },
      data: {
        transform: [
          { // 展开数据
            type: "fold",
            fields: [
              "israelDeath",
              "hamasDeath",
              "israeInjured",
              "hamasInjured"
            ],
            key: "type",
            value: "value"
          }
        ]
      },
      // 根据展开数据指定通道
      encode: { y: "value", color: "type", series: "type" }
    },
    // ...
  ]
})

线和数据标签

这之后我们绘制那根代表伤亡总人数的趋势线,以及为时间和事件添加标注文本:

image.png

这里我们需要添加一个 Line Mark,以及通过其 labels 属性,给它添加两个数据标签。第一个用来展示时间,第一个用来展示事件。需要注意的是,因为事件文本需要突出伤亡的具体信息(数字),对文本的展示和布局有较高的要求,所以这里我们使用 html 绘制:

g2({
  // ...
  children: [
    // ...
    {
      type: "line", // 绘制趋势线
      style: {
        stroke: "black",
        shape: "smooth",
        strokeWidth: 10,
        lineCap: "round"
      },
      tooltip: false,
      labels: [
        { // 展示日期,属性都是数据驱动的
          text: (d) => {
            const N = d.date.split("-");
            return `${N[1]}.${N[2]}`;
          },
          fontFamily: "Alibaba Sans 102",
          letterSpacing: -1,
          fontSize: 32,
          fontWeight: "bold",
          dy: (d) => (+d.date.split("-").pop() === 17 ? -15 : 0),
          dx: (d) => (+d.date.split("-").pop() < 13 ? 30 : -30),
          textAnchor: (d) => (+d.date.split("-").pop() < 13 ? "start" : "end"),
          fillOpacity: 1
        },
        { // 展示事件信息
          text: (d) => d.event,
          // render 函数返回的 string 会作为 innerHTML 渲染到 DOM 树中
          render: (text, d) => {
            const date = +d.date.split("-").pop();
            const right = date < 13;
            const dx = right ? 65 : -380;
            const dy = right ? 15 : date === 17 ? -100 : -90;
            const span = (text) => {
              return `<span style="color:red;font-size:18px;font-weight:bold">${text}</span>`;
            };
            const textHtml = text
              .replace(d.injured, span(d.injured))
              .replace(d.death, span(d.death));
            const width = date === 12 ? 250 : 300;
            return `<div style="
                      width: ${width}px; 
                      font-size: 12px; 
                      transform: translate(${dx}px, ${dy}px);
                      padding: 5px;
                      color: black;
                      font-family: 'Alibaba Sans 102'"
                    >${textHtml}</div>`;
          }
        }
      ]
    },
    // ...
  ]
})

圆圈和 Emoji

最后我们来绘制背景的圆圈和 Emoji 表情:

image.png

g2({
  // ...
  children: [
    // ...
    { // 背景圆
      type: "point",
      encode: { size: 14 },
      style: {
        shape: "point",
        fill: "white",
        stroke: "red",
        strokeWidth: 3,
        fillOpacity: 1
      },
      tooltip: false
    },
    { // 用 text 标记去绘制 Emoji
      type: "text",
      encode: { text: "date" },
      scale: {
        text: { // 映射文本内容
          type: "ordinal",
          range: ["🚀", "🔥", "⚡️", "🏠", "✈️", "👊", "👊", "🙅"]
        }
      },
      style: {
        textAlign: "center",
        textBaseline: "middle",
        dx: 2,
        fontSize: 15
      }
    }
    // ...
  ]
})

小结

这篇文章简单教大家如何用 G2 去绘制比较复杂的可视化,同时也给大家展示了 G2 在保证一定易用性的基础上,给用户的所提供的灵活性。

最后,愿天下没有战争。

附录

最后附上 Live Demo 的地址和完整代码。

g2({
  type: "view",
  width: 800,
  height: 1200,
  insetTop: 15,
  insetRight: 10,
  marginRight: 150,
  data,
  title: {
    title: "巴以冲突时间线",
    subtitle: "数据来源:新华网|统计时间:2023.10.17",
    titleFontWeight: "bolder",
    titleFontSize: 20
  },
  coordinate: { transform: [{ type: "transpose" }] },
  encode: { x: "date", y: (d) => d.death + d.injured },
  axis: false,
  style: { viewFill: "rgb(253, 247, 239)" },
  children: [
    {
      type: "area",
      tooltip: false,
      style: { fillOpacity: 0.1, fill: "red" }
    },
    {
      type: "interval",
      scale: {
        x: { key: "interval", paddingInner: 0.15 },
        color: {
          range: [
            "rgba(0, 0, 0, 0.8)",
            "rgba(0, 0, 0, 0.5)",
            "rgba(236, 61, 11, 0.8)",
            "rgba(236, 61, 11, 0.5)"
          ]
        }
      },
      legend: {
        color: {
          position: "bottom",
          labelFormatter: (d) => {
            const labelByValue = {
              israelDeath: "以色列死亡人数",
              hamasDeath: "哈马斯死亡人数",
              israeInjured: "以色列受伤人数",
              hamasInjured: "哈马斯受伤人数"
            };
            return labelByValue[d];
          }
        }
      },
      data: {
        transform: [
          {
            type: "fold",
            fields: [
              "israelDeath",
              "hamasDeath",
              "israeInjured",
              "hamasInjured"
            ],
            key: "type",
            value: "value"
          }
        ]
      },
      encode: { y: "value", color: "type", series: "type" }
    },
    {
      type: "line",
      style: {
        stroke: "black",
        shape: "smooth",
        strokeWidth: 10,
        lineCap: "round"
      },
      tooltip: false,
      labels: [
        {
          text: (d) => {
            const N = d.date.split("-");
            return `${N[1]}.${N[2]}`;
          },
          fontFamily: "Alibaba Sans 102",
          letterSpacing: -1,
          fontSize: 32,
          fontWeight: "bold",
          dy: (d) => (+d.date.split("-").pop() === 17 ? -15 : 0),
          dx: (d) => (+d.date.split("-").pop() < 13 ? 30 : -30),
          textAnchor: (d) => (+d.date.split("-").pop() < 13 ? "start" : "end"),
          fillOpacity: 1
        },
        {
          text: (d) => d.event,
          render: (text, d) => {
            const date = +d.date.split("-").pop();
            const right = date < 13;
            const dx = right ? 65 : -380;
            const dy = right ? 15 : date === 17 ? -100 : -90;
            const span = (text) => {
              return `<span style="color:red;font-size:18px;font-weight:bold">${text}</span>`;
            };
            const textHtml = text
              .replace(d.injured, span(d.injured))
              .replace(d.death, span(d.death));
            const width = date === 12 ? 250 : 300;
            return `<div style="
                      width: ${width}px; 
                      font-size: 12px; 
                      transform: translate(${dx}px, ${dy}px);
                      padding: 5px;
                      color: black;
                      font-family: 'Alibaba Sans 102'"
                    >${textHtml}</div>`;
          }
        }
      ]
    },
    {
      type: "point",
      encode: { size: 14 },
      style: {
        shape: "point",
        fill: "white",
        stroke: "red",
        strokeWidth: 3,
        fillOpacity: 1
      },
      tooltip: false
    },
    {
      type: "text",
      encode: { text: "date" },
      scale: {
        text: {
          type: "ordinal",
          range: ["🚀", "🔥", "⚡️", "🏠", "✈️", "👊", "👊", "🙅"]
        }
      },
      style: {
        textAlign: "center",
        textBaseline: "middle",
        dx: 2,
        fontSize: 15
      }
    }
  ]
})