如何用 G2 可视化小帅和他的朋友之间故事

avatar
数据可视化 @蚂蚁集团

上个世纪一个名叫小帅的男子,他特别喜欢和不同的小伙伴运动,比如打打篮球、游泳等。同时他也喜欢把他们之间的运动事件按照表格的形式记录下来,常常回味:

image.png

为了更直观展现它们的运动事件,我们用 G2 可视化了一下上面的数据,得到了如下的结果:

image.png

接下来我们就来看看如何实现这张图,源码在这里

实现思路

我们先来看看这张图的实现思路。

可以发现,除去“浮华”的外表,这张图本质上就是一个折线图,只不过还添加了区间标注、emoji 文本以及图片数据标签。

image.png

可以把它们分别都想象成一个图层,然后一层层堆叠起来构成我们最后的图表。G2 的视图(View)就提供了堆叠图层(或者说标记 Mark)的功能。那我们接下来就一步步来看看,该如何实现。

代码实现

首先,我们封装一个工具方法,来简化代码:

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

也声明一个对时间格式化的函数:

function format(date) {
  const year = date.getFullYear();
  const month = date.getMonth() + 1;
  const wrapper = (d) => (d <= 9 ? "0" + d : d);
  return `${year}${month == 1 ? "" : "." + wrapper(month)}`;
}

声明视图

首先我们声明一个视图,去指定数据、宽高、标题和比例尺等全局的信息。

g2({
  type: "view",
  // 布局信息
  width: 900,
  insetBottom: 20,
  insetTop: 10,
  height: 1300,
  marginLeft: 30,
  marginRight: 30,
  // 按照时间排序
  data: data.sort((a, b) => new Date(a.date) - new Date(b.date)),
  scale: { y: { nice: true, range: [0, 1] } },
  axis: { y: { grid: true, label: false, tick: false }, x: { title: "时间" } },
  legend: { color: false },
  interaction: { tooltip: false },
  theme: { type: "dark" },
  // 图表标题
  title: {
    title: "小帅和他的朋友们",
    subtitle: "如有雷同,纯属巧合"
  },
  encode: {
    x: (d) => new Date(d.date),
    y: (_, i) => i + ""
  },
  children: [
    // ...
  ]
})

这之后我们就需要向 children 里面添加标记了。

添加区域标注

image.png

我们先来画我们的背景的标注。这里使用 RangeX 标记,同时使用 d3.group 方法来简单处理一下数据。对应代码如下:

g2({
  // ...
  children: [
     //...
    { 
      type: "rangeX", // rangeX 标记
      data: d3
        .groups(data, (d) => d.name)
        .map(([name, events]) => ({
          name,
          start: events[0].date,
          end: events[events.length - 1].date
        })),
      encode: {
        x: "start",
        x1: "end",
        color: "name"
      },
      style: { fillOpacity: 0.5 },
      labels: [
        { // 每个人的名字和有关联的事件
          text: (d) => `${d.name}(${format(d.start)}~${format(d.end)})`,
          position: "top",
          dy: -20,
          dx: (_, i) => (i === 2 ? 30 : 0)
        }
      ]
    }
    // ...
  ]
})

绘制折线图和 Emoji

image.png

这之后我们就可以绘制折线图以及 Emoji 了。其中折线图我么用 Line Mark 和 Point Mark 共同绘制,然后用 Text Mark 去绘制 Emoji,具体代码如下:

g2({
  // ...
  children: [
    { 
      type: "line", // Line Mark
      style: { shape: "smooth", stroke: "orange", strokeWidth: 3 }
    },
    {
      type: "point", // Point Mark
      encode: { size: 12, color: "#fff" },
      style: {
        shape: "point",
        stroke: "orange",
        strokeWidth: 3,
        fillOpacity: 1
      }
    },
    {
      type: "text", // Text Mark
      encode: { text: "type" },
      scale: {
        text: {
          // 通过比例尺将 type 映射为对应 Emoji
          type: "ordinal",
          domain: [0, 1, 2, 3, 4, 5],
          range: ["🏊‍♀️", "🏀", "🏸️", "🏓️", "🥏", "🏈"],
          independent: true
        }
      },
      style: {
        textAlign: "center",
        textBaseline: "middle",
        dx: 2,
        fontSize: 15
      }
    },
  ]
})

绘制事件和图片

最后我们来绘制事件文本和图片,这里文本用 Text Mark,同时图片用 HTML 标签:

image.png

g2({
  // ...
  children: [
     // ...
    { // Text Mark
      // 展示事件
      type: "text",
      tooltip: false,
      encode: { text: "events" },
      labels: [
        {
          text: (d) => format(d.date), // 展示日期
          position: "right",
          textAlign: (_, i) => (i < data.length - 2 ? "start" : "end"),
          dx: (_, i) => (i < data.length - 2 ? 0 : -20)
        },
        {
          text: "image", // 展示图片
          render: (_, d, i) => {
            const { image } = d;
            const dx = i === 0 ? 300 : i < data.length - 2 ? -180 : -120;
            return `<img 
                      src="${image}" 
                      height="50" 
                      style="transform: translate(${dx}%, -20%);border-radius:10px"
                   />`;
          },
          style: { opacity: 1 },
          position: "top-left"
        }
      ],
      style: {
        fill: "#eee",
        fontSize: 14,
        fontWeight: "bold",
        dx: (_, i) => (i < data.length - 2 ? 30 : -70),
        textAlign: (_, i) => (i < data.length - 2 ? "start" : "end")
      }
    }
  ]
})

完整代码

面是完整的代码,如果觉得不错的话就去给 G2 点个 star 吧!

g2({
  type: "view",
  width: 900,
  insetBottom: 20,
  insetTop: 10,
  height: 1300,
  marginLeft: 30,
  marginRight: 30,
  data: data.sort((a, b) => new Date(a.date) - new Date(b.date)),
  scale: { y: { nice: true, range: [0, 1] } },
  axis: { y: { grid: true, label: false, tick: false }, x: { title: "时间" } },
  legend: { color: false },
  interaction: { tooltip: false },
  theme: { type: "dark" },
  title: {
    title: "小帅和他的朋友们",
    subtitle: "如有雷同,纯属巧合"
  },
  encode: {
    x: (d) => new Date(d.date),
    y: (_, i) => i + ""
  },
  children: [
    {
      type: "rangeX",
      data: d3
        .groups(data, (d) => d.name)
        .map(([name, events]) => ({
          name,
          start: events[0].date,
          end: events[events.length - 1].date
        })),
      encode: {
        x: "start",
        x1: "end",
        color: "name"
      },
      style: { fillOpacity: 0.5 },
      labels: [
        {
          text: (d) => `${d.name}(${format(d.start)}~${format(d.end)})`,
          position: "top",
          dy: -20,
          dx: (_, i) => (i === 2 ? 30 : 0)
        }
      ]
    },
    {
      type: "line",
      style: { shape: "smooth", stroke: "orange", strokeWidth: 3 }
    },
    {
      type: "point",
      encode: { size: 12, color: "#fff" },
      style: {
        shape: "point",
        stroke: "orange",
        strokeWidth: 3,
        fillOpacity: 1
      }
    },
    {
      type: "text",
      encode: { text: "type" },
      scale: {
        text: {
          type: "ordinal",
          domain: [0, 1, 2, 3, 4, 5],
          range: ["🏊‍♀️", "🏀", "🏸️", "🏓️", "🥏", "🏈"],
          independent: true
        }
      },
      style: {
        textAlign: "center",
        textBaseline: "middle",
        dx: 2,
        fontSize: 15
      }
    },
    {
      type: "text",
      tooltip: false,
      encode: { text: "events" },
      labels: [
        {
          text: (d) => format(d.date),
          position: "right",
          textAlign: (_, i) => (i < data.length - 2 ? "start" : "end"),
          dx: (_, i) => (i < data.length - 2 ? 0 : -20)
        },
        {
          text: "image",
          render: (_, d, i) => {
            const { image } = d;
            const dx = i === 0 ? 300 : i < data.length - 2 ? -180 : -120;
            return `<img 
                      src="${image}" 
                      height="50" 
                      style="transform: translate(${dx}%, -20%);border-radius:10px"
                   />`;
          },
          style: { opacity: 1 },
          position: "top-left"
        }
      ],
      style: {
        fill: "#eee",
        fontSize: 14,
        fontWeight: "bold",
        dx: (_, i) => (i < data.length - 2 ? 30 : -70),
        textAlign: (_, i) => (i < data.length - 2 ? "start" : "end")
      }
    }
  ]
})